Skip to content

Commit 89be03b

Browse files
feat(headless): add a Button primitive with focusableWhenDisabled (#9319)
1 parent 167204b commit 89be03b

7 files changed

Lines changed: 582 additions & 0 deletions

File tree

.changeset/hip-moles-jam.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/headless/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
"import": "./dist/primitives/accordion/index.js",
1010
"types": "./dist/primitives/accordion/index.d.ts"
1111
},
12+
"./button": {
13+
"import": "./dist/primitives/button/index.js",
14+
"types": "./dist/primitives/button/index.d.ts"
15+
},
1216
"./tabs": {
1317
"import": "./dist/primitives/tabs/index.js",
1418
"types": "./dist/primitives/tabs/index.d.ts"
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Button
2+
3+
A button with the disabled behaviour a native `<button>` cannot express: staying focusable while inert, and behaving like a button on elements that aren't one.
4+
5+
## When to Use
6+
7+
- A button that disables itself mid-interaction — while a form submits, while a request is in flight. Use `focusableWhenDisabled` so focus is not dropped to the body and the user keeps their place on the page.
8+
- Rendering a button as a link or a `<span>`. Pass `nativeButton={false}` so the role, tab order, and Enter/Space activation are applied.
9+
- A plain always-enabled button needs neither prop, and a bare `<button>` is fine.
10+
11+
## Usage
12+
13+
```tsx
14+
import { Button } from '@/primitives/button';
15+
16+
<Button onClick={save}>Save</Button>;
17+
```
18+
19+
### Focusable while disabled
20+
21+
```tsx
22+
<Button
23+
type='submit'
24+
disabled={submitting}
25+
focusableWhenDisabled
26+
>
27+
Save
28+
</Button>
29+
```
30+
31+
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.
32+
33+
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.
34+
35+
### Non-native element
36+
37+
```tsx
38+
<Button
39+
nativeButton={false}
40+
render={<a href='/settings' />}
41+
>
42+
Settings
43+
</Button>
44+
```
45+
46+
## Props
47+
48+
| Prop | Type | Default | Description |
49+
| ----------------------- | --------------------- | ------- | ------------------------------------------------------------------ |
50+
| `disabled` | `boolean` | `false` | Makes the button inert |
51+
| `focusableWhenDisabled` | `boolean` | `false` | Keeps a disabled button in the tab order |
52+
| `nativeButton` | `boolean` | `true` | Whether the rendered element is a real `<button>` |
53+
| `render` | `RenderPropOrElement` || Renders a different element (see the shared `render` escape hatch) |
54+
55+
Standard `<button>` attributes pass through. `type` defaults to `"button"` on a native button and is overridable.
56+
57+
## Data Attributes
58+
59+
| Attribute | Description |
60+
| --------------- | --------------------- |
61+
| `data-disabled` | Present when disabled |
62+
63+
## ARIA
64+
65+
- Native + disabled: the `disabled` attribute.
66+
- Native + `disabled` + `focusableWhenDisabled`: `aria-disabled="true"`, no `disabled` attribute.
67+
- `nativeButton={false}`: `role="button"`, `tabIndex={0}` (`-1` when disabled without `focusableWhenDisabled`, since an `<a href>` is tabbable on its own and dropping the attribute would leave it in the tab order), `aria-disabled` when disabled. `Enter` and `Space` activate it; `Enter` on a link is left to the browser so it does not fire twice.

0 commit comments

Comments
 (0)