diff --git a/.changeset/hip-moles-jam.md b/.changeset/hip-moles-jam.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/hip-moles-jam.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/package.json b/packages/headless/package.json index 1353767e78c..91e2b341f7c 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -9,6 +9,10 @@ "import": "./dist/primitives/accordion/index.js", "types": "./dist/primitives/accordion/index.d.ts" }, + "./button": { + "import": "./dist/primitives/button/index.js", + "types": "./dist/primitives/button/index.d.ts" + }, "./tabs": { "import": "./dist/primitives/tabs/index.js", "types": "./dist/primitives/tabs/index.d.ts" diff --git a/packages/headless/src/primitives/button/README.md b/packages/headless/src/primitives/button/README.md new file mode 100644 index 00000000000..30c1a2bbf6e --- /dev/null +++ b/packages/headless/src/primitives/button/README.md @@ -0,0 +1,67 @@ +# Button + +A button with the disabled behaviour a native `; +``` + +### Focusable while disabled + +```tsx + +``` + +The button keeps its place in the tab order, is marked `aria-disabled`, and ignores clicks and keyboard activation. Focus is not pulled off its current element by a pointer press either. + +Suppression is the consumer's handler plus the event's default action, not propagation. Events still bubble, so an enclosing dialog or menu keeps seeing them. Every key but `Tab` has its default prevented — `Tab` is exempt so focus can still move off the button, which is the point of keeping it focusable. + +### Non-native element + +```tsx + +``` + +## Props + +| Prop | Type | Default | Description | +| ----------------------- | --------------------- | ------- | ------------------------------------------------------------------ | +| `disabled` | `boolean` | `false` | Makes the button inert | +| `focusableWhenDisabled` | `boolean` | `false` | Keeps a disabled button in the tab order | +| `nativeButton` | `boolean` | `true` | Whether the rendered element is a real `); + + const button = screen.getByRole('button', { name: 'Save' }); + expect(button.tagName).toBe('BUTTON'); + expect(button).toHaveAttribute('type', 'button'); + }); + + it('lets the consumer override the type', () => { + render(); + + expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('type', 'submit'); + }); + + it('calls onClick when enabled', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('forwards its ref', () => { + const ref = React.createRef(); + render(); + + expect(ref.current).toBe(screen.getByRole('button', { name: 'Save' })); + }); + }); + + describe('disabled', () => { + it('sets the native disabled attribute', () => { + render(); + + const button = screen.getByRole('button', { name: 'Save' }); + expect(button).toBeDisabled(); + expect(button).not.toHaveAttribute('aria-disabled'); + expect(button).toHaveAttribute('data-disabled'); + }); + + it('is not reachable by keyboard', async () => { + const user = userEvent.setup(); + render(); + + await user.tab(); + + expect(screen.getByRole('button', { name: 'Save' })).not.toHaveFocus(); + }); + }); + + describe('focusableWhenDisabled', () => { + it('marks the button aria-disabled instead of disabled', () => { + render( + , + ); + + const button = screen.getByRole('button', { name: 'Save' }); + expect(button).not.toBeDisabled(); + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(button).toHaveAttribute('data-disabled'); + }); + + it('stays reachable by keyboard', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.tab(); + + expect(screen.getByRole('button', { name: 'Save' })).toHaveFocus(); + }); + + it('keeps focus when it becomes disabled', () => { + const { rerender } = render(); + const button = screen.getByRole('button', { name: 'Save' }); + button.focus(); + + rerender( + , + ); + + expect(button).toHaveFocus(); + }); + + it('does not call onClick on click', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(onClick).not.toHaveBeenCalled(); + }); + + it('does not call onClick on Enter or Space', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + , + ); + + screen.getByRole('button', { name: 'Save' }).focus(); + await user.keyboard('{Enter}'); + await user.keyboard(' '); + + expect(onClick).not.toHaveBeenCalled(); + }); + + it('does not submit the surrounding form', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault()); + render( +
+ +
, + ); + + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('can be tabbed past', async () => { + const user = userEvent.setup(); + render( +
+ + +
, + ); + + screen.getByRole('button', { name: 'Save' }).focus(); + await user.tab(); + + expect(screen.getByRole('textbox', { name: 'After' })).toHaveFocus(); + }); + + it('lets keys still reach an ancestor', async () => { + const user = userEvent.setup(); + const onAncestorKeyDown = vi.fn(); + render( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- stands in for a dialog listening for Escape +
+ +
, + ); + + screen.getByRole('button', { name: 'Save' }).focus(); + await user.keyboard('{Escape}'); + + expect(onAncestorKeyDown).toHaveBeenCalled(); + }); + + it('does not take focus on pointer interaction', async () => { + const user = userEvent.setup(); + render( +
+ + +
, + ); + + const input = screen.getByRole('textbox', { name: 'Name' }); + input.focus(); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(input).toHaveFocus(); + }); + }); + + describe('non-native button', () => { + it('applies button semantics to the rendered element', () => { + render( + , + ); + + const button = screen.getByRole('button', { name: 'Save' }); + expect(button.tagName).toBe('SPAN'); + expect(button).toHaveAttribute('tabindex', '0'); + expect(button).not.toHaveAttribute('type'); + }); + + it('activates on Enter and Space', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + , + ); + + screen.getByRole('button', { name: 'Save' }).focus(); + await user.keyboard('{Enter}'); + await user.keyboard(' '); + + expect(onClick).toHaveBeenCalledTimes(2); + }); + + it('activates a link once on Enter', async () => { + const user = userEvent.setup(); + const onClick = vi.fn((event: React.MouseEvent) => event.preventDefault()); + render( + , + ); + + screen.getByRole('button', { name: 'Save' }).focus(); + await user.keyboard('{Enter}'); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('drops out of the tab order when disabled', () => { + render( + , + ); + + const button = screen.getByRole('button', { name: 'Save' }); + expect(button).toHaveAttribute('tabindex', '-1'); + expect(button).toHaveAttribute('aria-disabled', 'true'); + }); + + // An anchor is tabbable on its own, so this needs the explicit `-1` rather than + // the absence of the attribute. + it('drops a disabled link out of the tab order', async () => { + const user = userEvent.setup(); + render( +
+ + +
, + ); + + await user.tab(); + + expect(screen.getByRole('textbox', { name: 'After' })).toHaveFocus(); + }); + + it('forwards a ref to the rendered element', () => { + const ref = React.createRef(); + render( + , + ); + + expect(ref.current).toBe(screen.getByRole('button', { name: 'Save' })); + }); + + it('stays in the tab order when disabled and focusable', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + , + ); + + await user.tab(); + const button = screen.getByRole('button', { name: 'Save' }); + expect(button).toHaveFocus(); + + await user.keyboard('{Enter}'); + expect(onClick).not.toHaveBeenCalled(); + }); + }); + + describe('accessibility', () => { + it('has no axe violations when disabled and focusable', async () => { + const { container } = render( + , + ); + + const results = await axe(container); + expect(results.violations).toEqual([]); + }); + }); +}); diff --git a/packages/headless/src/primitives/button/button.tsx b/packages/headless/src/primitives/button/button.tsx new file mode 100644 index 00000000000..0713ad38feb --- /dev/null +++ b/packages/headless/src/primitives/button/button.tsx @@ -0,0 +1,120 @@ +'use client'; + +import React from 'react'; + +import { type ComponentProps, mergeProps, useRender } from '../../utils'; + +/** Props for {@link Button}. */ +export interface ButtonProps extends ComponentProps<'button'> { + /** + * Keeps the button in the tab order while `disabled`. A button that disables itself + * mid-interaction — while a form submits, say — otherwise drops focus to the body and + * the user loses their place on the page. The button is marked `aria-disabled` rather + * than `disabled`, and stays inert to clicks and keyboard activation. + * @default false + */ + focusableWhenDisabled?: boolean; + /** + * Whether the rendered element is a native ` + * + * @example + * // Button semantics on a link + * + */ +// `HTMLElement` rather than `HTMLButtonElement`: `nativeButton={false}` renders an anchor or +// a span, and the ref has to accept one. +export const Button = React.forwardRef(function Button(props, ref) { + const { render, disabled = false, focusableWhenDisabled = false, nativeButton = true, ...otherProps } = props; + + // The `disabled` attribute is what makes a native button inert, but it also takes the + // button out of the tab order — the one thing `focusableWhenDisabled` exists to avoid. + const nativelyDisabled = nativeButton && !focusableWhenDisabled; + + const defaultProps: Record = nativeButton + ? { type: 'button', disabled: nativelyDisabled ? disabled : undefined } + : { + role: 'button', + // `-1` rather than dropping the attribute: an `
` is tabbable on its own, + // so omitting it would leave a disabled link in the tab order. + tabIndex: disabled && !focusableWhenDisabled ? -1 : 0, + onKeyDown: (event: React.KeyboardEvent) => { + if (event.key === ' ') { + // Space scrolls the page on anything that is not a native button. + event.preventDefault(); + } else if (event.key === 'Enter' && !isLink(event.currentTarget)) { + event.currentTarget.click(); + } + }, + onKeyUp: (event: React.KeyboardEvent) => { + if (event.key === ' ') { + event.currentTarget.click(); + } + }, + }; + + if (!nativelyDisabled) { + defaultProps['aria-disabled'] = disabled || undefined; + } + + const merged = mergeProps<'button'>(defaultProps, otherProps); + + if (disabled) { + // Without the `disabled` attribute the element still receives events, so they are + // suppressed here. These overwrite rather than chain: `mergeProps` runs the consumer's + // handler after ours, and a disabled button must not run it at all. + merged.onClick = preventDefault; + merged.onMouseDown = noop; + merged.onKeyUp = noop; + // Blocking the pointer press is what keeps focus on whatever currently holds it. It has + // to be `pointerdown` rather than `mousedown` — preventing that one does not stop focus. + merged.onPointerDown = preventDefault; + // Only a focusable disabled button needs its keys neutered; a natively disabled one + // never receives them. + merged.onKeyDown = focusableWhenDisabled ? preventDefaultUnlessTab : noop; + } + + return useRender({ + defaultTagName: 'button', + render, + ref, + state: { disabled }, + stateAttributesMapping: { + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), + }, + props: merged, + }); +}); diff --git a/packages/headless/src/primitives/button/index.ts b/packages/headless/src/primitives/button/index.ts new file mode 100644 index 00000000000..e43c452992b --- /dev/null +++ b/packages/headless/src/primitives/button/index.ts @@ -0,0 +1 @@ +export { Button, type ButtonProps } from './button'; diff --git a/packages/headless/vite.config.ts b/packages/headless/vite.config.ts index 23382fe0e80..70a8fcb5adf 100644 --- a/packages/headless/vite.config.ts +++ b/packages/headless/vite.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ lib: { entry: { 'primitives/accordion/index': 'src/primitives/accordion/index.ts', + 'primitives/button/index': 'src/primitives/button/index.ts', 'primitives/tabs/index': 'src/primitives/tabs/index.ts', 'primitives/tooltip/index': 'src/primitives/tooltip/index.ts', 'primitives/popover/index': 'src/primitives/popover/index.ts',