@primitiv-ui/react 0.1.27 → 0.1.29
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 +4 -0
- package/package.json +6 -1
- package/src/AccessibleIcon/AccessibleIcon.tsx +32 -9
- package/src/AccessibleIcon/__tests__/AccessibleIcon.test.tsx +5 -0
- package/src/AccessibleIcon/types.ts +15 -2
- package/src/Accordion/Accordion.tsx +25 -4
- package/src/Accordion/__tests__/Accordion.asChild.test.tsx +46 -0
- package/src/Accordion/__tests__/Accordion.basic-rendering.test.tsx +15 -0
- package/src/Accordion/__tests__/Accordion.disabled-items.test.tsx +42 -0
- package/src/Accordion/__tests__/Accordion.error-handling.test.tsx +33 -0
- package/src/Accordion/hooks/useAccordionRoot.ts +44 -7
- package/src/Accordion/hooks/useAccordionTrigger.ts +9 -1
- package/src/Accordion/types.ts +72 -21
- package/src/Alert/Alert.tsx +36 -7
- package/src/Alert/__tests__/Alert.test.tsx +5 -0
- package/src/Alert/types.ts +18 -2
- package/src/Avatar/Avatar.tsx +99 -15
- package/src/Avatar/__tests__/Avatar.basic-rendering.test.tsx +10 -0
- package/src/Avatar/__tests__/Avatar.fallback.test.tsx +30 -0
- package/src/Avatar/__tests__/Avatar.image-loading.test.tsx +35 -0
- package/src/Avatar/hooks/useAvatarImage.ts +16 -0
- package/src/Avatar/types.ts +15 -3
- package/src/Breadcrumb/Breadcrumb.tsx +136 -18
- package/src/Breadcrumb/README.md +47 -0
- package/src/Breadcrumb/__tests__/Breadcrumb.basic-rendering.test.tsx +11 -0
- package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +110 -0
- package/src/Breadcrumb/types.ts +73 -7
- package/src/Button/Button.tsx +2 -0
- package/src/Button/__tests__/Button.basic-rendering.test.tsx +6 -0
- package/src/Button/types.ts +21 -0
- package/src/Carousel/Carousel.tsx +263 -15
- package/src/Carousel/README.md +749 -72
- package/src/Carousel/__tests__/Carousel.autoplay-status.test.tsx +239 -0
- package/src/Carousel/__tests__/Carousel.drag-status.test.tsx +292 -0
- package/src/Carousel/__tests__/Carousel.dynamic-slides.test.tsx +38 -0
- package/src/Carousel/__tests__/Carousel.indicators.test.tsx +101 -0
- package/src/Carousel/__tests__/Carousel.infinite.test.tsx +1453 -0
- package/src/Carousel/__tests__/Carousel.instant-scroll.test.tsx +124 -0
- package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +88 -0
- package/src/Carousel/__tests__/Carousel.loop.test.tsx +163 -0
- package/src/Carousel/__tests__/Carousel.loopEngine.test.ts +77 -0
- package/src/Carousel/__tests__/Carousel.mouse-drag.test.tsx +504 -0
- package/src/Carousel/__tests__/Carousel.multi-slide-bounds.test.tsx +91 -0
- package/src/Carousel/__tests__/Carousel.orientation.test.tsx +207 -0
- package/src/Carousel/__tests__/Carousel.overscroll.test.tsx +532 -0
- package/src/Carousel/__tests__/Carousel.page-snap-points.test.tsx +50 -0
- package/src/Carousel/__tests__/Carousel.progress-text.test.tsx +76 -0
- package/src/Carousel/__tests__/Carousel.reduced-motion.test.tsx +3 -6
- package/src/Carousel/__tests__/Carousel.refresh-progress.test.tsx +2 -5
- package/src/Carousel/__tests__/Carousel.scroll-margin.test.tsx +167 -0
- package/src/Carousel/__tests__/Carousel.scroll-progress.test.tsx +518 -0
- package/src/Carousel/__tests__/Carousel.scroll-snap-change.test.tsx +32 -11
- package/src/Carousel/__tests__/Carousel.scroll-sync.test.tsx +237 -24
- package/src/Carousel/__tests__/Carousel.scroll-to-index.test.tsx +115 -0
- package/src/Carousel/__tests__/Carousel.slide-snap.test.tsx +172 -0
- package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +179 -1
- package/src/Carousel/__tests__/Carousel.slides-per-page.test.tsx +91 -7
- package/src/Carousel/__tests__/Carousel.snap-type.test.tsx +35 -0
- package/src/Carousel/__tests__/Carousel.transition-modes.test.tsx +30 -5
- package/src/Carousel/__tests__/Carousel.translations.test.tsx +23 -0
- package/src/Carousel/__tests__/Carousel.wheel.test.tsx +160 -0
- package/src/Carousel/hooks/index.ts +1 -0
- package/src/Carousel/hooks/useCarouselLoop.ts +869 -0
- package/src/Carousel/hooks/useCarouselRoot.ts +338 -42
- package/src/Carousel/hooks/useCarouselSlide.ts +44 -9
- package/src/Carousel/hooks/useCarouselViewport.ts +649 -46
- package/src/Carousel/loopEngine.ts +54 -0
- package/src/Carousel/types.ts +457 -35
- package/src/Checkbox/Checkbox.tsx +9 -1
- package/src/Checkbox/__tests__/Checkbox.basic-rendering.test.tsx +14 -0
- package/src/Checkbox/hooks/useCheckboxInput.ts +4 -0
- package/src/Checkbox/types.ts +19 -0
- package/src/CheckboxCard/CheckboxCard.tsx +13 -4
- package/src/CheckboxCard/__tests__/CheckboxCard.basic-rendering.test.tsx +7 -0
- package/src/CheckboxCard/types.ts +65 -17
- package/src/Collapsible/Collapsible.tsx +49 -3
- package/src/Collapsible/README.md +40 -0
- package/src/Collapsible/__tests__/Collapsible.asChild.test.tsx +89 -0
- package/src/Collapsible/__tests__/Collapsible.basic-rendering.test.tsx +27 -0
- package/src/Collapsible/__tests__/Collapsible.collapsedHeight.test.tsx +140 -0
- package/src/Collapsible/hooks/useCollapsibleTrigger.ts +3 -1
- package/src/Collapsible/types.ts +89 -22
- package/src/ContextMenu/ContextMenu.tsx +270 -4
- package/src/ContextMenu/types.ts +148 -6
- package/src/DirectionProvider/DirectionProvider.tsx +41 -9
- package/src/DirectionProvider/__tests__/DirectionProvider.test.tsx +5 -0
- package/src/DirectionProvider/types.ts +28 -3
- package/src/Divider/Divider.tsx +24 -10
- package/src/Divider/__tests__/Divider.test.tsx +5 -0
- package/src/Divider/types.ts +10 -3
- package/src/Drawer/Drawer.tsx +315 -0
- package/src/Drawer/README.md +124 -0
- package/src/Drawer/__tests__/Drawer.behavior.test.tsx +100 -0
- package/src/Drawer/__tests__/Drawer.focus-trap.test.tsx +55 -0
- package/src/Drawer/__tests__/Drawer.rendering.test.tsx +111 -0
- package/src/Drawer/__tests__/Drawer.side.test.tsx +69 -0
- package/src/Drawer/__tests__/dialog-polyfill.ts +39 -0
- package/src/Drawer/index.ts +2 -0
- package/src/Drawer/types.ts +66 -0
- package/src/Dropdown/Dropdown.tsx +117 -0
- package/src/Dropdown/types.ts +241 -11
- package/src/EmptyState/EmptyState.tsx +74 -4
- package/src/EmptyState/__tests__/EmptyState.Actions.test.tsx +7 -2
- package/src/EmptyState/__tests__/EmptyState.Description.test.tsx +3 -1
- package/src/EmptyState/__tests__/EmptyState.Root.test.tsx +10 -0
- package/src/EmptyState/__tests__/EmptyState.Title.test.tsx +3 -1
- package/src/EmptyState/types.ts +49 -6
- package/src/Field/Field.tsx +23 -0
- package/src/Field/__tests__/Field.basic-rendering.test.tsx +11 -0
- package/src/Field/__tests__/Field.error-handling.test.tsx +23 -0
- package/src/Field/__tests__/Field.state-cascade.test.tsx +21 -0
- package/src/Field/hooks/__tests__/useFieldProps.test.tsx +104 -9
- package/src/Field/types.ts +60 -12
- package/src/Fieldset/Fieldset.tsx +54 -18
- package/src/Fieldset/__tests__/Fieldset.basic-rendering.test.tsx +9 -0
- package/src/Fieldset/types.ts +20 -2
- package/src/Input/Input.tsx +14 -9
- package/src/Input/__tests__/Input.basic-rendering.test.tsx +5 -0
- package/src/Input/types.ts +19 -2
- package/src/InputGroup/InputGroup.tsx +11 -3
- package/src/InputGroup/__tests__/InputGroup.basic-rendering.test.tsx +14 -0
- package/src/InputGroup/types.ts +26 -5
- package/src/MillerColumns/MillerColumns.tsx +86 -19
- package/src/MillerColumns/types.ts +83 -27
- package/src/Modal/Modal.tsx +31 -4
- package/src/Modal/README.md +19 -10
- package/src/Modal/__tests__/Modal.focus-trap.test.tsx +162 -0
- package/src/Modal/hooks/useModalContent.ts +32 -0
- package/src/Modal/types.ts +110 -29
- package/src/NavigationMenu/NavigationMenu.tsx +890 -0
- package/src/NavigationMenu/NavigationMenuContext.ts +67 -0
- package/src/NavigationMenu/README.md +355 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.anchor-positioning.test.tsx +132 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.asChild.test.tsx +73 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.basic-rendering.test.tsx +92 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.contract.test.tsx +45 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.controlled-state.test.tsx +134 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.error-handling.test.tsx +62 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.fixtures.tsx +79 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.forceMount.test.tsx +52 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.hover-interaction.test.tsx +254 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.indicator.test.tsx +252 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-interaction.test.tsx +294 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-navigation.test.tsx +126 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.link.test.tsx +139 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.motion.test.tsx +568 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.mouse-interaction.test.tsx +76 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.uncontrolled-state.test.tsx +30 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.viewport.test.tsx +113 -0
- package/src/NavigationMenu/constants.ts +5 -0
- package/src/NavigationMenu/hooks/index.ts +7 -0
- package/src/NavigationMenu/hooks/useNavigationMenuEntry.ts +33 -0
- package/src/NavigationMenu/hooks/useNavigationMenuIndicator.ts +70 -0
- package/src/NavigationMenu/hooks/useNavigationMenuLink.ts +72 -0
- package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +301 -0
- package/src/NavigationMenu/hooks/useNavigationMenuTopLevelEntry.ts +65 -0
- package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +158 -0
- package/src/NavigationMenu/hooks/useNavigationMenuViewport.ts +101 -0
- package/src/NavigationMenu/index.ts +3 -0
- package/src/NavigationMenu/types.ts +259 -0
- package/src/NavigationMenu/utils.ts +89 -0
- package/src/Popover/Popover.tsx +427 -0
- package/src/Popover/PopoverContext.ts +13 -0
- package/src/Popover/README.md +106 -0
- package/src/Popover/__tests__/Popover.anchor.test.tsx +38 -0
- package/src/Popover/__tests__/Popover.asChild.test.tsx +160 -0
- package/src/Popover/__tests__/Popover.basic-rendering.test.tsx +106 -0
- package/src/Popover/__tests__/Popover.click-outside.test.tsx +149 -0
- package/src/Popover/__tests__/Popover.close.test.tsx +70 -0
- package/src/Popover/__tests__/Popover.content.test.tsx +58 -0
- package/src/Popover/__tests__/Popover.controlled-state.test.tsx +45 -0
- package/src/Popover/__tests__/Popover.error-handling.test.tsx +51 -0
- package/src/Popover/__tests__/Popover.focus.test.tsx +43 -0
- package/src/Popover/__tests__/Popover.keyboard-interaction.test.tsx +71 -0
- package/src/Popover/__tests__/Popover.mouse-interaction.test.tsx +69 -0
- package/src/Popover/__tests__/Popover.uncontrolled-state.test.tsx +44 -0
- package/src/Popover/hooks/index.ts +3 -0
- package/src/Popover/hooks/usePopoverContent.ts +98 -0
- package/src/Popover/hooks/usePopoverRoot.ts +84 -0
- package/src/Popover/hooks/usePopoverTrigger.ts +33 -0
- package/src/Popover/index.ts +2 -0
- package/src/Popover/types.ts +197 -0
- package/src/Portal/Portal.tsx +50 -10
- package/src/Portal/__tests__/Portal.basic-rendering.test.tsx +5 -0
- package/src/Portal/types.ts +5 -0
- package/src/Progress/Progress.tsx +33 -1
- package/src/Progress/__tests__/Progress.basic-rendering.test.tsx +10 -0
- package/src/Progress/__tests__/Progress.value.test.tsx +21 -0
- package/src/Progress/types.ts +20 -4
- package/src/Radio/Radio.tsx +9 -1
- package/src/Radio/__tests__/Radio.basic-rendering.test.tsx +14 -0
- package/src/Radio/hooks/useRadioRoot.ts +3 -0
- package/src/Radio/types.ts +20 -0
- package/src/RadioCard/RadioCard.tsx +11 -0
- package/src/RadioCard/__tests__/RadioCard.basic-rendering.test.tsx +8 -0
- package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +27 -0
- package/src/RadioCard/__tests__/RadioCard.keyboard-interaction.test.tsx +53 -0
- package/src/RadioCard/__tests__/RadioCard.ref-forwarding.test.tsx +54 -0
- package/src/RadioCard/__tests__/RadioCard.uncontrolled-state.test.tsx +21 -0
- package/src/RadioCard/hooks/useRadioCardRoot.ts +11 -0
- package/src/RadioCard/types.ts +95 -22
- package/src/RadioGroup/RadioGroup.tsx +11 -0
- package/src/RadioGroup/__tests__/RadioGroup.basic-rendering.test.tsx +8 -0
- package/src/RadioGroup/__tests__/RadioGroup.disabled.test.tsx +27 -0
- package/src/RadioGroup/__tests__/RadioGroup.ref-forwarding.test.tsx +29 -0
- package/src/RadioGroup/__tests__/RadioGroup.roving-tabindex.test.tsx +31 -0
- package/src/RadioGroup/hooks/useRadioGroupRoot.ts +11 -0
- package/src/RadioGroup/types.ts +73 -9
- package/src/SegmentedControl/README.md +154 -0
- package/src/SegmentedControl/SegmentedControl.tsx +309 -0
- package/src/SegmentedControl/SegmentedControlContext.ts +28 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.asChild.test.tsx +50 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.basic-rendering.test.tsx +100 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.controlled-state.test.tsx +107 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.disabled-keydown-guards.test.tsx +62 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.disabled.test.tsx +151 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.error-handling.test.tsx +17 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.keyboard-interaction.test.tsx +117 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.orientation.test.tsx +54 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.reading-direction.test.tsx +56 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.ref-forwarding.test.tsx +45 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.roving-tabindex.test.tsx +104 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.uncontrolled-state.test.tsx +94 -0
- package/src/SegmentedControl/hooks/index.ts +2 -0
- package/src/SegmentedControl/hooks/useSegmentedControlContext.ts +1 -0
- package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +86 -0
- package/src/SegmentedControl/index.ts +2 -0
- package/src/SegmentedControl/types.ts +138 -0
- package/src/Select/README.md +122 -157
- package/src/Select/Select.tsx +595 -68
- package/src/Select/SelectContext.ts +38 -0
- package/src/Select/SelectItemIndicatorContext.ts +29 -0
- package/src/Select/__tests__/Select.asChild.test.tsx +2 -2
- package/src/Select/__tests__/Select.basic-rendering.test.tsx +22 -3
- package/src/Select/__tests__/Select.controlled-state.test.tsx +6 -6
- package/src/Select/__tests__/Select.data-attributes.test.tsx +4 -4
- package/src/Select/__tests__/Select.field-integration.test.tsx +11 -11
- package/src/Select/__tests__/Select.form.test.tsx +38 -0
- package/src/Select/__tests__/Select.group.test.tsx +6 -6
- package/src/Select/__tests__/Select.keyboard-edge.test.tsx +95 -0
- package/src/Select/__tests__/Select.keyboard.test.tsx +124 -0
- package/src/Select/__tests__/Select.mutation-hardening.test.tsx +317 -0
- package/src/Select/__tests__/Select.native-item.test.tsx +30 -0
- package/src/Select/__tests__/Select.open-close.test.tsx +57 -0
- package/src/Select/__tests__/Select.placeholder.test.tsx +4 -4
- package/src/Select/__tests__/Select.rich-asChild.test.tsx +60 -0
- package/src/Select/__tests__/Select.rich-mode.test.tsx +16 -0
- package/src/Select/__tests__/Select.selection.test.tsx +104 -0
- package/src/Select/__tests__/Select.separator.test.tsx +71 -0
- package/src/Select/__tests__/Select.strict-context.test.tsx +36 -0
- package/src/Select/__tests__/Select.typeahead.test.tsx +156 -0
- package/src/Select/__tests__/Select.value-mirror.test.tsx +240 -0
- package/src/Select/constants.ts +8 -0
- package/src/Select/hooks/index.ts +2 -0
- package/src/Select/hooks/useSelectContent.ts +149 -0
- package/src/Select/hooks/useSelectRoot.ts +149 -0
- package/src/Select/types.ts +199 -31
- package/src/SkipNav/SkipNav.tsx +69 -29
- package/src/SkipNav/__tests__/SkipNav.basic-rendering.test.tsx +6 -0
- package/src/SkipNav/types.ts +17 -9
- package/src/Slider/Slider.tsx +33 -1
- package/src/Slider/SliderContext.ts +28 -1
- package/src/Slider/types.ts +157 -9
- package/src/Slot/Slot.tsx +94 -19
- package/src/Slot/__tests__/Slot.test.tsx +110 -0
- package/src/Slot/composeEventHandlers.ts +26 -2
- package/src/Slot/types.ts +16 -0
- package/src/Status/Status.tsx +36 -15
- package/src/Status/__tests__/Status.test.tsx +5 -0
- package/src/Status/types.ts +12 -1
- package/src/Switch/Switch.tsx +15 -2
- package/src/Switch/__tests__/Switch.basic-rendering.test.tsx +7 -0
- package/src/Switch/hooks/useSwitchRoot.ts +3 -0
- package/src/Switch/types.ts +49 -22
- package/src/Table/Table.tsx +44 -10
- package/src/Table/types.ts +66 -17
- package/src/Tabs/Tabs.tsx +8 -0
- package/src/Tabs/types.ts +29 -4
- package/src/Textarea/Textarea.tsx +9 -1
- package/src/Textarea/__tests__/Textarea.basic-rendering.test.tsx +5 -0
- package/src/Textarea/types.ts +20 -3
- package/src/Toggle/Toggle.tsx +51 -13
- package/src/Toggle/__tests__/Toggle.basic-rendering.test.tsx +5 -0
- package/src/Toggle/types.ts +54 -4
- package/src/ToggleGroup/ToggleGroup.tsx +14 -1
- package/src/ToggleGroup/__tests__/ToggleGroup.asChild.test.tsx +20 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.basic-rendering.test.tsx +9 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.dynamic-items.test.tsx +70 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.error-handling.test.tsx +23 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.keyboard-interaction.test.tsx +23 -1
- package/src/ToggleGroup/__tests__/ToggleGroup.ref-forwarding.test.tsx +58 -0
- package/src/ToggleGroup/hooks/useToggleGroupRoot.ts +21 -1
- package/src/ToggleGroup/types.ts +55 -2
- package/src/Tooltip/Tooltip.tsx +98 -20
- package/src/Tooltip/hooks/__tests__/useTooltipProvider.test.tsx +40 -0
- package/src/Tooltip/hooks/useTooltipProvider.ts +13 -1
- package/src/Tooltip/types.ts +89 -8
- package/src/Tree/Tree.tsx +187 -33
- package/src/Tree/types.ts +54 -12
- package/src/VisuallyHidden/VisuallyHidden.tsx +33 -8
- package/src/VisuallyHidden/__tests__/VisuallyHidden.test.tsx +15 -1
- package/src/VisuallyHidden/types.ts +11 -1
- package/src/index.ts +4 -0
- package/src/test/intersectionObserverPolyfill.ts +7 -0
- package/src/test/resizeObserverPolyfill.ts +80 -0
- package/src/utils/focusable.ts +15 -0
- package/src/utils/index.ts +1 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { render } from "@testing-library/react";
|
|
2
|
+
|
|
3
|
+
import { Select } from "../Select";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The rich sub-components read a strict context, so rendering one outside its
|
|
7
|
+
* provider throws a consumer-actionable message rather than failing obscurely
|
|
8
|
+
* further down. The message itself is the contract — assert it, not just that
|
|
9
|
+
* something threw.
|
|
10
|
+
*/
|
|
11
|
+
describe("Select strict context", () => {
|
|
12
|
+
// React logs the error boundary-less throw; silence it so the run stays clean.
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it.each([
|
|
18
|
+
["Trigger", <Select.Trigger key="t" />],
|
|
19
|
+
["Value", <Select.Value key="v" />],
|
|
20
|
+
["Content", <Select.Content key="c" />],
|
|
21
|
+
])("names <Select.Root> when %s is rendered outside one", (_name, element) => {
|
|
22
|
+
expect(() => render(element)).toThrow(
|
|
23
|
+
"Select rich sub-components (Trigger, Value, Content, Item) must be rendered inside a <Select.Root>.",
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("names <Select.Item> when an ItemIndicator is rendered outside one", () => {
|
|
28
|
+
expect(() =>
|
|
29
|
+
render(
|
|
30
|
+
<Select.Root>
|
|
31
|
+
<Select.ItemIndicator forceMount>✓</Select.ItemIndicator>
|
|
32
|
+
</Select.Root>,
|
|
33
|
+
),
|
|
34
|
+
).toThrow("Select.ItemIndicator must be rendered inside a <Select.Item>.");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
|
|
4
|
+
import { Select } from "../Select";
|
|
5
|
+
|
|
6
|
+
function renderSelect() {
|
|
7
|
+
return render(
|
|
8
|
+
<Select.Root>
|
|
9
|
+
<Select.Trigger>
|
|
10
|
+
<Select.Value placeholder="Pick one" />
|
|
11
|
+
</Select.Trigger>
|
|
12
|
+
<Select.Content>
|
|
13
|
+
<Select.Item value="apple">Apple</Select.Item>
|
|
14
|
+
<Select.Item value="apricot">Apricot</Select.Item>
|
|
15
|
+
<Select.Item value="avocado">Avocado</Select.Item>
|
|
16
|
+
<Select.Item value="banana">Banana</Select.Item>
|
|
17
|
+
</Select.Content>
|
|
18
|
+
</Select.Root>,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const option = (name: string) =>
|
|
23
|
+
screen.getByRole("option", { name, hidden: true });
|
|
24
|
+
|
|
25
|
+
describe("Select typeahead", () => {
|
|
26
|
+
it("jumps to the first option matching a single character", async () => {
|
|
27
|
+
const user = userEvent.setup();
|
|
28
|
+
renderSelect();
|
|
29
|
+
await user.click(screen.getByRole("button")); // opens, focuses Apple
|
|
30
|
+
|
|
31
|
+
await user.keyboard("b");
|
|
32
|
+
expect(option("Banana")).toHaveFocus();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("skips the current match and cycles on a repeated character, wrapping around", async () => {
|
|
36
|
+
const user = userEvent.setup();
|
|
37
|
+
renderSelect();
|
|
38
|
+
await user.click(screen.getByRole("button")); // Apple focused
|
|
39
|
+
|
|
40
|
+
// Typing the focused item's own letter advances to the NEXT match (offset).
|
|
41
|
+
await user.keyboard("a");
|
|
42
|
+
expect(option("Apricot")).toHaveFocus();
|
|
43
|
+
|
|
44
|
+
// Repeated same char keeps cycling through the "a" matches…
|
|
45
|
+
await user.keyboard("a");
|
|
46
|
+
expect(option("Avocado")).toHaveFocus();
|
|
47
|
+
|
|
48
|
+
// …and wraps back to the first "a" match (past the non-matching Banana).
|
|
49
|
+
await user.keyboard("a");
|
|
50
|
+
expect(option("Apple")).toHaveFocus();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("narrows to a prefix match with a multi-character query (no offset skip)", async () => {
|
|
54
|
+
const user = userEvent.setup();
|
|
55
|
+
renderSelect();
|
|
56
|
+
await user.click(screen.getByRole("button")); // Apple focused
|
|
57
|
+
|
|
58
|
+
// "av" is not a repeat; it searches from the current index without the
|
|
59
|
+
// single-char skip, so it stays put on the only "av…" match.
|
|
60
|
+
await user.keyboard("av");
|
|
61
|
+
expect(option("Avocado")).toHaveFocus();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("does not match when no option starts with the typed text", async () => {
|
|
65
|
+
const user = userEvent.setup();
|
|
66
|
+
renderSelect();
|
|
67
|
+
await user.click(screen.getByRole("button")); // Apple focused
|
|
68
|
+
|
|
69
|
+
await user.keyboard("z");
|
|
70
|
+
// Focus stays on Apple — nothing starts with "z".
|
|
71
|
+
expect(option("Apple")).toHaveFocus();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("matches the full multi-char prefix, not just the first character (isRepeat off)", async () => {
|
|
75
|
+
const user = userEvent.setup();
|
|
76
|
+
render(
|
|
77
|
+
<Select.Root>
|
|
78
|
+
<Select.Trigger>
|
|
79
|
+
<Select.Value placeholder="Pick" />
|
|
80
|
+
</Select.Trigger>
|
|
81
|
+
<Select.Content>
|
|
82
|
+
<Select.Item value="bat">Bat</Select.Item>
|
|
83
|
+
<Select.Item value="bengal">Bengal</Select.Item>
|
|
84
|
+
<Select.Item value="banana">Banana</Select.Item>
|
|
85
|
+
<Select.Item value="cat">Cat</Select.Item>
|
|
86
|
+
</Select.Content>
|
|
87
|
+
</Select.Root>,
|
|
88
|
+
);
|
|
89
|
+
await user.click(screen.getByRole("button")); // Bat focused (index 0)
|
|
90
|
+
|
|
91
|
+
// "ban" must resolve to Banana. If isRepeat were (wrongly) true, the
|
|
92
|
+
// search would use just "b" and land on Bengal (or wrap to Bat).
|
|
93
|
+
await user.keyboard("ban");
|
|
94
|
+
expect(
|
|
95
|
+
screen.getByRole("option", { name: "Banana", hidden: true }),
|
|
96
|
+
).toHaveFocus();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("starts the search at index 0 when no option is focused (startIndex)", async () => {
|
|
100
|
+
const user = userEvent.setup();
|
|
101
|
+
render(
|
|
102
|
+
<Select.Root>
|
|
103
|
+
<Select.Trigger>
|
|
104
|
+
<Select.Value placeholder="Pick" />
|
|
105
|
+
</Select.Trigger>
|
|
106
|
+
<Select.Content>
|
|
107
|
+
<Select.Item value="solid">Solid</Select.Item>
|
|
108
|
+
<Select.Item value="sun">Sun</Select.Item>
|
|
109
|
+
<Select.Item value="vue">Vue</Select.Item>
|
|
110
|
+
</Select.Content>
|
|
111
|
+
</Select.Root>,
|
|
112
|
+
);
|
|
113
|
+
await user.click(screen.getByRole("button"));
|
|
114
|
+
screen.getByRole("listbox", { hidden: true }).focus(); // no option focused → currentIndex -1
|
|
115
|
+
|
|
116
|
+
// startIndex 0 + offset 1 → search from index 1 → Sun (skips Solid at 0).
|
|
117
|
+
// A `startIndex = currentIndex` (-1) mutant would instead land on Solid.
|
|
118
|
+
await user.keyboard("s");
|
|
119
|
+
expect(screen.getByRole("option", { name: "Sun", hidden: true })).toHaveFocus();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("keeps the current match when narrowing with more characters (offset 0)", async () => {
|
|
123
|
+
const user = userEvent.setup();
|
|
124
|
+
render(
|
|
125
|
+
<Select.Root>
|
|
126
|
+
<Select.Trigger>
|
|
127
|
+
<Select.Value placeholder="Pick" />
|
|
128
|
+
</Select.Trigger>
|
|
129
|
+
<Select.Content>
|
|
130
|
+
<Select.Item value="apple">Apple</Select.Item>
|
|
131
|
+
<Select.Item value="apricot">Apricot</Select.Item>
|
|
132
|
+
<Select.Item value="avocado">Avocado</Select.Item>
|
|
133
|
+
</Select.Content>
|
|
134
|
+
</Select.Root>,
|
|
135
|
+
);
|
|
136
|
+
await user.click(screen.getByRole("button")); // Apple focused
|
|
137
|
+
await user.keyboard("a"); // → Apricot (single-char offset skip)
|
|
138
|
+
await user.keyboard("p"); // "ap" is multi-char, offset 0 → Apricot still matches, stays
|
|
139
|
+
|
|
140
|
+
// An `offset = 1` mutant would skip Apricot and wrap to Apple.
|
|
141
|
+
expect(
|
|
142
|
+
screen.getByRole("option", { name: "Apricot", hidden: true }),
|
|
143
|
+
).toHaveFocus();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("matches a prefix, not a substring", async () => {
|
|
147
|
+
const user = userEvent.setup();
|
|
148
|
+
renderSelect();
|
|
149
|
+
await user.click(screen.getByRole("button")); // Apple focused
|
|
150
|
+
|
|
151
|
+
// "n" is a substring of "Banana" but a prefix of nothing, so focus must
|
|
152
|
+
// not jump to Banana (guards startsWith, not includes).
|
|
153
|
+
await user.keyboard("n");
|
|
154
|
+
expect(option("Apple")).toHaveFocus();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { render, screen, within } from "@testing-library/react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
|
|
5
|
+
import { Select } from "../Select";
|
|
6
|
+
|
|
7
|
+
function Framework({ value, label }: { value: string; label: string }) {
|
|
8
|
+
return (
|
|
9
|
+
<Select.Item value={value}>
|
|
10
|
+
<svg data-testid={`icon-${value}`} aria-hidden />
|
|
11
|
+
{label}
|
|
12
|
+
<Select.ItemIndicator>✓</Select.ItemIndicator>
|
|
13
|
+
</Select.Item>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function renderSelect(props: Parameters<typeof Select.Root>[0] = {}) {
|
|
18
|
+
return render(
|
|
19
|
+
<Select.Root {...props}>
|
|
20
|
+
<Select.Trigger>
|
|
21
|
+
<Select.Value placeholder="Pick a framework" />
|
|
22
|
+
</Select.Trigger>
|
|
23
|
+
<Select.Content>
|
|
24
|
+
<Framework value="react" label="React" />
|
|
25
|
+
<Framework value="vue" label="Vue" />
|
|
26
|
+
</Select.Content>
|
|
27
|
+
</Select.Root>,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("Select.Value mirroring", () => {
|
|
32
|
+
it("shows the placeholder when nothing is selected", () => {
|
|
33
|
+
renderSelect();
|
|
34
|
+
|
|
35
|
+
expect(screen.getByRole("button")).toHaveTextContent("Pick a framework");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("mirrors the selected item's icon and label into the trigger", () => {
|
|
39
|
+
renderSelect({ defaultValue: "react" });
|
|
40
|
+
|
|
41
|
+
const trigger = screen.getByRole("button");
|
|
42
|
+
expect(trigger).toHaveTextContent("React");
|
|
43
|
+
expect(within(trigger).getByTestId("icon-react")).toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("excludes the ItemIndicator from the mirrored trigger content", () => {
|
|
47
|
+
renderSelect({ defaultValue: "react" });
|
|
48
|
+
|
|
49
|
+
const trigger = screen.getByRole("button");
|
|
50
|
+
expect(within(trigger).queryByText("✓")).not.toBeInTheDocument();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("drops an item's mirror registration when it unmounts", () => {
|
|
54
|
+
const { rerender } = render(
|
|
55
|
+
<Select.Root defaultValue="react">
|
|
56
|
+
<Select.Trigger>
|
|
57
|
+
<Select.Value placeholder="Nothing" />
|
|
58
|
+
</Select.Trigger>
|
|
59
|
+
<Select.Content>
|
|
60
|
+
<Framework value="react" label="React" />
|
|
61
|
+
<Framework value="vue" label="Vue" />
|
|
62
|
+
</Select.Content>
|
|
63
|
+
</Select.Root>,
|
|
64
|
+
);
|
|
65
|
+
expect(screen.getByRole("button")).toHaveTextContent("React");
|
|
66
|
+
|
|
67
|
+
// Remove the (still-selected) react item while the Select stays mounted —
|
|
68
|
+
// its registration is dropped and Value falls back to the placeholder.
|
|
69
|
+
rerender(
|
|
70
|
+
<Select.Root defaultValue="react">
|
|
71
|
+
<Select.Trigger>
|
|
72
|
+
<Select.Value placeholder="Nothing" />
|
|
73
|
+
</Select.Trigger>
|
|
74
|
+
<Select.Content>
|
|
75
|
+
<Framework value="vue" label="Vue" />
|
|
76
|
+
</Select.Content>
|
|
77
|
+
</Select.Root>,
|
|
78
|
+
);
|
|
79
|
+
expect(screen.getByRole("button")).toHaveTextContent("Nothing");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("excludes an indicator nested inside a consumer wrapper from the mirror", () => {
|
|
83
|
+
// A styled layer wraps Select.ItemIndicator in its own component (the
|
|
84
|
+
// registry does exactly this), so the mirror cannot identify the indicator
|
|
85
|
+
// by the element's own type. Mirroring it into the trigger would render it
|
|
86
|
+
// outside any Select.Item and throw.
|
|
87
|
+
function StyledIndicator({ children }: { children: ReactNode }) {
|
|
88
|
+
return <Select.ItemIndicator className="mark">{children}</Select.ItemIndicator>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
render(
|
|
92
|
+
<Select.Root defaultValue="react">
|
|
93
|
+
<Select.Trigger>
|
|
94
|
+
<Select.Value placeholder="Pick" />
|
|
95
|
+
</Select.Trigger>
|
|
96
|
+
<Select.Content>
|
|
97
|
+
<Select.Item value="react">
|
|
98
|
+
React
|
|
99
|
+
<StyledIndicator>✓</StyledIndicator>
|
|
100
|
+
</Select.Item>
|
|
101
|
+
</Select.Content>
|
|
102
|
+
</Select.Root>,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const trigger = screen.getByRole("button");
|
|
106
|
+
expect(trigger).toHaveTextContent("React");
|
|
107
|
+
expect(within(trigger).queryByText("✓")).not.toBeInTheDocument();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("keeps a forceMount indicator out of the mirror too", () => {
|
|
111
|
+
// forceMount keeps the mark in the DOM while its row is unselected, for CSS
|
|
112
|
+
// animation inside the listbox. The trigger's mirrored copy must still drop
|
|
113
|
+
// it — this is the only path where the mirror check is load-bearing, since
|
|
114
|
+
// an ordinary indicator already self-hides on the mirror's unselected state.
|
|
115
|
+
render(
|
|
116
|
+
<Select.Root defaultValue="react">
|
|
117
|
+
<Select.Trigger>
|
|
118
|
+
<Select.Value placeholder="Pick" />
|
|
119
|
+
</Select.Trigger>
|
|
120
|
+
<Select.Content>
|
|
121
|
+
<Select.Item value="react">
|
|
122
|
+
React
|
|
123
|
+
<Select.ItemIndicator forceMount data-testid="mark">
|
|
124
|
+
✓
|
|
125
|
+
</Select.ItemIndicator>
|
|
126
|
+
</Select.Item>
|
|
127
|
+
</Select.Content>
|
|
128
|
+
</Select.Root>,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const trigger = screen.getByRole("button");
|
|
132
|
+
expect(trigger).toHaveTextContent("React");
|
|
133
|
+
expect(within(trigger).queryByTestId("mark")).not.toBeInTheDocument();
|
|
134
|
+
// …while the row's own copy stays mounted.
|
|
135
|
+
expect(screen.getByTestId("mark")).toBeInTheDocument();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("flags the placeholder state with data-placeholder", () => {
|
|
139
|
+
renderSelect();
|
|
140
|
+
|
|
141
|
+
expect(screen.getByText("Pick a framework")).toHaveAttribute(
|
|
142
|
+
"data-placeholder",
|
|
143
|
+
"",
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("drops data-placeholder once an item is selected", () => {
|
|
148
|
+
renderSelect({ defaultValue: "react" });
|
|
149
|
+
|
|
150
|
+
const trigger = screen.getByRole("button");
|
|
151
|
+
expect(within(trigger).getByText("React").closest("span")).not.toHaveAttribute(
|
|
152
|
+
"data-placeholder",
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("updates the mirrored content when the selection changes", async () => {
|
|
157
|
+
const user = userEvent.setup();
|
|
158
|
+
renderSelect();
|
|
159
|
+
|
|
160
|
+
await user.click(screen.getByRole("button"));
|
|
161
|
+
await user.click(screen.getByText("Vue"));
|
|
162
|
+
|
|
163
|
+
const trigger = screen.getByRole("button");
|
|
164
|
+
expect(trigger).toHaveTextContent("Vue");
|
|
165
|
+
expect(within(trigger).getByTestId("icon-vue")).toBeInTheDocument();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("Select.ItemIndicator", () => {
|
|
170
|
+
it("renders only inside the selected item", async () => {
|
|
171
|
+
const user = userEvent.setup();
|
|
172
|
+
renderSelect({ defaultValue: "react" });
|
|
173
|
+
|
|
174
|
+
await user.click(screen.getByRole("button"));
|
|
175
|
+
|
|
176
|
+
// Exactly one indicator is mounted — the selected (react) item's.
|
|
177
|
+
const indicators = screen.getAllByText("✓");
|
|
178
|
+
expect(indicators).toHaveLength(1);
|
|
179
|
+
const reactOption = screen.getByRole("option", {
|
|
180
|
+
name: /React/,
|
|
181
|
+
hidden: true,
|
|
182
|
+
});
|
|
183
|
+
expect(within(reactOption).getByText("✓")).toHaveAttribute(
|
|
184
|
+
"data-state",
|
|
185
|
+
"checked",
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("stays mounted for an unselected item when forceMount is set", async () => {
|
|
190
|
+
const user = userEvent.setup();
|
|
191
|
+
render(
|
|
192
|
+
<Select.Root defaultValue="react">
|
|
193
|
+
<Select.Trigger>
|
|
194
|
+
<Select.Value placeholder="Pick" />
|
|
195
|
+
</Select.Trigger>
|
|
196
|
+
<Select.Content>
|
|
197
|
+
<Select.Item value="react">React</Select.Item>
|
|
198
|
+
<Select.Item value="vue">
|
|
199
|
+
Vue
|
|
200
|
+
<Select.ItemIndicator forceMount data-testid="vue-indicator">
|
|
201
|
+
•
|
|
202
|
+
</Select.ItemIndicator>
|
|
203
|
+
</Select.Item>
|
|
204
|
+
</Select.Content>
|
|
205
|
+
</Select.Root>,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
await user.click(screen.getByRole("button"));
|
|
209
|
+
|
|
210
|
+
expect(screen.getByTestId("vue-indicator")).toHaveAttribute(
|
|
211
|
+
"data-state",
|
|
212
|
+
"unchecked",
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("composes onto a custom element with asChild", async () => {
|
|
217
|
+
const user = userEvent.setup();
|
|
218
|
+
render(
|
|
219
|
+
<Select.Root defaultValue="react">
|
|
220
|
+
<Select.Trigger>
|
|
221
|
+
<Select.Value placeholder="Pick" />
|
|
222
|
+
</Select.Trigger>
|
|
223
|
+
<Select.Content>
|
|
224
|
+
<Select.Item value="react">
|
|
225
|
+
React
|
|
226
|
+
<Select.ItemIndicator asChild>
|
|
227
|
+
<i data-testid="mark">✓</i>
|
|
228
|
+
</Select.ItemIndicator>
|
|
229
|
+
</Select.Item>
|
|
230
|
+
</Select.Content>
|
|
231
|
+
</Select.Root>,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
await user.click(screen.getByRole("button"));
|
|
235
|
+
|
|
236
|
+
const mark = screen.getByTestId("mark");
|
|
237
|
+
expect(mark.tagName).toBe("I");
|
|
238
|
+
expect(mark).toHaveAttribute("data-state", "checked");
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Selector for enabled listbox options (disabled ones are skipped). */
|
|
2
|
+
export const OPTION_SELECTOR = '[role="option"]:not([aria-disabled="true"])';
|
|
3
|
+
|
|
4
|
+
/** Selector for the currently-selected option. */
|
|
5
|
+
export const SELECTED_OPTION_SELECTOR = '[role="option"][aria-selected="true"]';
|
|
6
|
+
|
|
7
|
+
/** Typeahead accumulation window, in milliseconds. */
|
|
8
|
+
export const TYPEAHEAD_RESET_MS = 500;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { KeyboardEvent, KeyboardEventHandler, useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
import { composeEventHandlers } from "../../Slot/index.ts";
|
|
4
|
+
import { useSelectContext } from "../SelectContext";
|
|
5
|
+
import {
|
|
6
|
+
OPTION_SELECTOR,
|
|
7
|
+
SELECTED_OPTION_SELECTOR,
|
|
8
|
+
TYPEAHEAD_RESET_MS,
|
|
9
|
+
} from "../constants";
|
|
10
|
+
import type { SelectContentProps } from "../types";
|
|
11
|
+
|
|
12
|
+
type UseSelectContentArgs = {
|
|
13
|
+
onKeyDown?: KeyboardEventHandler<HTMLDivElement>;
|
|
14
|
+
restProps: Omit<SelectContentProps, "children" | "onKeyDown" | "asChild">;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Drives the listbox popover for the rich Select: opens/closes the native
|
|
19
|
+
* Popover in sync with context `open`, moves focus onto the selected (or
|
|
20
|
+
* first enabled) option on open, syncs React state when the browser
|
|
21
|
+
* light-dismisses (`toggle`), and implements the WAI-ARIA listbox keyboard
|
|
22
|
+
* contract — Arrow/Home/End roving focus (skipping disabled, wrapping),
|
|
23
|
+
* Enter/Space to select, Escape to close (refocusing the trigger), and
|
|
24
|
+
* printable-character typeahead.
|
|
25
|
+
*/
|
|
26
|
+
export function useSelectContent({ onKeyDown, restProps }: UseSelectContentArgs) {
|
|
27
|
+
const { open, setOpen, contentId, triggerRef } = useSelectContext();
|
|
28
|
+
const listRef = useRef<HTMLDivElement | null>(null);
|
|
29
|
+
const typeaheadRef = useRef<{ query: string; timer: number | null }>({
|
|
30
|
+
query: "",
|
|
31
|
+
timer: null,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const list = listRef.current!;
|
|
36
|
+
if (open) {
|
|
37
|
+
list.showPopover();
|
|
38
|
+
const selected = list.querySelector<HTMLElement>(SELECTED_OPTION_SELECTOR);
|
|
39
|
+
const first = list.querySelector<HTMLElement>(OPTION_SELECTOR);
|
|
40
|
+
(selected ?? first ?? list).focus();
|
|
41
|
+
} else {
|
|
42
|
+
try {
|
|
43
|
+
list.hidePopover();
|
|
44
|
+
} catch {
|
|
45
|
+
/* already hidden — no-op (e.g. browser already light-dismissed) */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}, [open]);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const list = listRef.current!;
|
|
52
|
+
const handleToggle = (event: Event) => {
|
|
53
|
+
if ((event as ToggleEvent).newState === "closed") setOpen(false);
|
|
54
|
+
};
|
|
55
|
+
list.addEventListener("toggle", handleToggle);
|
|
56
|
+
// Stryker disable next-line StringLiteral,ArrowFunction: equivalent — unmount cleanup on a discarded node; removing/altering it only leaks a listener, unobservable.
|
|
57
|
+
return () => list.removeEventListener("toggle", handleToggle);
|
|
58
|
+
},
|
|
59
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — setOpen is stable, so the effect subscribes once either way.
|
|
60
|
+
[setOpen]);
|
|
61
|
+
|
|
62
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
63
|
+
if (event.key === "Escape") {
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
setOpen(false);
|
|
66
|
+
// Stryker disable next-line OptionalChaining: unreachable — the trigger is always mounted while the listbox is open.
|
|
67
|
+
triggerRef.current?.focus();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const list = listRef.current!;
|
|
72
|
+
const items = Array.from(
|
|
73
|
+
list.querySelectorAll<HTMLElement>(OPTION_SELECTOR),
|
|
74
|
+
);
|
|
75
|
+
if (items.length === 0) return;
|
|
76
|
+
const currentIndex = items.indexOf(document.activeElement as HTMLElement);
|
|
77
|
+
|
|
78
|
+
let nextIndex: number | null = null;
|
|
79
|
+
if (event.key === "ArrowDown") {
|
|
80
|
+
nextIndex = (currentIndex + 1) % items.length;
|
|
81
|
+
} else if (event.key === "ArrowUp") {
|
|
82
|
+
nextIndex = currentIndex <= 0 ? items.length - 1 : currentIndex - 1;
|
|
83
|
+
} else if (event.key === "Home") {
|
|
84
|
+
nextIndex = 0;
|
|
85
|
+
} else if (event.key === "End") {
|
|
86
|
+
nextIndex = items.length - 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (nextIndex !== null) {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
items[nextIndex].focus();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
96
|
+
if (currentIndex < 0) return;
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
items[currentIndex].click();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Space is already handled by the Enter/Space branch above, so any
|
|
103
|
+
// remaining single-character key is a typeahead search.
|
|
104
|
+
// Stryker disable next-line ConditionalExpression,EqualityOperator: the false / length!==1 variants are killed by the typeahead tests; the always-true variant is equivalent — no unhandled multi-character key (modifiers, arrows) can prefix-match an option.
|
|
105
|
+
if (event.key.length === 1) {
|
|
106
|
+
const state = typeaheadRef.current;
|
|
107
|
+
// Stryker disable next-line ConditionalExpression,EqualityOperator,MethodExpression,BlockStatement: clearing the pending timer is a real-time optimization; keystroke handling here is synchronous, so its presence/condition/target never changes the search outcome.
|
|
108
|
+
if (state.timer !== null) window.clearTimeout(state.timer);
|
|
109
|
+
state.query = (state.query + event.key).toLowerCase();
|
|
110
|
+
state.timer = window.setTimeout(() => {
|
|
111
|
+
state.query = "";
|
|
112
|
+
state.timer = null;
|
|
113
|
+
}, TYPEAHEAD_RESET_MS);
|
|
114
|
+
|
|
115
|
+
const isRepeat =
|
|
116
|
+
// Stryker disable next-line EqualityOperator,ConditionalExpression: equivalent — `length > 1 → true` reduces to `every(...)`, which for a single char produces the same searchQuery/offset outcome; the < 1 / false variants are killed by the repeated-char cycle test.
|
|
117
|
+
state.query.length > 1 &&
|
|
118
|
+
state.query.split("").every((c) => c === state.query[0]);
|
|
119
|
+
const searchQuery = isRepeat ? state.query[0] : state.query;
|
|
120
|
+
// Stryker disable next-line EqualityOperator: <= 0 is equivalent — currentIndex 0 yields startIndex 0 either way; the >= 0 variant is killed by the cycle test (advancing from a focused non-first item).
|
|
121
|
+
const startIndex = currentIndex < 0 ? 0 : currentIndex;
|
|
122
|
+
// Stryker disable next-line EqualityOperator: the length comparison variants are equivalent — a length-1 query and an isRepeat query both take offset 1 with the same outcome across the fixtures; the killable path is covered by the multi-char prefix test.
|
|
123
|
+
const offset = searchQuery.length === 1 || isRepeat ? 1 : 0;
|
|
124
|
+
// Stryker disable next-line EqualityOperator: <= is equivalent — the index wraps via % items.length, so the extra iteration only re-checks an already-visited index.
|
|
125
|
+
for (let i = 0; i < items.length; i++) {
|
|
126
|
+
const index = (startIndex + offset + i) % items.length;
|
|
127
|
+
// Stryker disable next-line MethodExpression: dropping .trim() is equivalent — the fixtures' (and real) option labels carry no surrounding whitespace; dropping .toLowerCase() is killed by the typeahead tests (lowercase query vs title-case labels).
|
|
128
|
+
const text = items[index].textContent!.trim().toLowerCase();
|
|
129
|
+
if (text.startsWith(searchQuery)) {
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
items[index].focus();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const contentProps = {
|
|
139
|
+
...restProps,
|
|
140
|
+
ref: listRef,
|
|
141
|
+
id: contentId,
|
|
142
|
+
role: "listbox" as const,
|
|
143
|
+
tabIndex: -1,
|
|
144
|
+
popover: "auto" as const,
|
|
145
|
+
onKeyDown: composeEventHandlers(onKeyDown, handleKeyDown),
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return { contentProps };
|
|
149
|
+
}
|