@primitiv-ui/react 0.1.29 → 0.1.31
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/RadioCard/README.md +2 -1
- package/src/RadioCard/RadioCard.tsx +5 -2
- package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +23 -0
- 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
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Context, Provider } from "react";
|
|
2
|
+
|
|
3
|
+
import { createStrictContext } from "../utils/index.ts";
|
|
4
|
+
|
|
5
|
+
import type { ListboxContextValue, ListboxGroupContextValue } from "./types";
|
|
6
|
+
|
|
7
|
+
const listboxContextPair = createStrictContext<ListboxContextValue>(
|
|
8
|
+
"Listbox.Option must be rendered as a child of Listbox.Root",
|
|
9
|
+
// Stryker disable next-line StringLiteral: equivalent — a DevTools label, asserted by no behaviour.
|
|
10
|
+
"ListboxContext",
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
/** Strict React context carrying the {@link ListboxContextValue} from
|
|
14
|
+
* `Listbox.Root` to its options. `null` outside a Root. */
|
|
15
|
+
export const ListboxContext: Context<ListboxContextValue | null> =
|
|
16
|
+
listboxContextPair[0];
|
|
17
|
+
|
|
18
|
+
/** Reads the {@link ListboxContextValue}; throws outside a `Listbox.Root`. */
|
|
19
|
+
export const useListboxContext: () => ListboxContextValue =
|
|
20
|
+
listboxContextPair[1];
|
|
21
|
+
|
|
22
|
+
/** Provider for {@link ListboxContext}, used by `Listbox.Root`. */
|
|
23
|
+
const ListboxProvider: Provider<ListboxContextValue | null> =
|
|
24
|
+
ListboxContext.Provider;
|
|
25
|
+
|
|
26
|
+
export { ListboxProvider };
|
|
27
|
+
|
|
28
|
+
const listboxGroupContextPair = createStrictContext<ListboxGroupContextValue>(
|
|
29
|
+
"Listbox.GroupLabel must be rendered as a child of Listbox.Group",
|
|
30
|
+
// Stryker disable next-line StringLiteral: equivalent — a DevTools label, asserted by no behaviour.
|
|
31
|
+
"ListboxGroupContext",
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/** Strict React context carrying the {@link ListboxGroupContextValue} from
|
|
35
|
+
* `Listbox.Group` to its label. `null` outside a Group. */
|
|
36
|
+
export const ListboxGroupContext: Context<ListboxGroupContextValue | null> =
|
|
37
|
+
listboxGroupContextPair[0];
|
|
38
|
+
|
|
39
|
+
/** Reads the {@link ListboxGroupContextValue}; throws outside a
|
|
40
|
+
* `Listbox.Group`. */
|
|
41
|
+
export const useListboxGroupContext: () => ListboxGroupContextValue =
|
|
42
|
+
listboxGroupContextPair[1];
|
|
43
|
+
|
|
44
|
+
/** Provider for {@link ListboxGroupContext}, used by `Listbox.Group`. */
|
|
45
|
+
const ListboxGroupProvider: Provider<ListboxGroupContextValue | null> =
|
|
46
|
+
ListboxGroupContext.Provider;
|
|
47
|
+
|
|
48
|
+
export { ListboxGroupProvider };
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# Listbox
|
|
2
|
+
|
|
3
|
+
A headless, accessible compound component implementing the
|
|
4
|
+
[WAI-ARIA APG listbox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/)
|
|
5
|
+
— a persistently-visible list of options with a **virtual-focus** cursor.
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import { Listbox } from "@primitiv-ui/react";
|
|
9
|
+
|
|
10
|
+
<Listbox.Root type="single" defaultValue="apple" aria-label="Fruit">
|
|
11
|
+
<Listbox.Option value="apple">Apple</Listbox.Option>
|
|
12
|
+
<Listbox.Option value="banana">Banana</Listbox.Option>
|
|
13
|
+
<Listbox.Option value="cherry">Cherry</Listbox.Option>
|
|
14
|
+
</Listbox.Root>;
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Sub-components
|
|
18
|
+
|
|
19
|
+
| Export | Element | Notes |
|
|
20
|
+
| ----------------- | ------- | ------------------------------------------------------------------------- |
|
|
21
|
+
| `Listbox.Root` | `<div>` | `role="listbox"`, the tab stop, `aria-activedescendant`, context provider, `asChild` |
|
|
22
|
+
| `Listbox.Option` | `<div>` | `role="option"`, `aria-selected`, `data-highlighted`, `disabled`, `asChild` |
|
|
23
|
+
| `Listbox.Group` | `<div>` | `role="group"`, named by `label` or a nested `GroupLabel`, `asChild` |
|
|
24
|
+
| `Listbox.GroupLabel` | `<div>` | `role="presentation"` visible heading; wires the group's `aria-labelledby` |
|
|
25
|
+
|
|
26
|
+
## Why this is not `Select`
|
|
27
|
+
|
|
28
|
+
`Select` (in its default rich mode) is a listbox **inside a popup**, opened
|
|
29
|
+
from a trigger, and its headless layer moves **real DOM focus** between
|
|
30
|
+
options. `Listbox` is the list on its own: always visible, no trigger, no
|
|
31
|
+
popup — and crucially it never moves DOM focus.
|
|
32
|
+
|
|
33
|
+
Focus stays on the root. The visually-focused option is published as
|
|
34
|
+
`aria-activedescendant` on the root and marked `data-highlighted` on the
|
|
35
|
+
option itself. That is the whole point of the component: it is what lets a
|
|
36
|
+
**separate** control — a search input, a command palette's text field — hold
|
|
37
|
+
DOM focus and drive the list at the same time. A roving tabstop cannot do
|
|
38
|
+
that, which is why `Select`'s internals could not simply be reused.
|
|
39
|
+
|
|
40
|
+
Practical consequence when styling: **the cursor is `[data-highlighted]`, not
|
|
41
|
+
`:focus`.** An option never matches `:focus`.
|
|
42
|
+
|
|
43
|
+
## Selection modes
|
|
44
|
+
|
|
45
|
+
### `type="single"`
|
|
46
|
+
|
|
47
|
+
At most one option is selected. Re-selecting the current option is a no-op —
|
|
48
|
+
unlike `ToggleGroup`, a listbox is not a toggle, so the value never returns to
|
|
49
|
+
empty by clicking the same row twice.
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
// Uncontrolled
|
|
53
|
+
<Listbox.Root type="single" defaultValue="apple" aria-label="Fruit">...</Listbox.Root>
|
|
54
|
+
|
|
55
|
+
// Controlled — onValueChange receives string
|
|
56
|
+
const [fruit, setFruit] = useState("apple");
|
|
57
|
+
<Listbox.Root type="single" value={fruit} onValueChange={setFruit} aria-label="Fruit">
|
|
58
|
+
...
|
|
59
|
+
</Listbox.Root>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### `type="multiple"`
|
|
63
|
+
|
|
64
|
+
Options toggle independently and the root carries `aria-multiselectable`.
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
// Controlled — onValueChange receives string[]
|
|
68
|
+
const [toppings, setToppings] = useState<string[]>([]);
|
|
69
|
+
<Listbox.Root type="multiple" value={toppings} onValueChange={setToppings} aria-label="Toppings">
|
|
70
|
+
...
|
|
71
|
+
</Listbox.Root>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Keyboard
|
|
75
|
+
|
|
76
|
+
| Key | Behaviour |
|
|
77
|
+
| ---------------------- | ---------------------------------------------------------------- |
|
|
78
|
+
| Arrow Down / Up | Move the cursor (vertical orientation), wrapping at both ends |
|
|
79
|
+
| Arrow Right / Left | Move the cursor (horizontal orientation), mirrored under `dir="rtl"` |
|
|
80
|
+
| Home / End | Move the cursor to the first / last enabled option |
|
|
81
|
+
| Enter, Space | Select (single) or toggle (multiple) the cursor option |
|
|
82
|
+
| Printable characters | Prefix typeahead — moves the cursor, resets after 500 ms idle |
|
|
83
|
+
|
|
84
|
+
Multiple-selection mode adds APG's optional modifier shortcuts:
|
|
85
|
+
|
|
86
|
+
| Key | Behaviour |
|
|
87
|
+
| ---------------------- | ---------------------------------------------------------------- |
|
|
88
|
+
| Shift+Arrow | Moves the cursor and selects the option it lands on. Does **not** wrap |
|
|
89
|
+
| Ctrl/Cmd+Shift+Home | Selects from the cursor to the first option, cursor parks there |
|
|
90
|
+
| Ctrl/Cmd+Shift+End | Selects from the cursor to the last option, cursor parks there |
|
|
91
|
+
| Ctrl/Cmd+A | Selects every enabled option, or clears the selection if all are already selected |
|
|
92
|
+
|
|
93
|
+
Every other chorded shortcut is left alone: `Alt+Arrow` and any unclaimed
|
|
94
|
+
`Ctrl`/`Cmd` combination pass straight through to your own `onKeyDown` and to
|
|
95
|
+
the browser. That is what lets you bind a toolbar to `Alt+Arrow`, as APG's
|
|
96
|
+
rearrangeable example does.
|
|
97
|
+
|
|
98
|
+
Moving the cursor **does not** select, by default. APG leaves
|
|
99
|
+
selection-follows-focus optional for single-select listboxes and warns it can
|
|
100
|
+
degrade accessibility; the manual model is also what a command palette needs,
|
|
101
|
+
since arrowing through results must not commit a choice. Opt into the APG
|
|
102
|
+
example's behaviour with `selectionFollowsFocus`:
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
<Listbox.Root type="single" selectionFollowsFocus aria-label="Fruit">...</Listbox.Root>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Typeahead follows APG's repeat rule: pressing the same letter repeatedly
|
|
109
|
+
cycles through the options starting with it, rather than searching for a
|
|
110
|
+
literal run of that character. Matching is a case-insensitive prefix test
|
|
111
|
+
against the option's rendered text, so keep the label in `children`.
|
|
112
|
+
|
|
113
|
+
## Pointer
|
|
114
|
+
|
|
115
|
+
Clicking an option selects it (single) or toggles it (multiple), **and moves
|
|
116
|
+
the cursor onto it**, so a following arrow key resumes from the row you
|
|
117
|
+
clicked rather than from wherever focus seeding left the cursor. APG treats
|
|
118
|
+
the clicked option as the focused option. Clicking a disabled option does
|
|
119
|
+
neither.
|
|
120
|
+
|
|
121
|
+
**Hovering does not move the cursor**, deliberately — there are no pointer
|
|
122
|
+
handlers on the option at all. `Select`'s popup listbox and `Dropdown` both
|
|
123
|
+
move their highlight on pointer movement, but those are transient surfaces
|
|
124
|
+
you are about to dismiss. This list is persistent and an external input may
|
|
125
|
+
be driving it, so a mouse resting anywhere over the list would steal the
|
|
126
|
+
cursor mid-keystroke.
|
|
127
|
+
|
|
128
|
+
The styling consequence: **hover needs its own treatment, weaker than the
|
|
129
|
+
cursor's and visually distinct from it.** If hover reused the cursor
|
|
130
|
+
treatment, two rows would look current at once and the pointer would appear
|
|
131
|
+
to move a cursor it cannot move.
|
|
132
|
+
|
|
133
|
+
## Grouping
|
|
134
|
+
|
|
135
|
+
Groups are presentational. The cursor walks every option in DOM order,
|
|
136
|
+
crossing group boundaries without stopping, and typeahead searches the whole
|
|
137
|
+
list.
|
|
138
|
+
|
|
139
|
+
APG requires every option group to carry an accessible name. There are two
|
|
140
|
+
ways to give it one, and you must use one of them:
|
|
141
|
+
|
|
142
|
+
```tsx
|
|
143
|
+
{/* Visible heading — what APG's grouped-options example does. */}
|
|
144
|
+
<Listbox.Group>
|
|
145
|
+
<Listbox.GroupLabel>Citrus</Listbox.GroupLabel>
|
|
146
|
+
<Listbox.Option value="lemon">Lemon</Listbox.Option>
|
|
147
|
+
</Listbox.Group>
|
|
148
|
+
|
|
149
|
+
{/* Invisible name, when the grouping is implied visually some other way. */}
|
|
150
|
+
<Listbox.Group label="Citrus">
|
|
151
|
+
<Listbox.Option value="lemon">Lemon</Listbox.Option>
|
|
152
|
+
</Listbox.Group>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`GroupLabel` renders `role="presentation"` — only `role="option"` elements may
|
|
156
|
+
sit inside a listbox — and the group points `aria-labelledby` at it, with the
|
|
157
|
+
id derived for you. If both are supplied the rendered `GroupLabel` wins, since
|
|
158
|
+
`aria-labelledby` outranks `aria-label` in the accessible-name algorithm and
|
|
159
|
+
emitting both would be misleading.
|
|
160
|
+
|
|
161
|
+
## Disabled options
|
|
162
|
+
|
|
163
|
+
`disabled` drops an option out of cursor navigation, focus seeding and
|
|
164
|
+
typeahead, and makes it unselectable — while keeping it in the DOM and in the
|
|
165
|
+
accessibility tree as `aria-disabled`.
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
<Listbox.Option value="durian" disabled>Durian (out of stock)</Listbox.Option>
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
This is a **house extension**, matching `Select` and `Dropdown` in this
|
|
172
|
+
library. APG's listbox pattern does not itself specify disabled options.
|
|
173
|
+
|
|
174
|
+
## Accessibility notes
|
|
175
|
+
|
|
176
|
+
- **Name the listbox.** Pass `aria-label` or `aria-labelledby` on the Root.
|
|
177
|
+
APG requires it for any listbox that is not part of another widget.
|
|
178
|
+
- `aria-orientation` is emitted **only** for `orientation="horizontal"`;
|
|
179
|
+
vertical is the ARIA default and is left implicit.
|
|
180
|
+
- The active option is scrolled into view (`block: "nearest"`) as the cursor
|
|
181
|
+
moves, per APG.
|
|
182
|
+
- On focus, the cursor is seeded onto the first selected option, or the first
|
|
183
|
+
enabled option when nothing is selected.
|
|
184
|
+
|
|
185
|
+
## Styling hooks
|
|
186
|
+
|
|
187
|
+
Zero styles ship with this library. Style from:
|
|
188
|
+
|
|
189
|
+
| Attribute | Where | Meaning |
|
|
190
|
+
| -------------------------------------- | ------- | ----------------------------------- |
|
|
191
|
+
| `data-orientation="vertical\|horizontal"` | Root | Layout axis |
|
|
192
|
+
| `aria-invalid="true"` | Root | Invalid — **you** set this; see below |
|
|
193
|
+
| `data-highlighted=""` | Option | The cursor is on this option |
|
|
194
|
+
| `data-disabled=""` | Option | The option is disabled |
|
|
195
|
+
| `aria-selected="true\|false"` | Option | Selection state |
|
|
196
|
+
|
|
197
|
+
There is deliberately **no `invalid` prop**. `Listbox.Root` spreads unknown
|
|
198
|
+
props onto its `<div>`, so `aria-invalid` already reaches the DOM — style the
|
|
199
|
+
invalid frame from `[aria-invalid="true"]`, the same way `InputGroup` styles
|
|
200
|
+
itself with `:has(input[aria-invalid="true"])`. A bespoke prop would only
|
|
201
|
+
restate what the ARIA attribute already says.
|
|
202
|
+
|
|
203
|
+
There is also no root-level `disabled`. Only individual options can be
|
|
204
|
+
disabled; a whole inert listbox is out of scope.
|
|
205
|
+
|
|
206
|
+
## `asChild`
|
|
207
|
+
|
|
208
|
+
Every part composes onto a consumer element via the `Slot` pattern, so the
|
|
209
|
+
listbox can be a semantic list:
|
|
210
|
+
|
|
211
|
+
```tsx
|
|
212
|
+
<Listbox.Root type="single" asChild aria-label="Fruit">
|
|
213
|
+
<ul>
|
|
214
|
+
<Listbox.Option value="apple" asChild>
|
|
215
|
+
<li>Apple</li>
|
|
216
|
+
</Listbox.Option>
|
|
217
|
+
</ul>
|
|
218
|
+
</Listbox.Root>
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Reordering options
|
|
222
|
+
|
|
223
|
+
Navigation order is read from the **live DOM** on every interaction, not from
|
|
224
|
+
the order options happened to mount in. Moving an option in place — as APG's
|
|
225
|
+
rearrangeable example does — keeps the same element mounted with unchanged
|
|
226
|
+
props, so nothing re-registers and any cached order would silently go stale.
|
|
227
|
+
Arrow keys, `Home`/`End`, focus seeding, typeahead and the range shortcuts all
|
|
228
|
+
follow the new visual order immediately.
|
|
229
|
+
|
|
230
|
+
## APG example coverage
|
|
231
|
+
|
|
232
|
+
| Example | Status |
|
|
233
|
+
| ------- | ------ |
|
|
234
|
+
| [Scrollable Listbox](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-scrollable/) | Covered. Set `selectionFollowsFocus`, which is what that example does |
|
|
235
|
+
| [Listbox with Grouped Options](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-grouped/) | Covered via `Listbox.GroupLabel` |
|
|
236
|
+
| [Rearrangeable Options](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-rearrangeable/) | Keyboard model covered — both selection modes, the modifier shortcuts, chord pass-through for the toolbar, and in-place reordering. The toolbar buttons and the move/transfer logic are the consumer's, since the component owns no data model |
|
|
237
|
+
|
|
238
|
+
## Not in scope
|
|
239
|
+
|
|
240
|
+
- **`aria-setsize` / `aria-posinset`** for virtualised or lazily-loaded lists.
|
|
241
|
+
APG calls for these when the full option set is not in the DOM; add them
|
|
242
|
+
yourself on each Option (they pass straight through) if you virtualise.
|
|
243
|
+
- **Shift+Space** to select a contiguous run from an anchor. APG marks it
|
|
244
|
+
optional, and the shortcuts that ship here cover the same ground without
|
|
245
|
+
needing anchor state.
|
|
246
|
+
- **Combobox integration.** The virtual-focus model is what makes it possible,
|
|
247
|
+
but the input wiring itself belongs to `Combobox`
|
|
248
|
+
(see [`docs/select-future-work.md`](../../../../docs/select-future-work.md)).
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { Listbox } from "../Listbox";
|
|
6
|
+
|
|
7
|
+
describe("Listbox active descendant", () => {
|
|
8
|
+
it("puts the listbox itself in the tab order and keeps DOM focus on it", async () => {
|
|
9
|
+
const user = userEvent.setup();
|
|
10
|
+
render(
|
|
11
|
+
<Listbox.Root type="single" aria-label="Fruits">
|
|
12
|
+
<Listbox.Option value="apple">Apple</Listbox.Option>
|
|
13
|
+
<Listbox.Option value="banana">Banana</Listbox.Option>
|
|
14
|
+
</Listbox.Root>,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
await user.tab();
|
|
18
|
+
|
|
19
|
+
const listbox = screen.getByRole("listbox");
|
|
20
|
+
expect(listbox).toHaveFocus();
|
|
21
|
+
expect(screen.getByRole("option", { name: "Apple" })).not.toHaveAttribute(
|
|
22
|
+
"tabindex",
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("seeds the active option on focus and points aria-activedescendant at it", async () => {
|
|
27
|
+
const user = userEvent.setup();
|
|
28
|
+
render(
|
|
29
|
+
<Listbox.Root type="single" aria-label="Fruits">
|
|
30
|
+
<Listbox.Option value="apple">Apple</Listbox.Option>
|
|
31
|
+
<Listbox.Option value="banana">Banana</Listbox.Option>
|
|
32
|
+
</Listbox.Root>,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
await user.tab();
|
|
36
|
+
|
|
37
|
+
const apple = screen.getByRole("option", { name: "Apple" });
|
|
38
|
+
expect(screen.getByRole("listbox")).toHaveAttribute(
|
|
39
|
+
"aria-activedescendant",
|
|
40
|
+
apple.id,
|
|
41
|
+
);
|
|
42
|
+
expect(apple).toHaveAttribute("data-highlighted", "");
|
|
43
|
+
expect(screen.getByRole("option", { name: "Banana" })).not.toHaveAttribute(
|
|
44
|
+
"data-highlighted",
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("seeds the active option from the current selection", async () => {
|
|
49
|
+
const user = userEvent.setup();
|
|
50
|
+
render(
|
|
51
|
+
<Listbox.Root type="single" defaultValue="banana" aria-label="Fruits">
|
|
52
|
+
<Listbox.Option value="apple">Apple</Listbox.Option>
|
|
53
|
+
<Listbox.Option value="banana">Banana</Listbox.Option>
|
|
54
|
+
</Listbox.Root>,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
await user.tab();
|
|
58
|
+
|
|
59
|
+
expect(screen.getByRole("listbox")).toHaveAttribute(
|
|
60
|
+
"aria-activedescendant",
|
|
61
|
+
screen.getByRole("option", { name: "Banana" }).id,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("moves the cursor to the option that was clicked", async () => {
|
|
66
|
+
const user = userEvent.setup();
|
|
67
|
+
render(
|
|
68
|
+
<Listbox.Root type="single" defaultValue="apple" aria-label="Fruits">
|
|
69
|
+
<Listbox.Option value="apple">Apple</Listbox.Option>
|
|
70
|
+
<Listbox.Option value="banana">Banana</Listbox.Option>
|
|
71
|
+
<Listbox.Option value="cherry">Cherry</Listbox.Option>
|
|
72
|
+
</Listbox.Root>,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
await user.click(screen.getByRole("option", { name: "Cherry" }));
|
|
76
|
+
|
|
77
|
+
const cherry = screen.getByRole("option", { name: "Cherry" });
|
|
78
|
+
expect(cherry).toHaveAttribute("data-highlighted", "");
|
|
79
|
+
expect(screen.getByRole("listbox")).toHaveAttribute(
|
|
80
|
+
"aria-activedescendant",
|
|
81
|
+
cherry.id,
|
|
82
|
+
);
|
|
83
|
+
expect(screen.getByRole("option", { name: "Apple" })).not.toHaveAttribute(
|
|
84
|
+
"data-highlighted",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("continues arrow navigation from the clicked option", async () => {
|
|
89
|
+
const user = userEvent.setup();
|
|
90
|
+
render(
|
|
91
|
+
<Listbox.Root type="single" defaultValue="apple" aria-label="Fruits">
|
|
92
|
+
<Listbox.Option value="apple">Apple</Listbox.Option>
|
|
93
|
+
<Listbox.Option value="banana">Banana</Listbox.Option>
|
|
94
|
+
<Listbox.Option value="cherry">Cherry</Listbox.Option>
|
|
95
|
+
</Listbox.Root>,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
await user.click(screen.getByRole("option", { name: "Banana" }));
|
|
99
|
+
await user.keyboard("{ArrowDown}");
|
|
100
|
+
|
|
101
|
+
expect(screen.getByRole("option", { name: "Cherry" })).toHaveAttribute(
|
|
102
|
+
"data-highlighted",
|
|
103
|
+
"",
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("drops the highlight when focus leaves the listbox", async () => {
|
|
108
|
+
const user = userEvent.setup();
|
|
109
|
+
render(
|
|
110
|
+
<>
|
|
111
|
+
<Listbox.Root type="single" aria-label="Fruits">
|
|
112
|
+
<Listbox.Option value="apple">Apple</Listbox.Option>
|
|
113
|
+
</Listbox.Root>
|
|
114
|
+
<button type="button">After</button>
|
|
115
|
+
</>,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
await user.tab();
|
|
119
|
+
await user.tab();
|
|
120
|
+
|
|
121
|
+
expect(screen.getByRole("listbox")).not.toHaveAttribute(
|
|
122
|
+
"aria-activedescendant",
|
|
123
|
+
);
|
|
124
|
+
expect(screen.getByRole("option", { name: "Apple" })).not.toHaveAttribute(
|
|
125
|
+
"data-highlighted",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createRef } from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { Listbox } from "../Listbox";
|
|
7
|
+
|
|
8
|
+
describe("Listbox asChild", () => {
|
|
9
|
+
it("renders the Root as a consumer-supplied element", () => {
|
|
10
|
+
render(
|
|
11
|
+
<Listbox.Root type="single" asChild aria-label="Fruits">
|
|
12
|
+
<ul>
|
|
13
|
+
<Listbox.Option value="apple">Apple</Listbox.Option>
|
|
14
|
+
</ul>
|
|
15
|
+
</Listbox.Root>,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const listbox = screen.getByRole("listbox");
|
|
19
|
+
expect(listbox.tagName).toBe("UL");
|
|
20
|
+
expect(listbox).toHaveAttribute("tabindex", "0");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("renders an Option as a consumer-supplied element and composes its handler", async () => {
|
|
24
|
+
const user = userEvent.setup();
|
|
25
|
+
const onClick = vi.fn();
|
|
26
|
+
render(
|
|
27
|
+
<Listbox.Root type="single" aria-label="Fruits">
|
|
28
|
+
<Listbox.Option value="apple" asChild>
|
|
29
|
+
<li onClick={onClick}>Apple</li>
|
|
30
|
+
</Listbox.Option>
|
|
31
|
+
</Listbox.Root>,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const apple = screen.getByRole("option", { name: "Apple" });
|
|
35
|
+
expect(apple.tagName).toBe("LI");
|
|
36
|
+
|
|
37
|
+
await user.click(apple);
|
|
38
|
+
|
|
39
|
+
expect(onClick).toHaveBeenCalled();
|
|
40
|
+
expect(apple).toHaveAttribute("aria-selected", "true");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("renders a Group as a consumer-supplied element", () => {
|
|
44
|
+
render(
|
|
45
|
+
<Listbox.Root type="single" aria-label="Fruits">
|
|
46
|
+
<Listbox.Group label="Citrus" asChild>
|
|
47
|
+
<section>
|
|
48
|
+
<Listbox.Option value="lemon">Lemon</Listbox.Option>
|
|
49
|
+
</section>
|
|
50
|
+
</Listbox.Group>
|
|
51
|
+
</Listbox.Root>,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
expect(screen.getByRole("group", { name: "Citrus" }).tagName).toBe(
|
|
55
|
+
"SECTION",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("renders a GroupLabel as a consumer-supplied element", () => {
|
|
60
|
+
render(
|
|
61
|
+
<Listbox.Root type="single" aria-label="Fruits">
|
|
62
|
+
<Listbox.Group>
|
|
63
|
+
<Listbox.GroupLabel asChild>
|
|
64
|
+
<h3>Citrus</h3>
|
|
65
|
+
</Listbox.GroupLabel>
|
|
66
|
+
<Listbox.Option value="lemon">Lemon</Listbox.Option>
|
|
67
|
+
</Listbox.Group>
|
|
68
|
+
</Listbox.Root>,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const heading = screen.getByText("Citrus");
|
|
72
|
+
expect(heading.tagName).toBe("H3");
|
|
73
|
+
expect(screen.getByRole("group", { name: "Citrus" })).toHaveAttribute(
|
|
74
|
+
"aria-labelledby",
|
|
75
|
+
heading.id,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("forwards a ref through an asChild Option", () => {
|
|
80
|
+
const ref = createRef<HTMLDivElement>();
|
|
81
|
+
render(
|
|
82
|
+
<Listbox.Root type="single" aria-label="Fruits">
|
|
83
|
+
<Listbox.Option value="apple" asChild ref={ref}>
|
|
84
|
+
<li>Apple</li>
|
|
85
|
+
</Listbox.Option>
|
|
86
|
+
</Listbox.Root>,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect(ref.current).toBe(screen.getByRole("option", { name: "Apple" }));
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { Listbox } from "../Listbox";
|
|
5
|
+
|
|
6
|
+
describe("Listbox basic rendering", () => {
|
|
7
|
+
it("renders a listbox role on the root", () => {
|
|
8
|
+
render(
|
|
9
|
+
<Listbox.Root type="single" aria-label="Fruits">
|
|
10
|
+
<Listbox.Option value="apple">Apple</Listbox.Option>
|
|
11
|
+
</Listbox.Root>,
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
expect(screen.getByRole("listbox", { name: "Fruits" })).toBeInTheDocument();
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { Listbox } from "../Listbox";
|
|
7
|
+
|
|
8
|
+
import { fruits } from "./Listbox.fixtures";
|
|
9
|
+
|
|
10
|
+
describe("Listbox controlled state", () => {
|
|
11
|
+
it("renders the selection the consumer supplies", () => {
|
|
12
|
+
render(
|
|
13
|
+
<Listbox.Root type="single" value="cherry" aria-label="Fruits">
|
|
14
|
+
{fruits.map((fruit) => (
|
|
15
|
+
<Listbox.Option key={fruit.value} value={fruit.value}>
|
|
16
|
+
{fruit.label}
|
|
17
|
+
</Listbox.Option>
|
|
18
|
+
))}
|
|
19
|
+
</Listbox.Root>,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
expect(screen.getByRole("option", { name: "Cherry" })).toHaveAttribute(
|
|
23
|
+
"aria-selected",
|
|
24
|
+
"true",
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("does not move the selection on its own", async () => {
|
|
29
|
+
const user = userEvent.setup();
|
|
30
|
+
const onValueChange = vi.fn();
|
|
31
|
+
render(
|
|
32
|
+
<Listbox.Root
|
|
33
|
+
type="single"
|
|
34
|
+
value="cherry"
|
|
35
|
+
onValueChange={onValueChange}
|
|
36
|
+
aria-label="Fruits"
|
|
37
|
+
>
|
|
38
|
+
{fruits.map((fruit) => (
|
|
39
|
+
<Listbox.Option key={fruit.value} value={fruit.value}>
|
|
40
|
+
{fruit.label}
|
|
41
|
+
</Listbox.Option>
|
|
42
|
+
))}
|
|
43
|
+
</Listbox.Root>,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
await user.click(screen.getByRole("option", { name: "Apple" }));
|
|
47
|
+
|
|
48
|
+
expect(onValueChange).toHaveBeenCalledWith("apple");
|
|
49
|
+
expect(screen.getByRole("option", { name: "Apple" })).toHaveAttribute(
|
|
50
|
+
"aria-selected",
|
|
51
|
+
"false",
|
|
52
|
+
);
|
|
53
|
+
expect(screen.getByRole("option", { name: "Cherry" })).toHaveAttribute(
|
|
54
|
+
"aria-selected",
|
|
55
|
+
"true",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("seeds the cursor from the controlled selection", async () => {
|
|
60
|
+
const user = userEvent.setup();
|
|
61
|
+
render(
|
|
62
|
+
<Listbox.Root type="single" value="cherry" aria-label="Fruits">
|
|
63
|
+
{fruits.map((fruit) => (
|
|
64
|
+
<Listbox.Option key={fruit.value} value={fruit.value}>
|
|
65
|
+
{fruit.label}
|
|
66
|
+
</Listbox.Option>
|
|
67
|
+
))}
|
|
68
|
+
</Listbox.Root>,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
await user.tab();
|
|
72
|
+
|
|
73
|
+
expect(screen.getByRole("listbox")).toHaveAttribute(
|
|
74
|
+
"aria-activedescendant",
|
|
75
|
+
screen.getByRole("option", { name: "Cherry" }).id,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("follows the consumer's state through a full round trip", async () => {
|
|
80
|
+
const user = userEvent.setup();
|
|
81
|
+
|
|
82
|
+
function Controlled() {
|
|
83
|
+
const [value, setValue] = useState<string[]>([]);
|
|
84
|
+
return (
|
|
85
|
+
<Listbox.Root
|
|
86
|
+
type="multiple"
|
|
87
|
+
value={value}
|
|
88
|
+
onValueChange={setValue}
|
|
89
|
+
aria-label="Fruits"
|
|
90
|
+
>
|
|
91
|
+
{fruits.map((fruit) => (
|
|
92
|
+
<Listbox.Option key={fruit.value} value={fruit.value}>
|
|
93
|
+
{fruit.label}
|
|
94
|
+
</Listbox.Option>
|
|
95
|
+
))}
|
|
96
|
+
</Listbox.Root>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
render(<Controlled />);
|
|
101
|
+
|
|
102
|
+
await user.click(screen.getByRole("option", { name: "Apple" }));
|
|
103
|
+
await user.click(screen.getByRole("option", { name: "Cherry" }));
|
|
104
|
+
|
|
105
|
+
expect(screen.getByRole("option", { name: "Apple" })).toHaveAttribute(
|
|
106
|
+
"aria-selected",
|
|
107
|
+
"true",
|
|
108
|
+
);
|
|
109
|
+
expect(screen.getByRole("option", { name: "Cherry" })).toHaveAttribute(
|
|
110
|
+
"aria-selected",
|
|
111
|
+
"true",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
});
|