From 75e873aefdb9e1d2d25968312b6ad3c7406b18d7 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 3 Aug 2026 20:42:58 -0400 Subject: [PATCH 1/3] feat(headless): add a Button primitive with focusableWhenDisabled --- .changeset/hip-moles-jam.md | 2 + packages/headless/package.json | 4 + .../headless/src/primitives/button/README.md | 65 ++++ .../src/primitives/button/button.test.tsx | 307 ++++++++++++++++++ .../headless/src/primitives/button/button.tsx | 101 ++++++ .../headless/src/primitives/button/index.ts | 1 + packages/headless/vite.config.ts | 1 + 7 files changed, 481 insertions(+) create mode 100644 .changeset/hip-moles-jam.md create mode 100644 packages/headless/src/primitives/button/README.md create mode 100644 packages/headless/src/primitives/button/button.test.tsx create mode 100644 packages/headless/src/primitives/button/button.tsx create mode 100644 packages/headless/src/primitives/button/index.ts 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..80ca6def1ca --- /dev/null +++ b/packages/headless/src/primitives/button/README.md @@ -0,0 +1,65 @@ +# 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, `Enter`, and `Space`. Focus is not pulled off its current element by a pointer press either. + +### 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('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).not.toHaveAttribute('tabindex'); + expect(button).toHaveAttribute('aria-disabled', 'true'); + }); + + 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..911c4f30af9 --- /dev/null +++ b/packages/headless/src/primitives/button/button.tsx @@ -0,0 +1,101 @@ +'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 + * + */ +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', + tabIndex: disabled && !focusableWhenDisabled ? undefined : 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 = suppressEvent; + merged.onKeyDown = suppressEvent; + merged.onKeyUp = suppressEvent; + // Keeps a pointer press from pulling focus off wherever it currently sits, matching + // what the `disabled` attribute does. + merged.onMouseDown = suppressEvent; + } + + 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', From 786dfe1444d43e26abe466a4c2c2ebc7889e5ada Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 4 Aug 2026 15:47:02 -0400 Subject: [PATCH 2/3] fix(headless): scope disabled Button suppression to activation keys --- .../headless/src/primitives/button/README.md | 4 +- .../src/primitives/button/button.test.tsx | 102 +++++++++++++++++- .../headless/src/primitives/button/button.tsx | 24 ++++- 3 files changed, 124 insertions(+), 6 deletions(-) diff --git a/packages/headless/src/primitives/button/README.md b/packages/headless/src/primitives/button/README.md index 80ca6def1ca..d27d1fa055e 100644 --- a/packages/headless/src/primitives/button/README.md +++ b/packages/headless/src/primitives/button/README.md @@ -30,6 +30,8 @@ import { Button } from '@/primitives/button'; The button keeps its place in the tab order, is marked `aria-disabled`, and ignores clicks, `Enter`, and `Space`. Focus is not pulled off its current element by a pointer press either. +Those suppressed events do not propagate, so a clickable ancestor does not fire — a natively disabled control dispatches no mouse event at all. Only the activation keys are suppressed: `Tab` still moves focus off the button, and `Escape` and the arrow keys still reach an enclosing dialog or menu. + ### Non-native element ```tsx @@ -62,4 +64,4 @@ Standard ` + + , + ); + + screen.getByRole('button', { name: 'Save' }).focus(); + await user.tab(); + + expect(screen.getByRole('textbox', { name: 'After' })).toHaveFocus(); + }); + + it('does not reach an ancestor click handler', async () => { + const user = userEvent.setup(); + const onAncestorClick = vi.fn(); + render( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- stands in for a clickable ancestor, which is the case under test +
+ +
, + ); + + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(onAncestorClick).not.toHaveBeenCalled(); + }); + + it('lets non-activation keys 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( @@ -261,10 +322,49 @@ describe('Button', () => { ); const button = screen.getByRole('button', { name: 'Save' }); - expect(button).not.toHaveAttribute('tabindex'); + 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(); diff --git a/packages/headless/src/primitives/button/button.tsx b/packages/headless/src/primitives/button/button.tsx index 911c4f30af9..dabdd1ef04b 100644 --- a/packages/headless/src/primitives/button/button.tsx +++ b/packages/headless/src/primitives/button/button.tsx @@ -27,8 +27,20 @@ function isLink(element: HTMLElement): boolean { return element.tagName === 'A' && element.hasAttribute('href'); } +// Propagation is stopped as well as the default action: a natively disabled control +// dispatches no mouse event at all, so an ancestor handler never sees the press either. function suppressEvent(event: React.SyntheticEvent): void { event.preventDefault(); + event.stopPropagation(); +} + +// Only the keys that activate a button. Suppressing every key would swallow `Tab` — +// trapping focus on the button `focusableWhenDisabled` exists to keep focusable — along +// with the `Escape` and arrow keys an enclosing dialog or menu listens for. +function suppressActivationKey(event: React.KeyboardEvent): void { + if (event.key === 'Enter' || event.key === ' ') { + suppressEvent(event); + } } /** @@ -43,7 +55,9 @@ function suppressEvent(event: React.SyntheticEvent): void { * // Button semantics on a link * */ -export const Button = React.forwardRef(function Button(props, ref) { +// `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 @@ -54,7 +68,9 @@ export const Button = React.forwardRef(function ? { type: 'button', disabled: nativelyDisabled ? disabled : undefined } : { role: 'button', - tabIndex: disabled && !focusableWhenDisabled ? undefined : 0, + // `-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. @@ -81,8 +97,8 @@ export const Button = React.forwardRef(function // 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 = suppressEvent; - merged.onKeyDown = suppressEvent; - merged.onKeyUp = suppressEvent; + merged.onKeyDown = suppressActivationKey; + merged.onKeyUp = suppressActivationKey; // Keeps a pointer press from pulling focus off wherever it currently sits, matching // what the `disabled` attribute does. merged.onMouseDown = suppressEvent; From b2fd4f97b05c6e249119a709a00995b5f731440d Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 4 Aug 2026 15:52:12 -0400 Subject: [PATCH 3/3] refactor(headless): align disabled-event handling with Base UI's useButton --- .../headless/src/primitives/button/README.md | 4 +-- .../src/primitives/button/button.test.tsx | 22 +----------- .../headless/src/primitives/button/button.tsx | 35 ++++++++++--------- 3 files changed, 22 insertions(+), 39 deletions(-) diff --git a/packages/headless/src/primitives/button/README.md b/packages/headless/src/primitives/button/README.md index d27d1fa055e..30c1a2bbf6e 100644 --- a/packages/headless/src/primitives/button/README.md +++ b/packages/headless/src/primitives/button/README.md @@ -28,9 +28,9 @@ import { Button } from '@/primitives/button'; ``` -The button keeps its place in the tab order, is marked `aria-disabled`, and ignores clicks, `Enter`, and `Space`. Focus is not pulled off its current element by a pointer press either. +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. -Those suppressed events do not propagate, so a clickable ancestor does not fire — a natively disabled control dispatches no mouse event at all. Only the activation keys are suppressed: `Tab` still moves focus off the button, and `Escape` and the arrow keys still reach an enclosing dialog or menu. +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 diff --git a/packages/headless/src/primitives/button/button.test.tsx b/packages/headless/src/primitives/button/button.test.tsx index 8e7aa35b564..e5080458eba 100644 --- a/packages/headless/src/primitives/button/button.test.tsx +++ b/packages/headless/src/primitives/button/button.test.tsx @@ -190,27 +190,7 @@ describe('Button', () => { expect(screen.getByRole('textbox', { name: 'After' })).toHaveFocus(); }); - it('does not reach an ancestor click handler', async () => { - const user = userEvent.setup(); - const onAncestorClick = vi.fn(); - render( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- stands in for a clickable ancestor, which is the case under test -
- -
, - ); - - await user.click(screen.getByRole('button', { name: 'Save' })); - - expect(onAncestorClick).not.toHaveBeenCalled(); - }); - - it('lets non-activation keys reach an ancestor', async () => { + it('lets keys still reach an ancestor', async () => { const user = userEvent.setup(); const onAncestorKeyDown = vi.fn(); render( diff --git a/packages/headless/src/primitives/button/button.tsx b/packages/headless/src/primitives/button/button.tsx index dabdd1ef04b..0713ad38feb 100644 --- a/packages/headless/src/primitives/button/button.tsx +++ b/packages/headless/src/primitives/button/button.tsx @@ -27,19 +27,19 @@ function isLink(element: HTMLElement): boolean { return element.tagName === 'A' && element.hasAttribute('href'); } -// Propagation is stopped as well as the default action: a natively disabled control -// dispatches no mouse event at all, so an ancestor handler never sees the press either. -function suppressEvent(event: React.SyntheticEvent): void { +function preventDefault(event: React.SyntheticEvent): void { event.preventDefault(); - event.stopPropagation(); } -// Only the keys that activate a button. Suppressing every key would swallow `Tab` — -// trapping focus on the button `focusableWhenDisabled` exists to keep focusable — along -// with the `Escape` and arrow keys an enclosing dialog or menu listens for. -function suppressActivationKey(event: React.KeyboardEvent): void { - if (event.key === 'Enter' || event.key === ' ') { - suppressEvent(event); +/** Replaces the consumer's handler while disabled, so theirs never runs. */ +function noop(): void {} + +// Every key but `Tab`, so focus can still move off the button — the whole point of keeping +// it focusable. Propagation is deliberately left alone: an enclosing dialog or menu still +// sees the key. +function preventDefaultUnlessTab(event: React.KeyboardEvent): void { + if (event.key !== 'Tab') { + event.preventDefault(); } } @@ -96,12 +96,15 @@ export const Button = React.forwardRef(function Button // 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 = suppressEvent; - merged.onKeyDown = suppressActivationKey; - merged.onKeyUp = suppressActivationKey; - // Keeps a pointer press from pulling focus off wherever it currently sits, matching - // what the `disabled` attribute does. - merged.onMouseDown = suppressEvent; + 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({