Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/core/button/button-base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ export function ButtonBase({
<Element
{...(rest as HTMLAttributes<HTMLElement>)}
className={cx(elButton, className)}
// NOTE: we explicitly DO NOT use `isBusy` to properly disable button-based buttons because
// that can lead to timing issues with form submissions (disabled form elements are not part
// of the submitted form data). Thus, busy buttons are only ever ARIA disabled.
aria-disabled={!!isBusy || !!rest['disabled'] || !!ariaDisabled}
data-variant={variant}
data-size={size}
Expand Down
3 changes: 3 additions & 0 deletions src/core/button/button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ export const Disabled: Story = {
* Buttons that have been clicked and are performing some asynchronous action should be marked as busy using `isBusy`.
* Busy buttons are `aria-disabled`, show a spinner instead of the button's other icons, and look the same regardless
* of the button's variant.
*
* We explicitly DO NOT use mark busy buttons as disabled because that can lead to timing issues with form submissions
* (disabled form elements are not part of the submitted form data). Thus, busy buttons are only ever ARIA disabled.
*/
export const Busy: Story = {
args: {
Expand Down
3 changes: 3 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ export * from './chip'
export * from './chip-group'
export * from './compact-select-native'
export * from './dialog'
export * from './divider'
export * from './drawer'
export * from './empty-data'
export * from './features'
export * from './folder-tabs'
export * from './filter-bar'
export * from './label-text'
export * from './link'
Expand All @@ -26,6 +28,7 @@ export * from './primary-tabs'
export * from './secondary-tabs'
export * from './side-bar'
export * from './skeleton'
export * from './split-button'
export * from './status-indicator'
export * from './supplementary-info'
export * from './tag'
Expand Down
56 changes: 46 additions & 10 deletions src/core/split-button/action/__tests__/action-base.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { SplitButtonContext } from '../../context'
import { StarIcon } from '#src/icons/star'

import type { ReactNode } from 'react'
import type { SplitButtonContextType } from '../../context'

test('renders as a button element when `as="button"`', () => {
render(<SplitButtonActionBase as="button">Button</SplitButtonActionBase>, { wrapper })
render(<SplitButtonActionBase as="button">Button</SplitButtonActionBase>, { wrapper: Wrapper })
expect(screen.getByRole('button', { name: 'Button' })).toBeVisible()
})

Expand All @@ -15,17 +16,49 @@ test('renders as a link element when `as="a"`', () => {
<SplitButtonActionBase as="a" href="https://fake.url">
Link
</SplitButtonActionBase>,
{ wrapper },
{ wrapper: Wrapper },
)
expect(screen.getByRole('link', { name: 'Link' })).toBeVisible()
})

test('is ARIA disabled when `SplitButtonContext` has `busy="action"`', () => {
render(<SplitButtonActionBase as="button" />, {
wrapper: (props) => <Wrapper {...props} busy="action" />,
})
const button = screen.getByRole('button')
expect(button).toBeEnabled()
expect(button).toHaveAttribute('aria-disabled', 'true')
})

test('is ARIA disabled when `SplitButtonContext` has `busy="menu-item"`', () => {
render(<SplitButtonActionBase as="button" />, {
wrapper: (props) => <Wrapper {...props} busy="menu-item" />,
})

const button = screen.getByRole('button')
expect(button).toBeEnabled()
expect(button).toHaveAttribute('aria-disabled', 'true')
})

test('is ARIA disabled when `SplitButtonContext` has no `busy` but `aria-disabled` is true', () => {
render(<SplitButtonActionBase as="button" aria-disabled={true} />, { wrapper: Wrapper })

const button = screen.getByRole('button')
expect(button).toBeEnabled()
expect(button).toHaveAttribute('aria-disabled', 'true')
})

test('is ARIA disabled when `SplitButtonContext` is not busy but `aria-disabled` is true', () => {
render(<SplitButtonActionBase as="a" aria-disabled href="https://fake.url" />, { wrapper: Wrapper })
expect(screen.getByRole('link')).toHaveAttribute('aria-disabled', 'true')
})

test('applies correct data-* attributes', () => {
render(
<SplitButtonActionBase as="button" isDestructive>
Test Button
</SplitButtonActionBase>,
{ wrapper },
{ wrapper: Wrapper },
)

const button = screen.getByRole('button')
Expand All @@ -37,7 +70,7 @@ test('applies correct data-* attributes', () => {
})

test('applies size and variant from `SplitButtonContext`', () => {
render(<SplitButtonActionBase as="button" />, { wrapper })
render(<SplitButtonActionBase as="button" />, { wrapper: Wrapper })
const button = screen.getByRole('button')
expect(button).toHaveAttribute('data-size', 'medium')
expect(button).toHaveAttribute('data-variant', 'primary')
Expand All @@ -48,7 +81,7 @@ test('forwards additional props to the underlying element', () => {
<SplitButtonActionBase as="button" data-testid="my-button">
Test Button
</SplitButtonActionBase>,
{ wrapper },
{ wrapper: Wrapper },
)
expect(screen.getByTestId('my-button')).toBeVisible()
})
Expand All @@ -58,7 +91,7 @@ test('can display an icon on the left', () => {
<SplitButtonActionBase as="button" iconLeft={<StarIcon data-testid="icon" />}>
Test Button
</SplitButtonActionBase>,
{ wrapper },
{ wrapper: Wrapper },
)
expect(screen.getByTestId('icon')).toBeVisible()
})
Expand All @@ -68,7 +101,7 @@ test('does not display icon when busy', () => {
<SplitButtonActionBase as="button" iconLeft={<StarIcon data-testid="icon" />} isBusy>
Test Button
</SplitButtonActionBase>,
{ wrapper },
{ wrapper: Wrapper },
)
expect(screen.queryByTestId('icon')).not.toBeInTheDocument()
})
Expand All @@ -78,17 +111,20 @@ test('shows a spinner when busy', () => {
<SplitButtonActionBase as="button" iconLeft={<StarIcon data-testid="icon" />} isBusy>
Test Button
</SplitButtonActionBase>,
{ wrapper },
{ wrapper: Wrapper },
)
expect(screen.queryByTestId('icon')).not.toBeInTheDocument()
})

interface WrapperProps {
children: ReactNode
busy?: SplitButtonContextType['busy']
}

function wrapper({ children }: WrapperProps) {
function Wrapper({ children, busy }: WrapperProps) {
return (
<SplitButtonContext.Provider value={{ size: 'medium', variant: 'primary' }}>{children}</SplitButtonContext.Provider>
<SplitButtonContext.Provider value={{ busy, size: 'medium', variant: 'primary' }}>
{children}
</SplitButtonContext.Provider>
)
}
26 changes: 23 additions & 3 deletions src/core/split-button/action/action-base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,30 @@ export interface SplitButtonActionAsAnchorProps

export type SplitButtonActionBaseProps = SplitButtonActionAsButtonProps | SplitButtonActionAsAnchorProps

export function SplitButtonActionBase({ children, className, ...rest }: SplitButtonActionBaseProps) {
const { size, variant } = useSplitButtonContext()
/**
* A polymorphic button foundation that can render as either a button or anchor element.
* This component is used internally by the `SplitButtonAction` and `SplitButtonAnchorAction`
* components and should not be used directly by consumers.
*/
export function SplitButtonActionBase({
'aria-disabled': ariaDisabled,
children,
className,
isBusy,
...rest
}: SplitButtonActionBaseProps) {
const { busy, size, variant } = useSplitButtonContext()

return (
<ButtonBase {...rest} className={cx(elSplitButtonAction, className)} size={size} variant={variant}>
<ButtonBase
{...rest}
// If the menu button is busy, this action button should be ARIA disabled.
aria-disabled={ariaDisabled || busy === 'menu-item'}
className={cx(elSplitButtonAction, className)}
isBusy={isBusy || busy === 'action'}
size={size}
variant={variant}
>
{children}
</ButtonBase>
)
Expand Down
2 changes: 1 addition & 1 deletion src/core/split-button/action/action.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
},
decorators: [
(Story) => (
<SplitButtonContext.Provider value={{ size: 'medium', variant: 'primary' }}>

Check failure on line 34 in src/core/split-button/action/action.stories.tsx

View workflow job for this annotation

GitHub Actions / Test

Property 'busy' is missing in type '{ size: "medium"; variant: "primary"; }' but required in type 'SplitButtonContextType'.
<Story />
</SplitButtonContext.Provider>
),
Expand Down Expand Up @@ -63,10 +63,10 @@
decorators: [
(Story) => (
<div style={{ display: 'flex', gap: 'var(--spacing-6)' }}>
<SplitButtonContext.Provider value={{ size: 'medium', variant: 'primary' }}>

Check failure on line 66 in src/core/split-button/action/action.stories.tsx

View workflow job for this annotation

GitHub Actions / Test

Property 'busy' is missing in type '{ size: "medium"; variant: "primary"; }' but required in type 'SplitButtonContextType'.
<Story />
</SplitButtonContext.Provider>
<SplitButtonContext.Provider value={{ size: 'medium', variant: 'secondary' }}>

Check failure on line 69 in src/core/split-button/action/action.stories.tsx

View workflow job for this annotation

GitHub Actions / Test

Property 'busy' is missing in type '{ size: "medium"; variant: "secondary"; }' but required in type 'SplitButtonContextType'.
<Story />
</SplitButtonContext.Provider>
</div>
Expand Down Expand Up @@ -127,7 +127,7 @@
}

/**
* Actions can be busy using `isBusy`. In this case, the button will be disabled and the loading spinner
* Actions can be busy using `isBusy`. In this case, the button will be ARIA disabled and the loading spinner
* will be displayed.
*/
export const Busy: Story = {
Expand Down
6 changes: 5 additions & 1 deletion src/core/split-button/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ import { createContext, useContext } from 'react'
import type { ComponentProps } from 'react'
import type { SplitButton } from './split-button'

interface SplitButtonContextType {
export interface SplitButtonContextType {
/** Whether the main action button, menu button, or neither, is busy. */
busy: 'action' | 'menu-item' | undefined
/** The size of the main action and menu buttons. */
size: ComponentProps<typeof SplitButton>['size']
/** The variant used by the main action and menu buttons. */
variant: ComponentProps<typeof SplitButton>['variant']
}

Expand Down
57 changes: 49 additions & 8 deletions src/core/split-button/menu-button/__tests__/menu-button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,46 +3,87 @@ import { SplitButtonMenuButton } from '../menu-button'
import { SplitButtonContext } from '../../context'

import type { ReactNode } from 'react'
import type { SplitButtonContextType } from '../../context'

vi.mock('#src/icons/chevron-down', () => ({
ChevronDownIcon: () => <span data-testid="chevron-down-icon" />,
}))

test('renders a button element', () => {
render(<SplitButtonMenuButton aria-label="Open menu" />, { wrapper })
render(<SplitButtonMenuButton aria-label="Open menu" />, { wrapper: Wrapper })
expect(screen.getByRole('button', { name: 'Open menu' })).toBeVisible()
})

test('always has type="button"', () => {
render(<SplitButtonMenuButton aria-label="Expand" aria-expanded={true} />, { wrapper: Wrapper })
expect(screen.getByRole('button')).toHaveAttribute('type', 'button')
})

test('is ARIA disabled when `SplitButtonContext` has `busy="menu-item"`', () => {
render(<SplitButtonMenuButton aria-label="Expand" aria-expanded={true} />, {
wrapper: (props) => <Wrapper {...props} busy="menu-item" />,
})

const button = screen.getByRole('button')
expect(button).toBeEnabled()
expect(button).toHaveAttribute('aria-disabled', 'true')
})

test('is ARIA disabled when `SplitButtonContext` has `busy="action"`', () => {
render(<SplitButtonMenuButton aria-label="Expand" aria-expanded={true} />, {
wrapper: (props) => <Wrapper {...props} busy="action" />,
})

const button = screen.getByRole('button')
expect(button).toBeEnabled()
expect(button).toHaveAttribute('aria-disabled', 'true')
})

test('is ARIA disabled when `SplitButtonContext` is no `busy` but `aria-disabled` is true', () => {
render(<SplitButtonMenuButton aria-disabled={true} aria-label="Expand" aria-expanded={true} />, { wrapper: Wrapper })

const button = screen.getByRole('button')
expect(button).toBeEnabled()
expect(button).toHaveAttribute('aria-disabled', 'true')
})

test('forwards the `aria-expanded` prop', () => {
render(<SplitButtonMenuButton aria-label="Expand" aria-expanded={true} />, { wrapper })
render(<SplitButtonMenuButton aria-label="Expand" aria-expanded={true} />, { wrapper: Wrapper })
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true')
})

test('forwards additional props to the underlying button', () => {
render(<SplitButtonMenuButton aria-label="Test" data-testid="menu-btn" />, { wrapper })
render(<SplitButtonMenuButton aria-label="Test" data-testid="menu-btn" />, { wrapper: Wrapper })
expect(screen.getByTestId('menu-btn')).toBeVisible()
})

test('shows `ChevronDownIcon`', () => {
render(<SplitButtonMenuButton aria-label="Menu" />, { wrapper })
render(<SplitButtonMenuButton aria-label="Menu" />, { wrapper: Wrapper })
expect(screen.getByTestId('chevron-down-icon')).toBeVisible()
})

test('applies size and variant from `SplitButtonContext`', () => {
render(<SplitButtonMenuButton aria-label="Menu" />, { wrapper })
render(<SplitButtonMenuButton aria-label="Menu" />, { wrapper: Wrapper })

const button = screen.getByRole('button')
expect(button).toHaveAttribute('data-size', 'medium')
expect(button).toHaveAttribute('data-variant', 'primary')
})

test('applies custom `className`', () => {
render(<SplitButtonMenuButton aria-label="Menu" className="custom-class" />, { wrapper })
render(<SplitButtonMenuButton aria-label="Menu" className="custom-class" />, { wrapper: Wrapper })
expect(screen.getByRole('button')).toHaveClass('custom-class')
})

function wrapper({ children }: { children: ReactNode }) {
interface WrapperProps {
children: ReactNode
busy?: SplitButtonContextType['busy']
}

function Wrapper({ children, busy }: WrapperProps) {
return (
<SplitButtonContext.Provider value={{ size: 'medium', variant: 'primary' }}>{children}</SplitButtonContext.Provider>
<SplitButtonContext.Provider value={{ busy, size: 'medium', variant: 'primary' }}>
{children}
</SplitButtonContext.Provider>
)
}
13 changes: 13 additions & 0 deletions src/core/split-button/menu-button/menu-button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const Example: Story = {
'aria-expanded': false,
'aria-label': 'More actions',
disabled: false,
isBusy: false,
},
}

Expand Down Expand Up @@ -99,3 +100,15 @@ export const Disabled: Story = {
disabled: true,
},
}

/**
* If any menu items performs an asynchronous action, the menu button should be marked as busy until
* that action is complete. This can be achieved using `isBusy`. Busy buttons are disabled to prevent
* the action being triggered again.
*/
export const Busy: Story = {
args: {
...Example.args,
isBusy: true,
},
}
23 changes: 20 additions & 3 deletions src/core/split-button/menu-button/menu-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { useSplitButtonContext } from '../context'

import type { ButtonHTMLAttributes } from 'react'

interface SplitButtonMenuButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
// NOTE: We omit...
// - children, because it has no visual label
// - type, because it should always be type="button"
type AttributesToOmit = 'children' | 'type'

interface SplitButtonMenuButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, AttributesToOmit> {
/**
* Whether the action is disabled. This can be used to make the action appear disabled to users, but still be
* focusable. ARIA disabled actions, whether they are button or anchor DOM elements, will ignore click events.
Expand All @@ -29,14 +34,26 @@ interface SplitButtonMenuButtonProps extends Omit<ButtonHTMLAttributes<HTMLButto
* The `SplitButton.MenuButton` component is used to represent the menu button in a `SplitButton`. It will typically
* be used via `SplitButton.Menu`, which includes the `Menu` component baked-in.
*/
export function SplitButtonMenuButton({ className, ...rest }: SplitButtonMenuButtonProps) {
const { size, variant } = useSplitButtonContext()
export function SplitButtonMenuButton({
'aria-disabled': ariaDisabled,
className,
isBusy,
...rest
}: SplitButtonMenuButtonProps) {
const { busy, size, variant } = useSplitButtonContext()

return (
<Button
{...rest}
// If the main action is busy, this menu button should be ARIA disabled. Importantly,
// we do not allow `aria-disabled` to be forced to `false` when the split button
// communicates the menu is busy.
aria-disabled={ariaDisabled || busy === 'action'}
className={cx(elSplitButtonMenuButton, className)}
iconLeft={<ChevronDownIcon />}
isBusy={isBusy ?? busy === 'menu-item'}
size={size}
type="button"
variant={variant}
/>
)
Expand Down
Loading
Loading