Skip to content

Commit

Permalink
feat(button): loading state for button
Browse files Browse the repository at this point in the history
  • Loading branch information
Powerplex committed Jun 8, 2023
1 parent ae0b7b2 commit e0e5a06
Show file tree
Hide file tree
Showing 8 changed files with 178 additions and 9 deletions.
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/components/button/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"dependencies": {
"@spark-ui/internal-utils": "^1.6.1",
"@spark-ui/slot": "^1.5.2",
"@spark-ui/spinner": "^0.2.8",
"class-variance-authority": "0.5.2"
},
"peerDependencies": {
Expand Down
9 changes: 9 additions & 0 deletions packages/components/button/src/Button.doc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ Compose the content of the button using the `Icon` component to add an icon to t

<Canvas of={ButtonStories.Icons} />

## Loading state

Use the `isLoading` prop to render the button in loading state.
This will prepend a spinner inside the button.

It preserves the width of your button to avoid content layout shifting.

<Canvas of={ButtonStories.LoadingState} />

## Link

Use `intent` prop to set the color intent of a button.
Expand Down
42 changes: 39 additions & 3 deletions packages/components/button/src/Button.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { StoryLabel } from '@docs/helpers/StoryLabel'
import { Checkbox } from '@spark-ui/checkbox'
import { Icon } from '@spark-ui/icon'
import { Check } from '@spark-ui/icons/dist/icons/Check'
import { FavoriteOutline } from '@spark-ui/icons/dist/icons/FavoriteOutline'
import { Meta, StoryFn } from '@storybook/react'
import { type ComponentProps } from 'react'
import { type ComponentProps, useState } from 'react'

import { Button } from '.'

Expand Down Expand Up @@ -35,7 +37,7 @@ export const Sizes: StoryFn = _args => (
<div className="gap-md flex flex-wrap items-center">
{sizes.map(size => {
return (
<Button key={size} size={size}>
<Button key={size} size={size} className="!bg-error">
Button {size}
</Button>
)
Expand Down Expand Up @@ -103,10 +105,44 @@ export const Icons: StoryFn = _args => (
</div>
)

export const LoadingState: StoryFn = () => {
const [isLoading, setIsLoading] = useState(true)

return (
<div className="gap-lg flex flex-col">
<Checkbox checked={isLoading} onClick={() => setIsLoading(!isLoading)}>
Toggle loading state
</Checkbox>

<div className="gap-md flex flex-wrap">
<div>
<StoryLabel>Spinner only</StoryLabel>
<Button isLoading={isLoading} loadingLabel="Loading...">
<Icon>
<FavoriteOutline />
</Icon>
Button (width is preserved)
</Button>
</div>

<div>
<StoryLabel>Spinner + text</StoryLabel>
<Button isLoading={isLoading} loadingText="Loading...">
<Icon>
<FavoriteOutline />
</Icon>
Button
</Button>
</div>
</div>
</div>
)
}

export const Link: StoryFn = _args => (
<div className="gap-md flex flex-wrap">
<Button asChild>
<a href="/">button</a>
<a href="/">Button as a link</a>
</Button>
</div>
)
35 changes: 35 additions & 0 deletions packages/components/button/src/Button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,41 @@ describe('Button', () => {
// Then
expect(element).toBeInTheDocument()
expect(document.querySelector('[data-spark-component="button"]')).toBeInTheDocument()
expect(document.querySelector('[data-spark-component="spinner"]')).not.toBeInTheDocument()
})

describe('Loading state', () => {
it('should display spinner and replace accessible name with hidden loading label', () => {
// Given
render(
<Button isLoading loadingLabel="Loading...">
Hello World!
</Button>
)

screen.debug(screen.getByText('Hello World!'))

// Then
expect(document.querySelector('[data-spark-component="spinner"]')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Loading...' })).toBeInTheDocument()
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
})

it('should display spinner and replace accessible name with visible loading text', () => {
// Given
render(
<Button isLoading loadingText="Loading...">
Hello World!
</Button>
)

screen.debug(screen.getByText('Hello World!'))

// Then
expect(document.querySelector('[data-spark-component="spinner"]')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Loading...' })).toBeInTheDocument()
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
})

it('should render as link', async () => {
Expand Down
78 changes: 74 additions & 4 deletions packages/components/button/src/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,60 @@
import { Slot } from '@spark-ui/slot'
import React, { PropsWithChildren } from 'react'
import { Spinner } from '@spark-ui/spinner'
import { cx } from 'class-variance-authority'
import React, { MouseEvent, PropsWithChildren, ReactNode } from 'react'

import { buttonStyles, type ButtonStylesProps } from './Button.styles'

/**
*
* When using Radix `Slot` component, it will consider its first child to merge its props with.
* In some cases, you might need to wrap the top child with additional markup without breaking this behaviour.
*/
const wrapPolymorphicSlot = (
asChild: boolean | undefined,
children: ReactNode,
callback: (children: ReactNode) => ReactNode
) => {
if (!asChild) return callback(children) // If polymorphic behaviour is not used, we keep the original children

return React.isValidElement(children)
? React.cloneElement(children, undefined, callback(children.props.children))
: null
}

export interface ButtonProps
extends PropsWithChildren<Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'disabled'>>,
ButtonStylesProps {
/**
* Change the component to the HTML tag or custom component of the only child.
*/
asChild?: boolean
/**
* Display a spinner to indicate to the user that the button is loading something after they interacted with it.
*/
isLoading?: boolean
/**
* If your loading state should only display a spinner, it's better to specify a label for it (a11y).
*/
loadingLabel?: string
/**
* If your loading state should also display a label, you can use this prop instead of `loadingLabel`.
* **Please note that using this can result in layout shifting when the Button goes from loading state to normal state.**
*/
loadingText?: string
}

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
design = 'filled',
disabled = false,
intent = 'primary',
isLoading = false,
loadingLabel,
loadingText,
onClick,
shape = 'rounded',
size = 'md',
asChild,
Expand All @@ -27,6 +64,16 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
ref
) => {
const Component = asChild ? Slot : 'button'
const isDisabled = !!disabled || isLoading

/**
* When using `asChild` (polymorphic) it's possible that the button becomes another HTML element.
* Depending on its tag, it could break the `disabled` html attribute preventing the clicks on a disabled button.
*/
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
if (isDisabled) event.preventDefault()
if (onClick) onClick(event)
}

return (
<Component
Expand All @@ -35,14 +82,37 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
className={buttonStyles({
className,
design,
disabled,
disabled: isDisabled,
intent,
shape,
size,
})}
disabled={!!disabled}
disabled={isDisabled}
aria-live={isLoading ? 'assertive' : 'off'}
onClick={handleClick}
{...others}
/>
>
{wrapPolymorphicSlot(asChild, children, slotted =>
isLoading ? (
<>
<Spinner
size="current"
className={loadingText ? 'inline-block' : 'absolute'}
{...(loadingLabel && { 'aria-label': loadingLabel })}
/>
{loadingText && loadingText}
<div
aria-hidden
className={cx('gap-md inline-flex', loadingText ? 'hidden' : 'opacity-0')}
>
{slotted}
</div>
</>
) : (
slotted
)
)}
</Component>
)
}
)
Expand Down
3 changes: 2 additions & 1 deletion packages/components/spinner/src/Spinner.styles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { cva, VariantProps } from 'class-variance-authority'

const defaultVariants = {
intent: 'current',
size: 'sm',
size: 'current',
isBackgroundVisible: false,
} as const

Expand All @@ -11,6 +11,7 @@ export const spinnerStyles = cva(
{
variants: {
size: {
current: ['w-[1em]', 'h-[1em]'],
sm: ['w-sz-20', 'h-sz-20'],
md: ['w-sz-28', 'h-sz-28'],
},
Expand Down
6 changes: 5 additions & 1 deletion packages/components/spinner/src/Spinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ export interface SpinnerProps extends ComponentPropsWithoutRef<'div'>, SpinnerSt
}

export const Spinner = forwardRef<HTMLDivElement, PropsWithChildren<SpinnerProps>>(
({ className, size, intent, label, isBackgroundVisible, ...others }, ref) => {
(
{ className, size = 'current', intent = 'current', label, isBackgroundVisible, ...others },
ref
) => {
return (
<div
role="status"
data-spark-component="spinner"
ref={ref}
className={spinnerStyles({ className, size, intent, isBackgroundVisible })}
Expand Down

0 comments on commit e0e5a06

Please sign in to comment.