@primitiv-ui/react 0.1.29 → 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -2
- package/package.json +1 -1
- package/src/AccessibleIcon/AccessibleIcon.tsx +1 -1
- package/src/Accordion/Accordion.tsx +7 -7
- package/src/Accordion/README.md +2 -2
- package/src/Avatar/__tests__/Avatar.fallback.test.tsx +3 -3
- package/src/Breadcrumb/Breadcrumb.tsx +7 -7
- package/src/Breadcrumb/README.md +2 -2
- package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +1 -1
- package/src/Breadcrumb/types.ts +1 -1
- package/src/Button/__tests__/Button.contract.test.tsx +1 -1
- package/src/Carousel/Carousel.tsx +6 -6
- package/src/Carousel/README.md +22 -22
- package/src/Carousel/__tests__/Carousel.infinite.test.tsx +5 -5
- package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +2 -2
- package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +1 -1
- package/src/Carousel/loopEngine.ts +1 -1
- package/src/Carousel/types.ts +1 -1
- package/src/Checkbox/Checkbox.tsx +2 -2
- package/src/CheckboxCard/CheckboxCard.tsx +1 -1
- package/src/CheckboxCard/README.md +1 -1
- package/src/Collapsible/README.md +2 -2
- package/src/Combobox/Combobox.tsx +508 -0
- package/src/Combobox/ComboboxContext.ts +23 -0
- package/src/Combobox/README.md +170 -0
- package/src/Combobox/__tests__/Combobox.asChild.test.tsx +62 -0
- package/src/Combobox/__tests__/Combobox.basic-rendering.test.tsx +16 -0
- package/src/Combobox/__tests__/Combobox.content.test.tsx +36 -0
- package/src/Combobox/__tests__/Combobox.controlled-state.test.tsx +88 -0
- package/src/Combobox/__tests__/Combobox.empty.test.tsx +42 -0
- package/src/Combobox/__tests__/Combobox.ids.test.tsx +66 -0
- package/src/Combobox/__tests__/Combobox.item.test.tsx +76 -0
- package/src/Combobox/__tests__/Combobox.keyboard-interaction.test.tsx +196 -0
- package/src/Combobox/__tests__/Combobox.light-dismiss.test.tsx +101 -0
- package/src/Combobox/__tests__/Combobox.query.test.tsx +105 -0
- package/src/Combobox/hooks/index.ts +1 -0
- package/src/Combobox/hooks/useComboboxRoot.ts +2 -0
- package/src/Combobox/index.ts +3 -0
- package/src/Combobox/types.ts +133 -0
- package/src/ContextMenu/ContextMenu.tsx +3 -3
- package/src/ContextMenu/README.md +1 -1
- package/src/DirectionProvider/DirectionProvider.tsx +5 -5
- package/src/DirectionProvider/README.md +4 -4
- package/src/Divider/__tests__/Divider.contract.test.tsx +1 -1
- package/src/Drawer/Drawer.tsx +5 -5
- package/src/Drawer/README.md +2 -2
- package/src/Dropdown/Dropdown.tsx +2 -2
- package/src/Dropdown/README.md +7 -5
- package/src/EmptyState/README.md +2 -2
- package/src/Field/README.md +1 -1
- package/src/Field/hooks/useFieldProps.ts +1 -1
- package/src/Fieldset/Fieldset.tsx +3 -3
- package/src/Fieldset/README.md +2 -2
- package/src/Input/Input.tsx +1 -1
- package/src/Input/README.md +2 -2
- package/src/Listbox/Listbox.tsx +475 -0
- package/src/Listbox/ListboxContext.ts +48 -0
- package/src/Listbox/README.md +248 -0
- package/src/Listbox/__tests__/Listbox.active-descendant.test.tsx +128 -0
- package/src/Listbox/__tests__/Listbox.asChild.test.tsx +91 -0
- package/src/Listbox/__tests__/Listbox.basic-rendering.test.tsx +16 -0
- package/src/Listbox/__tests__/Listbox.controlled-state.test.tsx +114 -0
- package/src/Listbox/__tests__/Listbox.disabled.test.tsx +90 -0
- package/src/Listbox/__tests__/Listbox.fixtures.ts +27 -0
- package/src/Listbox/__tests__/Listbox.group-label.test.tsx +97 -0
- package/src/Listbox/__tests__/Listbox.group.test.tsx +45 -0
- package/src/Listbox/__tests__/Listbox.keyboard-edge-cases.test.tsx +144 -0
- package/src/Listbox/__tests__/Listbox.keyboard-interaction.test.tsx +112 -0
- package/src/Listbox/__tests__/Listbox.modifier-keys.test.tsx +86 -0
- package/src/Listbox/__tests__/Listbox.multi-select-keyboard.test.tsx +368 -0
- package/src/Listbox/__tests__/Listbox.multiple-mode.test.tsx +91 -0
- package/src/Listbox/__tests__/Listbox.mutation-hardening.test.tsx +480 -0
- package/src/Listbox/__tests__/Listbox.orientation.test.tsx +59 -0
- package/src/Listbox/__tests__/Listbox.reading-direction.test.tsx +65 -0
- package/src/Listbox/__tests__/Listbox.reorder.test.tsx +78 -0
- package/src/Listbox/__tests__/Listbox.selection-follows-focus.test.tsx +64 -0
- package/src/Listbox/__tests__/Listbox.strict-context.test.tsx +21 -0
- package/src/Listbox/__tests__/Listbox.typeahead.test.tsx +123 -0
- package/src/Listbox/__tests__/Listbox.uncontrolled-state.test.tsx +48 -0
- package/src/Listbox/constants.ts +2 -0
- package/src/Listbox/hooks/index.ts +1 -0
- package/src/Listbox/hooks/useListboxRoot.ts +407 -0
- package/src/Listbox/index.ts +3 -0
- package/src/Listbox/types.ts +238 -0
- package/src/MillerColumns/MillerColumns.tsx +104 -30
- package/src/MillerColumns/README.md +90 -20
- package/src/MillerColumns/__tests__/MillerColumns.aria.test.tsx +45 -0
- package/src/MillerColumns/__tests__/MillerColumns.auto-scroll.test.tsx +4 -2
- package/src/MillerColumns/__tests__/MillerColumns.basic-rendering.test.tsx +18 -3
- package/src/MillerColumns/__tests__/MillerColumns.data-attributes.test.tsx +48 -0
- package/src/MillerColumns/__tests__/MillerColumns.keyboard-interaction.test.tsx +35 -2
- package/src/MillerColumns/__tests__/MillerColumns.keyboard-selection-state.test.tsx +96 -0
- package/src/MillerColumns/__tests__/MillerColumns.preview-panel.test.tsx +29 -4
- package/src/MillerColumns/__tests__/MillerColumns.preview-visibility.test.tsx +79 -0
- package/src/MillerColumns/__tests__/MillerColumns.resize.test.tsx +189 -0
- package/src/MillerColumns/__tests__/MillerColumns.styled-parts.test.tsx +143 -0
- package/src/MillerColumns/__tests__/MillerColumns.typeahead.test.tsx +104 -0
- package/src/MillerColumns/constants.ts +2 -0
- package/src/MillerColumns/hooks/useMillerColumnsColumn.ts +5 -2
- package/src/MillerColumns/hooks/useMillerColumnsItem.ts +29 -7
- package/src/MillerColumns/hooks/useMillerColumnsResizeHandle.ts +80 -10
- package/src/MillerColumns/hooks/useMillerColumnsRoot.ts +0 -0
- package/src/MillerColumns/index.ts +4 -0
- package/src/MillerColumns/types.ts +56 -6
- package/src/MillerColumns/utils.ts +69 -3
- package/src/Modal/Modal.tsx +4 -4
- package/src/Modal/README.md +5 -5
- package/src/Modal/types.ts +1 -1
- package/src/NavigationMenu/NavigationMenu.tsx +6 -6
- package/src/NavigationMenu/README.md +3 -3
- package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +1 -1
- package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +1 -1
- package/src/Popover/Popover.tsx +2 -2
- package/src/Popover/README.md +1 -1
- package/src/Portal/Portal.tsx +2 -2
- package/src/Portal/README.md +2 -2
- package/src/Progress/README.md +1 -1
- package/src/Radio/Radio.tsx +1 -1
- package/src/RadioGroup/README.md +2 -2
- package/src/RadioGroup/RadioGroup.tsx +1 -1
- package/src/SegmentedControl/README.md +2 -2
- package/src/SegmentedControl/SegmentedControl.tsx +1 -1
- package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +1 -0
- package/src/Select/README.md +2 -2
- package/src/Select/Select.tsx +8 -8
- package/src/Select/__tests__/Select.placeholder.test.tsx +3 -3
- package/src/Select/__tests__/Select.typeahead.test.tsx +3 -3
- package/src/Select/__tests__/Select.value-mirror.test.tsx +1 -1
- package/src/Select/types.ts +2 -2
- package/src/SkipNav/README.md +3 -3
- package/src/SkipNav/SkipNav.tsx +5 -5
- package/src/Slider/README.md +37 -7
- package/src/Slider/Slider.tsx +9 -3
- package/src/Slot/Slot.tsx +2 -2
- package/src/Slot/__tests__/Slot.test.tsx +2 -2
- package/src/Slot/types.ts +1 -1
- package/src/SplitButton/README.md +225 -0
- package/src/SplitButton/SplitButton.tsx +457 -0
- package/src/SplitButton/SplitButtonContext.ts +26 -0
- package/src/SplitButton/__tests__/SplitButton.accessibility.test.tsx +55 -0
- package/src/SplitButton/__tests__/SplitButton.asChild.test.tsx +51 -0
- package/src/SplitButton/__tests__/SplitButton.basic-rendering.test.tsx +57 -0
- package/src/SplitButton/__tests__/SplitButton.data-attributes.test.tsx +28 -0
- package/src/SplitButton/__tests__/SplitButton.disabled.test.tsx +79 -0
- package/src/SplitButton/__tests__/SplitButton.error-handling.test.tsx +29 -0
- package/src/SplitButton/__tests__/SplitButton.keyboard-interaction.test.tsx +56 -0
- package/src/SplitButton/__tests__/SplitButton.state-modes.test.tsx +68 -0
- package/src/SplitButton/index.ts +2 -0
- package/src/SplitButton/types.ts +186 -0
- package/src/Switch/README.md +1 -1
- package/src/Switch/Switch.tsx +2 -2
- package/src/Switch/types.ts +1 -1
- package/src/Table/README.md +110 -8
- package/src/Table/Table.tsx +206 -10
- package/src/Table/TableExpandableContext.ts +7 -0
- package/src/Table/__tests__/Table.DetailRow.test.tsx +83 -0
- package/src/Table/__tests__/Table.ExpandTrigger.test.tsx +102 -0
- package/src/Table/__tests__/Table.Expandable.test.tsx +121 -0
- package/src/Table/types.ts +125 -0
- package/src/Tabs/README.md +4 -4
- package/src/Tabs/Tabs.tsx +5 -4
- package/src/Tabs/__tests__/Tabs.activation-mode.test.tsx +58 -0
- package/src/Tabs/__tests__/Tabs.basic-rendering.test.tsx +15 -0
- package/src/Tabs/__tests__/Tabs.change-event-callbacks.test.tsx +24 -0
- package/src/Tabs/__tests__/Tabs.disabled-tabs.test.tsx +36 -0
- package/src/Tabs/__tests__/Tabs.lazy-mount.test.tsx +17 -0
- package/src/Tabs/hooks/useTabsContent.ts +5 -0
- package/src/Tabs/hooks/useTabsRoot.ts +29 -5
- package/src/Tabs/hooks/useTabsTrigger.ts +7 -2
- package/src/ToggleGroup/README.md +4 -4
- package/src/Tooltip/README.md +7 -7
- package/src/Tooltip/Tooltip.tsx +2 -2
- package/src/Tooltip/__tests__/Tooltip.basic-rendering.test.tsx +50 -0
- package/src/Tooltip/__tests__/Tooltip.error-handling.test.tsx +37 -0
- package/src/Tooltip/__tests__/Tooltip.escape-hatches.test.tsx +73 -0
- package/src/Tooltip/__tests__/Tooltip.keyboard-interaction.test.tsx +134 -0
- package/src/Tooltip/__tests__/Tooltip.skip-delay.test.tsx +216 -0
- package/src/Tooltip/__tests__/Tooltip.timer-coordination.test.tsx +223 -0
- package/src/Tooltip/hooks/useTooltipContent.ts +3 -0
- package/src/Tooltip/hooks/useTooltipProvider.ts +40 -14
- package/src/Tooltip/hooks/useTooltipRoot.ts +30 -6
- package/src/Tree/README.md +69 -5
- package/src/Tree/Tree.tsx +1 -1
- package/src/Tree/__tests__/Tree.styled-wrapper.test.tsx +159 -0
- package/src/Tree/hooks/useTreePath.ts +33 -1
- package/src/Tree/index.ts +6 -1
- package/src/Tree/utils.ts +46 -4
- package/src/VisuallyHidden/VisuallyHidden.tsx +1 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useTypeahead.ts +95 -0
- package/src/index.ts +6 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.reading.test.tsx +31 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.resilience.test.tsx +97 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.ssr.test.tsx +20 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.writing.test.tsx +72 -0
- package/src/useLocalStorage/index.ts +1 -0
- package/src/useLocalStorage/useLocalStorage.ts +140 -0
- package/src/useMediaQuery/README.md +66 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.matching.test.tsx +42 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.ssr.test.tsx +19 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.subscription.test.tsx +89 -0
- package/src/useMediaQuery/index.ts +1 -0
- package/src/useMediaQuery/useMediaQuery.ts +49 -0
- package/src/usePagination/README.md +170 -0
- package/src/usePagination/__tests__/usePagination.navigation.test.tsx +109 -0
- package/src/usePagination/__tests__/usePagination.page-count.test.tsx +56 -0
- package/src/usePagination/__tests__/usePagination.page-state.test.tsx +87 -0
- package/src/usePagination/__tests__/usePagination.range.test.tsx +82 -0
- package/src/usePagination/__tests__/usePagination.slice.test.tsx +59 -0
- package/src/usePagination/__tests__/usePagination.windowing.test.tsx +68 -0
- package/src/usePagination/index.ts +1 -0
- package/src/usePagination/usePagination.ts +292 -0
- package/src/utils/getKeyToActionMap.ts +1 -1
package/src/Slider/README.md
CHANGED
|
@@ -10,11 +10,11 @@ styles ship.
|
|
|
10
10
|
```tsx
|
|
11
11
|
import { Slider } from "@primitiv-ui/react";
|
|
12
12
|
|
|
13
|
-
<Slider.Root defaultValue={[40]}
|
|
13
|
+
<Slider.Root defaultValue={[40]}>
|
|
14
14
|
<Slider.Track>
|
|
15
15
|
<Slider.Range />
|
|
16
16
|
</Slider.Track>
|
|
17
|
-
<Slider.Thumb />
|
|
17
|
+
<Slider.Thumb aria-label="Volume" />
|
|
18
18
|
</Slider.Root>
|
|
19
19
|
```
|
|
20
20
|
|
|
@@ -56,12 +56,12 @@ Pass `defaultValue` (or omit it for a single thumb seeded at `min`). The
|
|
|
56
56
|
component owns the value internally.
|
|
57
57
|
|
|
58
58
|
```tsx
|
|
59
|
-
<Slider.Root defaultValue={[25, 75]}
|
|
59
|
+
<Slider.Root defaultValue={[25, 75]}>
|
|
60
60
|
<Slider.Track>
|
|
61
61
|
<Slider.Range />
|
|
62
62
|
</Slider.Track>
|
|
63
|
-
<Slider.Thumb />
|
|
64
|
-
<Slider.Thumb />
|
|
63
|
+
<Slider.Thumb aria-label="Minimum" />
|
|
64
|
+
<Slider.Thumb aria-label="Maximum" />
|
|
65
65
|
</Slider.Root>
|
|
66
66
|
```
|
|
67
67
|
|
|
@@ -92,10 +92,40 @@ only the final value.
|
|
|
92
92
|
defaultValue={[40]}
|
|
93
93
|
onValueCommit={(value) => save(value)}
|
|
94
94
|
>
|
|
95
|
-
|
|
95
|
+
...
|
|
96
96
|
</Slider.Root>
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
+
## Labelling
|
|
100
|
+
|
|
101
|
+
**The accessible name goes on the `Thumb`, not the Root.** The Thumb is the
|
|
102
|
+
`role="slider"`; the Root renders a plain `<span>` with no role, so an
|
|
103
|
+
`aria-label` there is attached to nothing and announced nowhere. It is a silent
|
|
104
|
+
failure — the slider still looks and behaves correctly — and the examples in this
|
|
105
|
+
file used to model it.
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
<Slider.Thumb aria-label="Volume" />
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
For a **visible** label, give it an id and point the thumb at it. A
|
|
112
|
+
`<label htmlFor>` cannot associate with a `<span role="slider">`, and `Slider`
|
|
113
|
+
does not read `FieldContext` (only `Input`, `Textarea` and `Select` do) — so a
|
|
114
|
+
`Field.Label` does not reach the thumb on its own:
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
<Field.Root>
|
|
118
|
+
<Field.Label id="volume-label">Volume</Field.Label>
|
|
119
|
+
<Slider.Root defaultValue={[40]}>
|
|
120
|
+
<Slider.Track><Slider.Range /></Slider.Track>
|
|
121
|
+
<Slider.Thumb aria-labelledby="volume-label" />
|
|
122
|
+
</Slider.Root>
|
|
123
|
+
</Field.Root>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
On a range, name each thumb separately — two thumbs sharing one label are
|
|
127
|
+
announced identically, so you cannot tell by ear which end you are on.
|
|
128
|
+
|
|
99
129
|
## Keyboard interaction
|
|
100
130
|
|
|
101
131
|
Each thumb is independently focusable.
|
|
@@ -141,7 +171,7 @@ surrounding `<form>`. Multi-thumb sliders suffix the name with `[]`
|
|
|
141
171
|
```tsx
|
|
142
172
|
<form>
|
|
143
173
|
<Slider.Root defaultValue={[10, 90]} name="price">
|
|
144
|
-
|
|
174
|
+
...
|
|
145
175
|
</Slider.Root>
|
|
146
176
|
</form>
|
|
147
177
|
```
|
package/src/Slider/Slider.tsx
CHANGED
|
@@ -330,18 +330,24 @@ export type TSliderCompound = typeof SliderRoot & {
|
|
|
330
330
|
* - {@link SliderRange | `Slider.Range`} — the filled portion of the track.
|
|
331
331
|
* - {@link SliderThumb | `Slider.Thumb`} — a draggable handle; one per value.
|
|
332
332
|
*
|
|
333
|
+
* The accessible name belongs on the **Thumb**, which is the `role="slider"`.
|
|
334
|
+
* The Root renders a plain `<span>` with no role, so an `aria-label` there is
|
|
335
|
+
* attached to nothing and announced nowhere — a silent failure, because the
|
|
336
|
+
* slider still looks and behaves correctly. This example used to put it on the
|
|
337
|
+
* Root.
|
|
338
|
+
*
|
|
333
339
|
* @example
|
|
334
340
|
* ```tsx
|
|
335
341
|
* import { Slider } from "@primitiv-ui/react";
|
|
336
342
|
*
|
|
337
|
-
* <Slider.Root defaultValue={[40]}
|
|
343
|
+
* <Slider.Root defaultValue={[40]}>
|
|
338
344
|
* <Slider.Track><Slider.Range /></Slider.Track>
|
|
339
|
-
* <Slider.Thumb />
|
|
345
|
+
* <Slider.Thumb aria-label="Volume" />
|
|
340
346
|
* </Slider.Root>
|
|
341
347
|
* ```
|
|
342
348
|
*
|
|
343
349
|
* @see {@link SliderRoot} for state modes, form submission, and validation.
|
|
344
|
-
* @see {@link SliderThumb} for keyboard interaction.
|
|
350
|
+
* @see {@link SliderThumb} for keyboard interaction and labelling.
|
|
345
351
|
*/
|
|
346
352
|
const SliderCompound: TSliderCompound = Object.assign(SliderRoot, {
|
|
347
353
|
Root: SliderRoot,
|
package/src/Slot/Slot.tsx
CHANGED
|
@@ -138,7 +138,7 @@ function mergeProps(slotProps: AnyProps, childProps: AnyProps): AnyProps {
|
|
|
138
138
|
* data attributes, event handlers, and `ref` all still land on the
|
|
139
139
|
* rendered DOM node, just not the node `Slot` itself would have created.
|
|
140
140
|
* The pattern avoids an extra wrapper element in the DOM, which matters for
|
|
141
|
-
* layout (no unwanted `<button><a
|
|
141
|
+
* layout (no unwanted `<button><a>...</a></button>` nesting) and for CSS
|
|
142
142
|
* selectors that expect a single element.
|
|
143
143
|
*
|
|
144
144
|
* **Usage shape** — every component using `Slot` follows the same
|
|
@@ -205,7 +205,7 @@ function mergeProps(slotProps: AnyProps, childProps: AnyProps): AnyProps {
|
|
|
205
205
|
* Settings
|
|
206
206
|
* </RouterLink>
|
|
207
207
|
* </Button>
|
|
208
|
-
* // Rendered DOM: a single <a class="
|
|
208
|
+
* // Rendered DOM: a single <a class="...settings-link"> — trackClick and the
|
|
209
209
|
* // link's own onClick both fire; no wrapping <button>.
|
|
210
210
|
* ```
|
|
211
211
|
*/
|
|
@@ -132,8 +132,8 @@ describe("Slot tests", () => {
|
|
|
132
132
|
const baseElement = React.createElement("div", null, "text");
|
|
133
133
|
const react18Element = {
|
|
134
134
|
...baseElement,
|
|
135
|
-
ref: childRef, // React ≤18: ref lives here
|
|
136
|
-
props: { children: "text" }, //
|
|
135
|
+
ref: childRef, // React ≤18: ref lives here ...
|
|
136
|
+
props: { children: "text" }, // ... not in props
|
|
137
137
|
} as unknown as React.ReactElement;
|
|
138
138
|
const SlotWithRef = React.forwardRef<HTMLElement>((_props, ref) => (
|
|
139
139
|
<Slot ref={ref}>{react18Element}</Slot>
|
package/src/Slot/types.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { HTMLAttributes, ReactNode } from "react";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Props for {@link Slot} — all native `HTMLAttributes` (event handlers,
|
|
5
|
-
* `style`, `className`, `aria-*`, `data-*`,
|
|
5
|
+
* `style`, `className`, `aria-*`, `data-*`, ...), merged onto the single
|
|
6
6
|
* child element rather than rendered onto an element `Slot` creates
|
|
7
7
|
* itself. See {@link Slot} for the exact prop-merging rules (event
|
|
8
8
|
* handlers compose child-first, `style` shallow-merges child-wins,
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# SplitButton
|
|
2
|
+
|
|
3
|
+
One primary action paired with a menu of related alternatives, bound into
|
|
4
|
+
a single `role="group"` widget. Zero styles ship.
|
|
5
|
+
|
|
6
|
+
```tsx
|
|
7
|
+
import { SplitButton, VisuallyHidden } from "@primitiv-ui/react";
|
|
8
|
+
|
|
9
|
+
<SplitButton>
|
|
10
|
+
<SplitButton.Action onClick={squashAndMerge}>
|
|
11
|
+
Squash and merge
|
|
12
|
+
</SplitButton.Action>
|
|
13
|
+
<SplitButton.Trigger>
|
|
14
|
+
<ChevronDown aria-hidden="true" />
|
|
15
|
+
<VisuallyHidden>More merge options</VisuallyHidden>
|
|
16
|
+
</SplitButton.Trigger>
|
|
17
|
+
<SplitButton.Menu>
|
|
18
|
+
<SplitButton.Item onSelect={mergeCommit}>
|
|
19
|
+
Create a merge commit
|
|
20
|
+
</SplitButton.Item>
|
|
21
|
+
<SplitButton.Item onSelect={rebase}>Rebase and merge</SplitButton.Item>
|
|
22
|
+
</SplitButton.Menu>
|
|
23
|
+
</SplitButton>;
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Split button, not select
|
|
27
|
+
|
|
28
|
+
A `Select` answers *"which option is currently selected?"* — picking an
|
|
29
|
+
item changes a **value**. A split button is an **actions** control: the
|
|
30
|
+
left half runs the default action immediately, and the right half opens a
|
|
31
|
+
menu of alternatives. Those alternatives may run their own action, or
|
|
32
|
+
re-point what the primary half does next; either way nothing about the
|
|
33
|
+
widget models a selected value.
|
|
34
|
+
|
|
35
|
+
If you want a value picker, use [`Select`](../Select/README.md). If you
|
|
36
|
+
want a plain menu button with no default action, use
|
|
37
|
+
[`Dropdown`](../Dropdown/README.md) on its own.
|
|
38
|
+
|
|
39
|
+
## Sub-components
|
|
40
|
+
|
|
41
|
+
| Export | Element | Role | Notes |
|
|
42
|
+
| ------------------------ | -------------------------- | ------------ | ----------------------------------------------------------------------- |
|
|
43
|
+
| `SplitButton` / `.Root` | `<div>` | `group` | Owns the menu's open state; uncontrolled (`defaultOpen`) or controlled |
|
|
44
|
+
| `SplitButton.Action` | `<button type="button">` | `button` | The primary action; `ArrowDown` opens the menu |
|
|
45
|
+
| `SplitButton.Trigger` | `<button type="button">` | `button` | `aria-haspopup="menu"`; name derived from the action |
|
|
46
|
+
| `SplitButton.Menu` | `<menu popover="auto">` | `menu` | The panel of alternatives |
|
|
47
|
+
| `SplitButton.Item` | `<div>` | `menuitem` | One alternative action, with `onSelect` |
|
|
48
|
+
| `SplitButton.Separator` | `<div>` | `separator` | Divider between groups of alternatives |
|
|
49
|
+
|
|
50
|
+
`Root`, `Action` and the group accept `asChild`; `Trigger`, `Menu`,
|
|
51
|
+
`Item` and `Separator` inherit `asChild` from the Dropdown parts they
|
|
52
|
+
delegate to.
|
|
53
|
+
|
|
54
|
+
## Anatomy and styling hooks
|
|
55
|
+
|
|
56
|
+
| Part | Hook |
|
|
57
|
+
| ------------- | ------------------------------------------------------ |
|
|
58
|
+
| Root | `data-split-button=""`, `data-state="open" \| "closed"` |
|
|
59
|
+
| Action | `data-split-button-action=""` |
|
|
60
|
+
| Trigger | `data-split-button-trigger=""` |
|
|
61
|
+
| Menu | `data-split-button-menu=""` |
|
|
62
|
+
| Any disabled | `data-disabled=""` |
|
|
63
|
+
|
|
64
|
+
`data-state` on the **root** tracks the menu, which is what makes the
|
|
65
|
+
seam between the two halves styleable while the menu is open (a pressed
|
|
66
|
+
chevron, a joined border) without reaching for `:has()`.
|
|
67
|
+
|
|
68
|
+
## What this component adds over `Dropdown`
|
|
69
|
+
|
|
70
|
+
The menu half *is* a `Dropdown` — Root renders a `Dropdown.Root`, and
|
|
71
|
+
`Menu` / `Item` / `Separator` delegate to `Dropdown.Content` / `.Item` /
|
|
72
|
+
`.Separator`. All the arrow-key navigation, typeahead, Escape handling,
|
|
73
|
+
light dismiss and focus restoration are Dropdown's, tested once over
|
|
74
|
+
there.
|
|
75
|
+
|
|
76
|
+
What `SplitButton` owns is the coordination **between the two halves**,
|
|
77
|
+
which is the whole reason it exists as a primitive rather than a styled
|
|
78
|
+
recipe:
|
|
79
|
+
|
|
80
|
+
1. **The `role="group"` boundary** — assistive technology reads two
|
|
81
|
+
buttons as one widget rather than two unrelated commands.
|
|
82
|
+
2. **The trigger's accessible name, derived from the action** — see
|
|
83
|
+
below. This is the thing hand-rolled split buttons get wrong most
|
|
84
|
+
often.
|
|
85
|
+
3. **`disabled` propagation** — one prop on the group disables both
|
|
86
|
+
halves; it is OR-ed with each half's own `disabled`, so a part can
|
|
87
|
+
never override the group back to enabled.
|
|
88
|
+
4. **`ArrowDown` on the action opens the menu** — the action is *not*
|
|
89
|
+
the menu trigger, so nothing in `Dropdown` wires this. Note this
|
|
90
|
+
affordance is specific to `SplitButton`; `Dropdown.Trigger` itself
|
|
91
|
+
opens on click/Enter/Space only.
|
|
92
|
+
|
|
93
|
+
## The derived accessible name
|
|
94
|
+
|
|
95
|
+
A chevron-only control has no text of its own. Unless you pass
|
|
96
|
+
`aria-label` or `aria-labelledby`, `SplitButton.Trigger` defaults to:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
aria-labelledby="<the trigger's own id> <the action's id>"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Self-reference is valid in `aria-labelledby` and resolves to the
|
|
103
|
+
element's own contents, so putting visually-hidden text inside the
|
|
104
|
+
trigger produces *"More merge options, Squash and merge"*:
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
<SplitButton.Trigger>
|
|
108
|
+
<ChevronDown aria-hidden="true" />
|
|
109
|
+
<VisuallyHidden>More merge options</VisuallyHidden>
|
|
110
|
+
</SplitButton.Trigger>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
With no text inside the trigger at all, the name falls back to just the
|
|
114
|
+
action's label — ambiguous, but still better than an unlabelled button.
|
|
115
|
+
Passing either ARIA attribute yourself opts out of the derivation
|
|
116
|
+
completely:
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
<SplitButton.Trigger aria-label="More merge options">
|
|
120
|
+
<ChevronDown aria-hidden="true" />
|
|
121
|
+
</SplitButton.Trigger>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Because the derivation depends on both ids, **`id` is component-owned on
|
|
125
|
+
`Action` and `Trigger`** and is `Omit`-ted from their prop types. Each
|
|
126
|
+
id is derived from the root's `useId()` with a stable `-split-button-action`
|
|
127
|
+
/ `-split-button-trigger` suffix.
|
|
128
|
+
|
|
129
|
+
## Keyboard interaction
|
|
130
|
+
|
|
131
|
+
| Key | On | Behaviour |
|
|
132
|
+
| ----------------------- | ----------------- | ------------------------------------------------ |
|
|
133
|
+
| `Enter` / `Space` | Action | Runs the primary action (native button) |
|
|
134
|
+
| `ArrowDown` | Action | Opens the menu, focusing its first item |
|
|
135
|
+
| `Enter` / `Space` | Trigger | Toggles the menu |
|
|
136
|
+
| `ArrowDown` / `ArrowUp` | Menu | Moves between items (wraps) |
|
|
137
|
+
| `Home` / `End` | Menu | First / last enabled item |
|
|
138
|
+
| printable characters | Menu | Typeahead |
|
|
139
|
+
| `Escape` | Menu | Closes and returns focus to the trigger |
|
|
140
|
+
|
|
141
|
+
**Both halves are separate tab stops.** They are two genuinely distinct
|
|
142
|
+
commands, so each is independently tabbable — this is deliberately *not*
|
|
143
|
+
a roving-tabindex widget, and shouldn't become one. (Contrast
|
|
144
|
+
`NavigationMenu`, which uses the roving-tabindex hook for its keymap only,
|
|
145
|
+
for the same underlying reason.)
|
|
146
|
+
|
|
147
|
+
## State modes
|
|
148
|
+
|
|
149
|
+
Identical to `Dropdown.Root`'s, and statically discriminated at the type
|
|
150
|
+
level:
|
|
151
|
+
|
|
152
|
+
- **Uncontrolled** — pass `defaultOpen` (or omit it to start closed).
|
|
153
|
+
Optional `onOpenChange` observes user-driven transitions.
|
|
154
|
+
- **Controlled** — pass `open` and `onOpenChange` together.
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
const [open, setOpen] = useState(false);
|
|
158
|
+
|
|
159
|
+
<SplitButton open={open} onOpenChange={setOpen}>
|
|
160
|
+
...
|
|
161
|
+
</SplitButton>;
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Disabled
|
|
165
|
+
|
|
166
|
+
```tsx
|
|
167
|
+
{/* Both halves disabled */}
|
|
168
|
+
<SplitButton disabled>...</SplitButton>
|
|
169
|
+
|
|
170
|
+
{/* Only the action — alternatives stay reachable */}
|
|
171
|
+
<SplitButton.Action disabled>Squash and merge</SplitButton.Action>
|
|
172
|
+
|
|
173
|
+
{/* Only the menu — the default action still works */}
|
|
174
|
+
<SplitButton.Trigger disabled aria-label="More merge options" />
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Richer menus
|
|
178
|
+
|
|
179
|
+
`SplitButton` re-exports only the two menu parts a split button almost
|
|
180
|
+
always needs. Root provides the same Dropdown context, so every other
|
|
181
|
+
`Dropdown` part composes inside `SplitButton.Menu` directly:
|
|
182
|
+
|
|
183
|
+
```tsx
|
|
184
|
+
import { Dropdown, SplitButton } from "@primitiv-ui/react";
|
|
185
|
+
|
|
186
|
+
<SplitButton.Menu>
|
|
187
|
+
<Dropdown.Group>
|
|
188
|
+
<Dropdown.Label>Merge strategy</Dropdown.Label>
|
|
189
|
+
<SplitButton.Item onSelect={mergeCommit}>
|
|
190
|
+
Create a merge commit
|
|
191
|
+
</SplitButton.Item>
|
|
192
|
+
</Dropdown.Group>
|
|
193
|
+
<SplitButton.Separator />
|
|
194
|
+
<Dropdown.Sub>
|
|
195
|
+
<Dropdown.SubTrigger>Advanced</Dropdown.SubTrigger>
|
|
196
|
+
<Dropdown.SubContent>
|
|
197
|
+
<SplitButton.Item onSelect={rebase}>Rebase and merge</SplitButton.Item>
|
|
198
|
+
</Dropdown.SubContent>
|
|
199
|
+
</Dropdown.Sub>
|
|
200
|
+
</SplitButton.Menu>;
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`dir` on `SplitButton.Root` is forwarded to the Dropdown, so submenus
|
|
204
|
+
invert their open/close arrow keys in RTL.
|
|
205
|
+
|
|
206
|
+
## `asChild`
|
|
207
|
+
|
|
208
|
+
```tsx
|
|
209
|
+
{/* The group is a <section> */}
|
|
210
|
+
<SplitButton asChild>
|
|
211
|
+
<section aria-label="Merge">...</section>
|
|
212
|
+
</SplitButton>
|
|
213
|
+
|
|
214
|
+
{/* The primary action navigates instead of firing a handler.
|
|
215
|
+
type="button" is not forwarded in asChild mode. */}
|
|
216
|
+
<SplitButton.Action asChild>
|
|
217
|
+
<a href="/merge">Squash and merge</a>
|
|
218
|
+
</SplitButton.Action>
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Positioning
|
|
222
|
+
|
|
223
|
+
Like `Dropdown`, the menu is a native `popover="auto"` element in the top
|
|
224
|
+
layer — placement is the consumer's CSS concern (CSS anchor positioning,
|
|
225
|
+
as the registry `dropdown` component does).
|