@primitiv-ui/react 0.1.28 → 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 +19 -2
- 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 +32 -11
- package/src/Accordion/README.md +2 -2
- 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 +139 -21
- 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/__tests__/Button.contract.test.tsx +1 -1
- package/src/Button/types.ts +21 -0
- package/src/Carousel/Carousel.tsx +268 -20
- package/src/Carousel/README.md +764 -87
- 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 +90 -2
- 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 +458 -36
- package/src/Checkbox/Checkbox.tsx +11 -3
- 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 +14 -5
- package/src/CheckboxCard/README.md +1 -1
- 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 +41 -1
- 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/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 +270 -4
- package/src/ContextMenu/README.md +1 -1
- package/src/ContextMenu/types.ts +148 -6
- package/src/DirectionProvider/DirectionProvider.tsx +41 -9
- package/src/DirectionProvider/README.md +4 -4
- 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.contract.test.tsx +1 -1
- 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 +119 -2
- package/src/Dropdown/README.md +7 -5
- package/src/Dropdown/types.ts +241 -11
- package/src/EmptyState/EmptyState.tsx +74 -4
- package/src/EmptyState/README.md +2 -2
- 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/README.md +1 -1
- 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/hooks/useFieldProps.ts +1 -1
- package/src/Field/types.ts +60 -12
- package/src/Fieldset/Fieldset.tsx +55 -19
- package/src/Fieldset/README.md +2 -2
- package/src/Fieldset/__tests__/Fieldset.basic-rendering.test.tsx +9 -0
- package/src/Fieldset/types.ts +20 -2
- package/src/Input/Input.tsx +15 -10
- package/src/Input/README.md +2 -2
- 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/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 +186 -45
- 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 +136 -30
- package/src/MillerColumns/utils.ts +69 -3
- package/src/Modal/Modal.tsx +35 -8
- package/src/Modal/README.md +24 -15
- 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/README.md +2 -2
- 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/README.md +1 -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 +10 -2
- 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/README.md +2 -2
- package/src/RadioGroup/RadioGroup.tsx +12 -1
- 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 +87 -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 +596 -69
- 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 +7 -7
- 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/README.md +3 -3
- package/src/SkipNav/SkipNav.tsx +72 -32
- package/src/SkipNav/__tests__/SkipNav.basic-rendering.test.tsx +6 -0
- package/src/SkipNav/types.ts +17 -9
- package/src/Slider/README.md +37 -7
- package/src/Slider/Slider.tsx +42 -4
- 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 +112 -2
- package/src/Slot/composeEventHandlers.ts +26 -2
- package/src/Slot/types.ts +16 -0
- 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/Status/Status.tsx +36 -15
- package/src/Status/__tests__/Status.test.tsx +5 -0
- package/src/Status/types.ts +12 -1
- package/src/Switch/README.md +1 -1
- package/src/Switch/Switch.tsx +17 -4
- 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/README.md +110 -8
- package/src/Table/Table.tsx +246 -16
- 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 +191 -17
- package/src/Tabs/README.md +4 -4
- package/src/Tabs/Tabs.tsx +13 -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/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/README.md +4 -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/README.md +7 -7
- package/src/Tooltip/Tooltip.tsx +100 -22
- 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/__tests__/useTooltipProvider.test.tsx +40 -0
- package/src/Tooltip/hooks/useTooltipContent.ts +3 -0
- package/src/Tooltip/hooks/useTooltipProvider.ts +46 -8
- package/src/Tooltip/hooks/useTooltipRoot.ts +30 -6
- package/src/Tooltip/types.ts +89 -8
- package/src/Tree/README.md +69 -5
- package/src/Tree/Tree.tsx +188 -34
- 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/types.ts +54 -12
- package/src/Tree/utils.ts +46 -4
- 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/hooks/index.ts +1 -0
- package/src/hooks/useTypeahead.ts +95 -0
- package/src/index.ts +10 -0
- package/src/test/intersectionObserverPolyfill.ts +7 -0
- package/src/test/resizeObserverPolyfill.ts +80 -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/focusable.ts +15 -0
- package/src/utils/getKeyToActionMap.ts +1 -1
- package/src/utils/index.ts +1 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { Listbox } from "../Listbox";
|
|
6
|
+
|
|
7
|
+
import { fruits } from "./Listbox.fixtures";
|
|
8
|
+
|
|
9
|
+
describe("Listbox selectionFollowsFocus", () => {
|
|
10
|
+
it("selects each option the cursor lands on", async () => {
|
|
11
|
+
const user = userEvent.setup();
|
|
12
|
+
const onValueChange = vi.fn();
|
|
13
|
+
render(
|
|
14
|
+
<Listbox.Root
|
|
15
|
+
type="single"
|
|
16
|
+
defaultValue="apple"
|
|
17
|
+
selectionFollowsFocus
|
|
18
|
+
onValueChange={onValueChange}
|
|
19
|
+
aria-label="Fruits"
|
|
20
|
+
>
|
|
21
|
+
{fruits.map((fruit) => (
|
|
22
|
+
<Listbox.Option key={fruit.value} value={fruit.value}>
|
|
23
|
+
{fruit.label}
|
|
24
|
+
</Listbox.Option>
|
|
25
|
+
))}
|
|
26
|
+
</Listbox.Root>,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
await user.tab();
|
|
30
|
+
await user.keyboard("{ArrowDown}");
|
|
31
|
+
|
|
32
|
+
expect(onValueChange).toHaveBeenCalledWith("banana");
|
|
33
|
+
expect(screen.getByRole("option", { name: "Banana" })).toHaveAttribute(
|
|
34
|
+
"aria-selected",
|
|
35
|
+
"true",
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("still moves the cursor with the selection", async () => {
|
|
40
|
+
const user = userEvent.setup();
|
|
41
|
+
render(
|
|
42
|
+
<Listbox.Root
|
|
43
|
+
type="single"
|
|
44
|
+
defaultValue="apple"
|
|
45
|
+
selectionFollowsFocus
|
|
46
|
+
aria-label="Fruits"
|
|
47
|
+
>
|
|
48
|
+
{fruits.map((fruit) => (
|
|
49
|
+
<Listbox.Option key={fruit.value} value={fruit.value}>
|
|
50
|
+
{fruit.label}
|
|
51
|
+
</Listbox.Option>
|
|
52
|
+
))}
|
|
53
|
+
</Listbox.Root>,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
await user.tab();
|
|
57
|
+
await user.keyboard("{End}");
|
|
58
|
+
|
|
59
|
+
expect(screen.getByRole("listbox")).toHaveAttribute(
|
|
60
|
+
"aria-activedescendant",
|
|
61
|
+
screen.getByRole("option", { name: "Cherry" }).id,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { render } from "@testing-library/react";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { Listbox } from "../Listbox";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Options read a strict context, so rendering one outside its provider throws
|
|
8
|
+
* a consumer-actionable message rather than failing obscurely further down.
|
|
9
|
+
* The message itself is the contract — assert it, not just that something threw.
|
|
10
|
+
*/
|
|
11
|
+
describe("Listbox strict context", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("names <Listbox.Root> when an Option is rendered outside one", () => {
|
|
17
|
+
expect(() =>
|
|
18
|
+
render(<Listbox.Option value="apple">Apple</Listbox.Option>),
|
|
19
|
+
).toThrow("Listbox.Option must be rendered as a child of Listbox.Root");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { Listbox } from "../Listbox";
|
|
6
|
+
import { TYPEAHEAD_RESET_MS } from "../constants";
|
|
7
|
+
|
|
8
|
+
import { typeaheadOptions } from "./Listbox.fixtures";
|
|
9
|
+
|
|
10
|
+
function renderListbox(props: { onValueChange?: (value: string) => void } = {}) {
|
|
11
|
+
return render(
|
|
12
|
+
<Listbox.Root type="single" aria-label="Fruits" {...props}>
|
|
13
|
+
{typeaheadOptions.map((fruit) => (
|
|
14
|
+
<Listbox.Option key={fruit.value} value={fruit.value}>
|
|
15
|
+
{fruit.label}
|
|
16
|
+
</Listbox.Option>
|
|
17
|
+
))}
|
|
18
|
+
</Listbox.Root>,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const cursorIsOn = (name: string) =>
|
|
23
|
+
expect(screen.getByRole("listbox")).toHaveAttribute(
|
|
24
|
+
"aria-activedescendant",
|
|
25
|
+
screen.getByRole("option", { name }).id,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
describe("Listbox typeahead", () => {
|
|
29
|
+
it("moves the cursor to the first option matching a single character", async () => {
|
|
30
|
+
const user = userEvent.setup();
|
|
31
|
+
renderListbox();
|
|
32
|
+
|
|
33
|
+
await user.tab();
|
|
34
|
+
await user.keyboard("b");
|
|
35
|
+
|
|
36
|
+
cursorIsOn("Banana");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("cycles through matches on a repeated character, wrapping around", async () => {
|
|
40
|
+
const user = userEvent.setup();
|
|
41
|
+
renderListbox();
|
|
42
|
+
|
|
43
|
+
await user.tab();
|
|
44
|
+
|
|
45
|
+
await user.keyboard("a");
|
|
46
|
+
cursorIsOn("Apricot");
|
|
47
|
+
|
|
48
|
+
await user.keyboard("a");
|
|
49
|
+
cursorIsOn("Avocado");
|
|
50
|
+
|
|
51
|
+
await user.keyboard("a");
|
|
52
|
+
cursorIsOn("Apple");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("narrows to a prefix match with a multi-character query", async () => {
|
|
56
|
+
const user = userEvent.setup();
|
|
57
|
+
renderListbox();
|
|
58
|
+
|
|
59
|
+
await user.tab();
|
|
60
|
+
await user.keyboard("av");
|
|
61
|
+
|
|
62
|
+
cursorIsOn("Avocado");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("does not select the option it lands on", async () => {
|
|
66
|
+
const user = userEvent.setup();
|
|
67
|
+
const onValueChange = vi.fn();
|
|
68
|
+
renderListbox({ onValueChange });
|
|
69
|
+
|
|
70
|
+
await user.tab();
|
|
71
|
+
await user.keyboard("b");
|
|
72
|
+
|
|
73
|
+
expect(onValueChange).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("skips a disabled match", async () => {
|
|
77
|
+
const user = userEvent.setup();
|
|
78
|
+
render(
|
|
79
|
+
<Listbox.Root type="single" aria-label="Fruits">
|
|
80
|
+
<Listbox.Option value="apple">Apple</Listbox.Option>
|
|
81
|
+
<Listbox.Option value="banana" disabled>
|
|
82
|
+
Banana
|
|
83
|
+
</Listbox.Option>
|
|
84
|
+
<Listbox.Option value="blueberry">Blueberry</Listbox.Option>
|
|
85
|
+
</Listbox.Root>,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
await user.tab();
|
|
89
|
+
await user.keyboard("b");
|
|
90
|
+
|
|
91
|
+
cursorIsOn("Blueberry");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The accumulation window is real elapsed time. userEvent + fake timers
|
|
97
|
+
* deadlocks in this repo (RTL's asyncWrapper drives fake clocks through a
|
|
98
|
+
* global `jest`, which vitest does not define), so this waits on the real
|
|
99
|
+
* clock rather than reaching for fireEvent. The timer callback only clears a
|
|
100
|
+
* ref, so nothing lands outside act.
|
|
101
|
+
*/
|
|
102
|
+
describe("Listbox typeahead reset window", () => {
|
|
103
|
+
it("starts a fresh query once the accumulation window lapses", async () => {
|
|
104
|
+
const user = userEvent.setup();
|
|
105
|
+
renderListbox();
|
|
106
|
+
|
|
107
|
+
await user.tab();
|
|
108
|
+
|
|
109
|
+
// A two-character query narrows in place onto the only "av..." match.
|
|
110
|
+
await user.keyboard("av");
|
|
111
|
+
cursorIsOn("Avocado");
|
|
112
|
+
|
|
113
|
+
await new Promise((resolve) =>
|
|
114
|
+
setTimeout(resolve, TYPEAHEAD_RESET_MS + 100),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// With the buffer cleared this is a fresh single "a", which advances past
|
|
118
|
+
// Avocado and wraps to Apple. Had "av" survived, "ava" would match nothing
|
|
119
|
+
// and the cursor would not move.
|
|
120
|
+
await user.keyboard("a");
|
|
121
|
+
cursorIsOn("Apple");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { Listbox } from "../Listbox";
|
|
6
|
+
|
|
7
|
+
describe("Listbox uncontrolled state", () => {
|
|
8
|
+
it("marks the defaultValue option as selected", () => {
|
|
9
|
+
render(
|
|
10
|
+
<Listbox.Root type="single" defaultValue="banana" aria-label="Fruits">
|
|
11
|
+
<Listbox.Option value="apple">Apple</Listbox.Option>
|
|
12
|
+
<Listbox.Option value="banana">Banana</Listbox.Option>
|
|
13
|
+
</Listbox.Root>,
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
expect(screen.getByRole("option", { name: "Banana" })).toHaveAttribute(
|
|
17
|
+
"aria-selected",
|
|
18
|
+
"true",
|
|
19
|
+
);
|
|
20
|
+
expect(screen.getByRole("option", { name: "Apple" })).toHaveAttribute(
|
|
21
|
+
"aria-selected",
|
|
22
|
+
"false",
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("selects an option on click and reports it via onValueChange", async () => {
|
|
27
|
+
const user = userEvent.setup();
|
|
28
|
+
const onValueChange = vi.fn();
|
|
29
|
+
render(
|
|
30
|
+
<Listbox.Root
|
|
31
|
+
type="single"
|
|
32
|
+
onValueChange={onValueChange}
|
|
33
|
+
aria-label="Fruits"
|
|
34
|
+
>
|
|
35
|
+
<Listbox.Option value="apple">Apple</Listbox.Option>
|
|
36
|
+
<Listbox.Option value="banana">Banana</Listbox.Option>
|
|
37
|
+
</Listbox.Root>,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
await user.click(screen.getByRole("option", { name: "Banana" }));
|
|
41
|
+
|
|
42
|
+
expect(onValueChange).toHaveBeenCalledWith("banana");
|
|
43
|
+
expect(screen.getByRole("option", { name: "Banana" })).toHaveAttribute(
|
|
44
|
+
"aria-selected",
|
|
45
|
+
"true",
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./useListboxRoot";
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { useCallback, useId, useState, type KeyboardEvent } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCollection,
|
|
5
|
+
useControllableState,
|
|
6
|
+
useTypeahead,
|
|
7
|
+
} from "../../hooks/index.ts";
|
|
8
|
+
import { deriveId, getKeyToActionMap } from "../../utils/index.ts";
|
|
9
|
+
|
|
10
|
+
import { TYPEAHEAD_RESET_MS } from "../constants";
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
type OptionMeta = { element: HTMLElement; disabled: boolean };
|
|
14
|
+
|
|
15
|
+
type UseListboxRootArgs = {
|
|
16
|
+
type: "single" | "multiple";
|
|
17
|
+
defaultValue: string | string[] | undefined;
|
|
18
|
+
value: string | string[] | undefined;
|
|
19
|
+
onValueChange:
|
|
20
|
+
| ((value: string) => void)
|
|
21
|
+
| ((value: string[]) => void)
|
|
22
|
+
| undefined;
|
|
23
|
+
selectionFollowsFocus: boolean;
|
|
24
|
+
orientation: "horizontal" | "vertical";
|
|
25
|
+
dir: "ltr" | "rtl";
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function toArray(value: string | string[] | undefined): string[] {
|
|
29
|
+
if (value === undefined) return [];
|
|
30
|
+
return typeof value === "string" ? [value] : value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Owns the Listbox's selection state and its virtual-focus cursor.
|
|
35
|
+
*
|
|
36
|
+
* Selection is normalised into an array so options only ever ask "am I in
|
|
37
|
+
* the selected set?". The cursor (`activeValue`) is the option
|
|
38
|
+
* `aria-activedescendant` points at — real DOM focus stays on the root, so
|
|
39
|
+
* the cursor is seeded when the root takes focus and cleared when it loses
|
|
40
|
+
* it.
|
|
41
|
+
*
|
|
42
|
+
* No `tabIndex` is moved between options: the root is the single tab stop,
|
|
43
|
+
* and navigation just repoints the cursor.
|
|
44
|
+
*
|
|
45
|
+
* Navigation order is read from the **live DOM** on every interaction rather
|
|
46
|
+
* than from a memoised array. Reordering options in place (APG's rearrangeable
|
|
47
|
+
* example) keeps the same elements mounted with unchanged props, so nothing
|
|
48
|
+
* re-registers and any cached order would silently go stale.
|
|
49
|
+
*/
|
|
50
|
+
export function useListboxRoot({
|
|
51
|
+
type,
|
|
52
|
+
defaultValue,
|
|
53
|
+
value: controlledValue,
|
|
54
|
+
onValueChange,
|
|
55
|
+
selectionFollowsFocus,
|
|
56
|
+
orientation,
|
|
57
|
+
dir,
|
|
58
|
+
}: UseListboxRootArgs): {
|
|
59
|
+
selectedValues: string[];
|
|
60
|
+
select: (optionValue: string) => void;
|
|
61
|
+
activeValue: string | undefined;
|
|
62
|
+
registerOption: (
|
|
63
|
+
optionValue: string,
|
|
64
|
+
element: HTMLElement | null,
|
|
65
|
+
disabled?: boolean,
|
|
66
|
+
) => void;
|
|
67
|
+
getOptionId: (optionValue: string) => string;
|
|
68
|
+
seedActiveValue: () => void;
|
|
69
|
+
clearActiveValue: () => void;
|
|
70
|
+
handleKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
|
|
71
|
+
moveCursor: (optionValue: string) => void;
|
|
72
|
+
} {
|
|
73
|
+
const rootId = useId();
|
|
74
|
+
const [selectedValues, setSelectedValues] = useControllableState<string[]>(
|
|
75
|
+
controlledValue === undefined ? undefined : toArray(controlledValue),
|
|
76
|
+
toArray(defaultValue),
|
|
77
|
+
);
|
|
78
|
+
const [activeValue, setActiveValue] = useState<string | undefined>(undefined);
|
|
79
|
+
// `keys` is deliberately unused: navigation order comes from the DOM, not
|
|
80
|
+
// from registration order. The collection is still what keeps `itemsRef`
|
|
81
|
+
// populated and re-renders the Root as options mount and unmount.
|
|
82
|
+
const { register: registerBase, itemsRef } = useCollection<
|
|
83
|
+
string,
|
|
84
|
+
OptionMeta
|
|
85
|
+
>();
|
|
86
|
+
|
|
87
|
+
const registerOption = useCallback(
|
|
88
|
+
// Stryker disable next-line BooleanLiteral: equivalent — the only caller that omits this argument is the unmount path, which passes a null element and so never reads it.
|
|
89
|
+
(optionValue: string, element: HTMLElement | null, disabled = false) => {
|
|
90
|
+
registerBase(optionValue, element ? { element, disabled } : null);
|
|
91
|
+
},
|
|
92
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — registerBase is a stable useCollection callback, so an empty dep array memoises identically.
|
|
93
|
+
[registerBase],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Disabled options stay in the DOM and in the a11y tree, but drop out of
|
|
97
|
+
// every navigable list: cursor movement and focus seeding both skip them.
|
|
98
|
+
//
|
|
99
|
+
// Sorted by document position, not registration order — see the note above.
|
|
100
|
+
const getNavigableValues = useCallback(
|
|
101
|
+
() => {
|
|
102
|
+
const entries = Array.from(itemsRef.current.entries()).filter(
|
|
103
|
+
([, meta]) => !meta.disabled,
|
|
104
|
+
);
|
|
105
|
+
entries.sort(([, a], [, b]) =>
|
|
106
|
+
a.element.compareDocumentPosition(b.element) &
|
|
107
|
+
Node.DOCUMENT_POSITION_FOLLOWING
|
|
108
|
+
? -1
|
|
109
|
+
: 1,
|
|
110
|
+
);
|
|
111
|
+
return entries.map(([key]) => key);
|
|
112
|
+
},
|
|
113
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — itemsRef is a stable RefObject.
|
|
114
|
+
[itemsRef],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Single mode replaces the selection outright — re-selecting the current
|
|
118
|
+
// option is a no-op re-select, not a deselect (a listbox is not a toggle).
|
|
119
|
+
// Multiple mode toggles the option in place, preserving the rest.
|
|
120
|
+
const select = useCallback(
|
|
121
|
+
(optionValue: string) => {
|
|
122
|
+
if (type === "single") {
|
|
123
|
+
setSelectedValues([optionValue]);
|
|
124
|
+
(onValueChange as ((value: string) => void) | undefined)?.(optionValue);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const next = selectedValues.includes(optionValue)
|
|
128
|
+
? selectedValues.filter((key) => key !== optionValue)
|
|
129
|
+
: [...selectedValues, optionValue];
|
|
130
|
+
setSelectedValues(next);
|
|
131
|
+
(onValueChange as ((value: string[]) => void) | undefined)?.(next);
|
|
132
|
+
},
|
|
133
|
+
[type, selectedValues, setSelectedValues, onValueChange],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Bulk selection writes. Multiple-mode only: single mode's onValueChange
|
|
137
|
+
// takes a string, and none of the range shortcuts apply to it.
|
|
138
|
+
const commitSelection = useCallback(
|
|
139
|
+
(next: string[]) => {
|
|
140
|
+
setSelectedValues(next);
|
|
141
|
+
(onValueChange as ((value: string[]) => void) | undefined)?.(next);
|
|
142
|
+
},
|
|
143
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — both are stable for a given render pair; the value read is the ref/state itself.
|
|
144
|
+
[setSelectedValues, onValueChange],
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// APG: Ctrl+A selects all, or deselects if everything is already selected.
|
|
148
|
+
// Disabled options are not selectable, so they are not part of "all".
|
|
149
|
+
const toggleSelectAll = useCallback(() => {
|
|
150
|
+
const navigable = getNavigableValues();
|
|
151
|
+
const allSelected =
|
|
152
|
+
// Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent — with nothing navigable both arms commit the empty array.
|
|
153
|
+
navigable.length > 0 &&
|
|
154
|
+
navigable.every((key) => selectedValues.includes(key));
|
|
155
|
+
commitSelection(allSelected ? [] : navigable);
|
|
156
|
+
}, [getNavigableValues, selectedValues, commitSelection]);
|
|
157
|
+
|
|
158
|
+
const getOptionId = useCallback(
|
|
159
|
+
// Stryker disable next-line StringLiteral: equivalent — the suffix only has to be stable; every assertion compares against the element's own id.
|
|
160
|
+
(optionValue: string) => deriveId(rootId, "option", optionValue),
|
|
161
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — rootId is a useId() constant.
|
|
162
|
+
[rootId],
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// APG: when the referenced option is not fully visible, scroll it into view.
|
|
166
|
+
const moveCursor = useCallback(
|
|
167
|
+
(optionValue: string) => {
|
|
168
|
+
setActiveValue(optionValue);
|
|
169
|
+
// Stryker disable next-line OptionalChaining: unreachable — every value reaching moveCursor comes from getNavigableValues(), so the entry is always registered.
|
|
170
|
+
itemsRef.current
|
|
171
|
+
.get(optionValue)
|
|
172
|
+
?.element.scrollIntoView({ block: "nearest" });
|
|
173
|
+
},
|
|
174
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — itemsRef is a stable RefObject.
|
|
175
|
+
[itemsRef],
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// APG: when the listbox receives focus, the first selected option takes
|
|
179
|
+
// the cursor; with nothing selected, the first option does.
|
|
180
|
+
const seedActiveValue = useCallback(
|
|
181
|
+
() => {
|
|
182
|
+
const navigable = getNavigableValues();
|
|
183
|
+
setActiveValue(
|
|
184
|
+
(current) =>
|
|
185
|
+
current ??
|
|
186
|
+
navigable.find((key) => selectedValues.includes(key)) ??
|
|
187
|
+
navigable[0],
|
|
188
|
+
);
|
|
189
|
+
},
|
|
190
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — the callback is rebuilt each render and reads both through its closure.
|
|
191
|
+
[getNavigableValues, selectedValues],
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — the dep array is already empty; adding a phantom entry cannot change a setState-only callback.
|
|
195
|
+
const clearActiveValue = useCallback(() => setActiveValue(undefined), []);
|
|
196
|
+
|
|
197
|
+
// One navigation path for both the arrow keymap and typeahead, so
|
|
198
|
+
// `selectionFollowsFocus` applies identically to each.
|
|
199
|
+
const navigateTo = useCallback(
|
|
200
|
+
(target: string) => {
|
|
201
|
+
moveCursor(target);
|
|
202
|
+
if (selectionFollowsFocus) select(target);
|
|
203
|
+
},
|
|
204
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — a stale closure here still calls the same setState and select.
|
|
205
|
+
[moveCursor, selectionFollowsFocus, select],
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
// APG: Shift+Arrow "moves focus and selects" the option it lands on. Unlike
|
|
209
|
+
// the plain arrows this deliberately does not wrap — extending a range off
|
|
210
|
+
// the end and round to the top would select options never travelled past.
|
|
211
|
+
const extendSelection = useCallback(
|
|
212
|
+
(direction: "next" | "prev") => {
|
|
213
|
+
const navigable = getNavigableValues();
|
|
214
|
+
const currentIndex =
|
|
215
|
+
// Stryker disable next-line ConditionalExpression: equivalent — indexOf(undefined) is itself -1, so both arms yield the same index.
|
|
216
|
+
activeValue === undefined ? -1 : navigable.indexOf(activeValue);
|
|
217
|
+
if (currentIndex === -1) return;
|
|
218
|
+
|
|
219
|
+
const targetIndex =
|
|
220
|
+
direction === "next" ? currentIndex + 1 : currentIndex - 1;
|
|
221
|
+
if (targetIndex < 0 || targetIndex >= navigable.length) return;
|
|
222
|
+
|
|
223
|
+
const target = navigable[targetIndex];
|
|
224
|
+
moveCursor(target);
|
|
225
|
+
if (!selectedValues.includes(target)) {
|
|
226
|
+
commitSelection([...selectedValues, target]);
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
[
|
|
230
|
+
activeValue,
|
|
231
|
+
getNavigableValues,
|
|
232
|
+
moveCursor,
|
|
233
|
+
selectedValues,
|
|
234
|
+
commitSelection,
|
|
235
|
+
],
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
// APG: Ctrl+Shift+Home/End selects from the cursor through to the first or
|
|
239
|
+
// last option and moves the cursor to that edge. Selections outside the
|
|
240
|
+
// swept range are preserved.
|
|
241
|
+
const extendToEdge = useCallback(
|
|
242
|
+
(edge: "first" | "last") => {
|
|
243
|
+
const navigable = getNavigableValues();
|
|
244
|
+
const currentIndex =
|
|
245
|
+
// Stryker disable next-line ConditionalExpression: equivalent — indexOf(undefined) is itself -1, so both arms yield the same index.
|
|
246
|
+
activeValue === undefined ? -1 : navigable.indexOf(activeValue);
|
|
247
|
+
if (currentIndex === -1) return;
|
|
248
|
+
|
|
249
|
+
const range =
|
|
250
|
+
edge === "first"
|
|
251
|
+
? navigable.slice(0, currentIndex + 1)
|
|
252
|
+
: navigable.slice(currentIndex);
|
|
253
|
+
|
|
254
|
+
const next = [...selectedValues];
|
|
255
|
+
for (const key of range) {
|
|
256
|
+
if (!next.includes(key)) next.push(key);
|
|
257
|
+
}
|
|
258
|
+
commitSelection(next);
|
|
259
|
+
moveCursor(
|
|
260
|
+
edge === "first" ? navigable[0] : navigable[navigable.length - 1],
|
|
261
|
+
);
|
|
262
|
+
},
|
|
263
|
+
[
|
|
264
|
+
activeValue,
|
|
265
|
+
getNavigableValues,
|
|
266
|
+
selectedValues,
|
|
267
|
+
commitSelection,
|
|
268
|
+
moveCursor,
|
|
269
|
+
],
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
// The arrow / Home / End / Enter-Space keymap. This mirrors what
|
|
273
|
+
// `useRovingTabindex` does, but resolves `navigable` at event time so a
|
|
274
|
+
// reorder is picked up; that hook takes its list at render time.
|
|
275
|
+
const handleNavigationKey = useCallback(
|
|
276
|
+
(event: KeyboardEvent<HTMLElement>) => {
|
|
277
|
+
const action = getKeyToActionMap({
|
|
278
|
+
orientation,
|
|
279
|
+
dir,
|
|
280
|
+
homeEnd: true,
|
|
281
|
+
activate: true,
|
|
282
|
+
})[event.key];
|
|
283
|
+
if (!action) return;
|
|
284
|
+
|
|
285
|
+
const navigable = getNavigableValues();
|
|
286
|
+
// Stryker disable next-line ConditionalExpression: equivalent — with no options every downstream index resolves to undefined, which moves the cursor nowhere.
|
|
287
|
+
if (navigable.length === 0) return;
|
|
288
|
+
|
|
289
|
+
if (action === "activate") {
|
|
290
|
+
if (activeValue === undefined) return;
|
|
291
|
+
event.preventDefault();
|
|
292
|
+
select(activeValue);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const currentIndex =
|
|
297
|
+
// Stryker disable next-line ConditionalExpression: equivalent — indexOf(undefined) is itself -1, so both arms yield the same index.
|
|
298
|
+
activeValue === undefined ? -1 : navigable.indexOf(activeValue);
|
|
299
|
+
let targetIndex: number;
|
|
300
|
+
if (action === "first") {
|
|
301
|
+
targetIndex = 0;
|
|
302
|
+
} else if (action === "last") {
|
|
303
|
+
targetIndex = navigable.length - 1;
|
|
304
|
+
} else if (currentIndex === -1) {
|
|
305
|
+
// No cursor yet — the options streamed in after the listbox took
|
|
306
|
+
// focus, so seeding never ran. Enter the list from the near end
|
|
307
|
+
// rather than swallowing the keystroke.
|
|
308
|
+
targetIndex = action === "next" ? 0 : navigable.length - 1;
|
|
309
|
+
} else {
|
|
310
|
+
targetIndex =
|
|
311
|
+
action === "next"
|
|
312
|
+
? (currentIndex + 1) % navigable.length
|
|
313
|
+
: (currentIndex - 1 + navigable.length) % navigable.length;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
event.preventDefault();
|
|
317
|
+
navigateTo(navigable[targetIndex]);
|
|
318
|
+
},
|
|
319
|
+
[orientation, dir, getNavigableValues, activeValue, select, navigateTo],
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
// `optionValue` is always drawn from `navigable` (the enabled subset of the
|
|
323
|
+
// registered keys), and useCollection writes `itemsRef` and the keys state
|
|
324
|
+
// together — so the entry, and an element with textContent, always exist.
|
|
325
|
+
const getLabel = useCallback(
|
|
326
|
+
(optionValue: string) =>
|
|
327
|
+
itemsRef.current.get(optionValue)!.element.textContent!,
|
|
328
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — itemsRef is a stable RefObject.
|
|
329
|
+
[itemsRef],
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const { handleTypeahead } = useTypeahead({
|
|
333
|
+
getNavigable: getNavigableValues,
|
|
334
|
+
getLabel,
|
|
335
|
+
currentKey: activeValue,
|
|
336
|
+
onMatch: navigateTo,
|
|
337
|
+
resetMs: TYPEAHEAD_RESET_MS,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// The keymap gets first refusal: it calls preventDefault on every key it
|
|
341
|
+
// consumes, so a still-unhandled printable character falls through to
|
|
342
|
+
// typeahead (and Space, which activates, never does).
|
|
343
|
+
const handleKeyDown = useCallback(
|
|
344
|
+
(event: KeyboardEvent<HTMLElement>) => {
|
|
345
|
+
const chord = event.ctrlKey || event.metaKey;
|
|
346
|
+
|
|
347
|
+
if (chord && !event.altKey && event.key.toLowerCase() === "a") {
|
|
348
|
+
if (type !== "multiple") return;
|
|
349
|
+
event.preventDefault();
|
|
350
|
+
toggleSelectAll();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (
|
|
355
|
+
chord &&
|
|
356
|
+
event.shiftKey &&
|
|
357
|
+
(event.key === "Home" || event.key === "End")
|
|
358
|
+
) {
|
|
359
|
+
if (type !== "multiple") return;
|
|
360
|
+
event.preventDefault();
|
|
361
|
+
// Stryker disable next-line StringLiteral: equivalent — extendToEdge branches on `edge === "first"`, so any non-"first" string selects the same path.
|
|
362
|
+
extendToEdge(event.key === "Home" ? "first" : "last");
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Every other chorded shortcut belongs to the browser and the consumer —
|
|
367
|
+
// APG's rearrangeable example binds Alt+Arrow to its toolbar. Only Shift
|
|
368
|
+
// is ours, for range selection.
|
|
369
|
+
if (chord || event.altKey) return;
|
|
370
|
+
|
|
371
|
+
if (event.shiftKey && type === "multiple") {
|
|
372
|
+
const action = getKeyToActionMap({ orientation, dir })[event.key];
|
|
373
|
+
if (action === "next" || action === "prev") {
|
|
374
|
+
event.preventDefault();
|
|
375
|
+
extendSelection(action);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
handleNavigationKey(event);
|
|
381
|
+
if (event.defaultPrevented) return;
|
|
382
|
+
handleTypeahead(event);
|
|
383
|
+
},
|
|
384
|
+
[
|
|
385
|
+
type,
|
|
386
|
+
orientation,
|
|
387
|
+
dir,
|
|
388
|
+
toggleSelectAll,
|
|
389
|
+
extendSelection,
|
|
390
|
+
extendToEdge,
|
|
391
|
+
handleNavigationKey,
|
|
392
|
+
handleTypeahead,
|
|
393
|
+
],
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
selectedValues,
|
|
398
|
+
select,
|
|
399
|
+
activeValue,
|
|
400
|
+
registerOption,
|
|
401
|
+
getOptionId,
|
|
402
|
+
seedActiveValue,
|
|
403
|
+
clearActiveValue,
|
|
404
|
+
handleKeyDown,
|
|
405
|
+
moveCursor,
|
|
406
|
+
};
|
|
407
|
+
}
|