@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
package/src/Tree/utils.ts
CHANGED
|
@@ -3,14 +3,56 @@ import type { ReactElement, ReactNode } from "react";
|
|
|
3
3
|
|
|
4
4
|
import { TreeBranchControl, TreeBranchContent } from "./Tree";
|
|
5
5
|
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* The property a component sets on itself to declare which `Tree` part it
|
|
8
|
+
* stands in for.
|
|
9
|
+
*
|
|
10
|
+
* `Tree.Branch` finds its control and content by inspecting its children before
|
|
11
|
+
* rendering them, so it sees the component the consumer wrote — not what that
|
|
12
|
+
* component renders. A styled layer wraps every part in its own component to
|
|
13
|
+
* attach classes (that is exactly what `primitiv add tree` copies), and without
|
|
14
|
+
* a marker those wrappers are unrecognisable, making `Tree` the one primitive
|
|
15
|
+
* in the library that cannot be wrapped at all.
|
|
16
|
+
*
|
|
17
|
+
* Set it on the wrapper and it partitions like the part it renders:
|
|
18
|
+
*
|
|
19
|
+
* ```tsx
|
|
20
|
+
* function StyledBranchControl(props: TreeBranchControlProps) {
|
|
21
|
+
* return <Tree.BranchControl {...props} className="row" />;
|
|
22
|
+
* }
|
|
23
|
+
* StyledBranchControl[TREE_PART] = "branch-control";
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export const TREE_PART: "__primitivTreePart" = "__primitivTreePart";
|
|
27
|
+
|
|
28
|
+
/** The part names {@link TREE_PART} accepts. */
|
|
29
|
+
export type TreePartName = "branch-control" | "branch-content";
|
|
30
|
+
|
|
31
|
+
/** Read a component's {@link TREE_PART} marker, if it declares one. */
|
|
32
|
+
function treePartOf(type: unknown): TreePartName | undefined {
|
|
33
|
+
return (type as Record<string, TreePartName | undefined> | null)?.[TREE_PART];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether `node` is a `Tree.BranchControl` element — either the part itself, or
|
|
38
|
+
* a component that marks itself as standing in for it (see {@link TREE_PART}).
|
|
39
|
+
*/
|
|
7
40
|
export function isBranchControlElement(node: ReactNode): node is ReactElement {
|
|
8
|
-
return
|
|
41
|
+
return (
|
|
42
|
+
isValidElement(node) &&
|
|
43
|
+
(node.type === TreeBranchControl || treePartOf(node.type) === "branch-control")
|
|
44
|
+
);
|
|
9
45
|
}
|
|
10
46
|
|
|
11
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Whether `node` is a `Tree.BranchContent` element — either the part itself, or
|
|
49
|
+
* a component that marks itself as standing in for it (see {@link TREE_PART}).
|
|
50
|
+
*/
|
|
12
51
|
export function isBranchContentElement(node: ReactNode): node is ReactElement {
|
|
13
|
-
return
|
|
52
|
+
return (
|
|
53
|
+
isValidElement(node) &&
|
|
54
|
+
(node.type === TreeBranchContent || treePartOf(node.type) === "branch-content")
|
|
55
|
+
);
|
|
14
56
|
}
|
|
15
57
|
|
|
16
58
|
/**
|
|
@@ -19,18 +19,35 @@ const visuallyHiddenStyle: CSSProperties = {
|
|
|
19
19
|
/**
|
|
20
20
|
* Visually hides its children while keeping them in the accessibility tree.
|
|
21
21
|
*
|
|
22
|
-
* Renders a `<span>` carrying the canonical screen-reader-only clip styles
|
|
23
|
-
* the content is removed from the visual layout but
|
|
24
|
-
* assistive technology. Use it for text that gives a control
|
|
25
|
-
* accessible name without showing on screen.
|
|
22
|
+
* Renders a `<span>` carrying the canonical screen-reader-only clip styles
|
|
23
|
+
* (WCAG 2.1 Technique C7): the content is removed from the visual layout but
|
|
24
|
+
* still announced by assistive technology. Use it for text that gives a control
|
|
25
|
+
* or region an accessible name without showing on screen.
|
|
26
26
|
*
|
|
27
27
|
* **Functional styles.** Unlike other `@primitiv-ui/react` components, the
|
|
28
|
-
* clip styles are applied inline because they *are* the component's
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* clip styles are applied inline because they *are* the component's behaviour,
|
|
29
|
+
* not decoration:
|
|
30
|
+
*
|
|
31
|
+
* ```css
|
|
32
|
+
* position: absolute;
|
|
33
|
+
* width: 1px; height: 1px;
|
|
34
|
+
* padding: 0; margin: -1px;
|
|
35
|
+
* overflow: hidden;
|
|
36
|
+
* clip: rect(0 0 0 0);
|
|
37
|
+
* clip-path: inset(50%);
|
|
38
|
+
* white-space: nowrap;
|
|
39
|
+
* border-width: 0;
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* A consumer `style` prop is merged on top, so any individual property can
|
|
43
|
+
* still be overridden.
|
|
31
44
|
*
|
|
32
45
|
* **`asChild` composition.** Renders the consumer's element instead of a
|
|
33
|
-
* `<span>`, merging the clip styles
|
|
46
|
+
* `<span>`, merging the clip styles onto the child via the {@link Slot}
|
|
47
|
+
* utility. Useful when the hidden content needs specific semantics (e.g. a
|
|
48
|
+
* heading or landmark). The child must be a single React element.
|
|
49
|
+
*
|
|
50
|
+
* @extends HTMLSpanElement
|
|
34
51
|
*
|
|
35
52
|
* @example Accessible name for an icon-only button
|
|
36
53
|
* ```tsx
|
|
@@ -40,6 +57,14 @@ const visuallyHiddenStyle: CSSProperties = {
|
|
|
40
57
|
* </button>
|
|
41
58
|
* ```
|
|
42
59
|
*
|
|
60
|
+
* @example Region label hidden from sighted users
|
|
61
|
+
* ```tsx
|
|
62
|
+
* <nav aria-labelledby="nav-label">
|
|
63
|
+
* <VisuallyHidden id="nav-label">Primary navigation</VisuallyHidden>
|
|
64
|
+
* ...
|
|
65
|
+
* </nav>
|
|
66
|
+
* ```
|
|
67
|
+
*
|
|
43
68
|
* @example asChild — keep semantic markup hidden
|
|
44
69
|
* ```tsx
|
|
45
70
|
* <VisuallyHidden asChild>
|
|
@@ -15,15 +15,24 @@ describe("VisuallyHidden component", () => {
|
|
|
15
15
|
// Arrange
|
|
16
16
|
render(<VisuallyHidden>Loading complete</VisuallyHidden>);
|
|
17
17
|
|
|
18
|
-
// Assert
|
|
18
|
+
// Assert — the full WCAG C7 clip set; every property matters, so each is
|
|
19
|
+
// asserted (a missing one lets a mutant blank that value undetected).
|
|
19
20
|
const hidden = screen.getByText("Loading complete");
|
|
20
21
|
expect(hidden).toHaveStyle({
|
|
21
22
|
position: "absolute",
|
|
22
23
|
width: "1px",
|
|
23
24
|
height: "1px",
|
|
25
|
+
padding: "0px",
|
|
26
|
+
margin: "-1px",
|
|
24
27
|
overflow: "hidden",
|
|
25
28
|
whiteSpace: "nowrap",
|
|
29
|
+
borderWidth: "0px",
|
|
26
30
|
});
|
|
31
|
+
// jsdom's computed style doesn't surface clip/clip-path, so assert them on
|
|
32
|
+
// the inline style directly.
|
|
33
|
+
// jsdom normalises `rect(0 0 0 0)` to `rect(0px)`.
|
|
34
|
+
expect(hidden.style.clip).toBe("rect(0px)");
|
|
35
|
+
expect(hidden.style.clipPath).toBe("inset(50%)");
|
|
27
36
|
});
|
|
28
37
|
|
|
29
38
|
it("should merge a consumer style over the clip styles", () => {
|
|
@@ -56,4 +65,9 @@ describe("VisuallyHidden component", () => {
|
|
|
56
65
|
expect(hidden.tagName).toBe("LABEL");
|
|
57
66
|
expect(hidden).toHaveStyle({ position: "absolute", overflow: "hidden" });
|
|
58
67
|
});
|
|
68
|
+
|
|
69
|
+
it('sets displayName to "VisuallyHidden" for React DevTools and stack traces', () => {
|
|
70
|
+
// Assert — an empty displayName would render it as an anonymous component.
|
|
71
|
+
expect(VisuallyHidden.displayName).toBe("VisuallyHidden");
|
|
72
|
+
});
|
|
59
73
|
});
|
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
import { ComponentProps } from "react";
|
|
1
|
+
import { ComponentProps, ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Props for {@link VisuallyHidden} — all native `<span>` attributes plus
|
|
5
5
|
* the `asChild` escape hatch.
|
|
6
6
|
*/
|
|
7
7
|
export type VisuallyHiddenProps = ComponentProps<"span"> & {
|
|
8
|
+
/**
|
|
9
|
+
* Renders the child element instead of a native `<span>`, merging the
|
|
10
|
+
* clip styles onto it via {@link Slot}. Useful when the hidden content
|
|
11
|
+
* needs specific semantics (e.g. a heading or landmark element). The
|
|
12
|
+
* child must be a single React element. See the `asChild` example on
|
|
13
|
+
* {@link VisuallyHidden}.
|
|
14
|
+
* @default false
|
|
15
|
+
*/
|
|
8
16
|
asChild?: boolean;
|
|
17
|
+
/** Content to visually hide. Under `asChild`, becomes the single child element whose clip styles are merged. */
|
|
18
|
+
children?: ReactNode;
|
|
9
19
|
};
|
package/src/hooks/index.ts
CHANGED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useCallback, useRef, type KeyboardEvent } from "react";
|
|
2
|
+
|
|
3
|
+
type UseTypeaheadArgs = {
|
|
4
|
+
/** Reads the navigable (enabled) option values in current DOM order. Called
|
|
5
|
+
* per keystroke so an in-place reorder is picked up. */
|
|
6
|
+
getNavigable: () => string[];
|
|
7
|
+
/** Reads an option's visible label, used for prefix matching. */
|
|
8
|
+
getLabel: (optionValue: string) => string;
|
|
9
|
+
/** The option the cursor is on, or `undefined` before it is seeded. */
|
|
10
|
+
currentKey: string | undefined;
|
|
11
|
+
/** Moves the cursor to the matched option. */
|
|
12
|
+
onMatch: (optionValue: string) => void;
|
|
13
|
+
/** Milliseconds of inactivity after which the accumulated query resets. */
|
|
14
|
+
resetMs: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Printable-character typeahead for a list cursor, per APG's
|
|
19
|
+
* recommendation for lists longer than about seven options. Shared by
|
|
20
|
+
* `Listbox` and `MillerColumns` (which scopes it to the focused column).
|
|
21
|
+
*
|
|
22
|
+
* Keystrokes accumulate into a query that resets after `resetMs` of
|
|
23
|
+
* inactivity. A query of all-identical
|
|
24
|
+
* characters is treated as a repeat of that single character, which cycles
|
|
25
|
+
* through the options starting with it rather than searching for a literal
|
|
26
|
+
* run — so pressing "a" three times walks the "a" options instead of looking
|
|
27
|
+
* for "aaa". Matching is a case-insensitive prefix test against the option's
|
|
28
|
+
* text, and the search wraps.
|
|
29
|
+
*/
|
|
30
|
+
export function useTypeahead({
|
|
31
|
+
getNavigable,
|
|
32
|
+
getLabel,
|
|
33
|
+
currentKey,
|
|
34
|
+
onMatch,
|
|
35
|
+
resetMs,
|
|
36
|
+
}: UseTypeaheadArgs): {
|
|
37
|
+
handleTypeahead: (event: KeyboardEvent<HTMLElement>) => void;
|
|
38
|
+
} {
|
|
39
|
+
const stateRef = useRef<{ query: string; timer: number | null }>({
|
|
40
|
+
query: "",
|
|
41
|
+
timer: null,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const handleTypeahead = useCallback(
|
|
45
|
+
(event: KeyboardEvent<HTMLElement>) => {
|
|
46
|
+
if (event.key.length !== 1) return;
|
|
47
|
+
|
|
48
|
+
const navigable = getNavigable();
|
|
49
|
+
// Stryker disable next-line ConditionalExpression: equivalent — the match loop is bounded by navigable.length, so an empty list exits immediately anyway.
|
|
50
|
+
if (navigable.length === 0) return;
|
|
51
|
+
|
|
52
|
+
const state = stateRef.current;
|
|
53
|
+
// Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent — keystroke handling is synchronous, so clearing (or not clearing) the pending reset cannot change this search's outcome.
|
|
54
|
+
if (state.timer !== null) window.clearTimeout(state.timer);
|
|
55
|
+
state.query = (state.query + event.key).toLowerCase();
|
|
56
|
+
state.timer = window.setTimeout(() => {
|
|
57
|
+
state.query = "";
|
|
58
|
+
state.timer = null;
|
|
59
|
+
}, resetMs);
|
|
60
|
+
|
|
61
|
+
const isRepeat =
|
|
62
|
+
// Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent — for a single character `every()` is trivially true, so both forms yield the same isRepeat.
|
|
63
|
+
state.query.length > 1 &&
|
|
64
|
+
state.query.split("").every((char) => char === state.query[0]);
|
|
65
|
+
const searchQuery = isRepeat ? state.query[0] : state.query;
|
|
66
|
+
|
|
67
|
+
// A single character (or a repeat of one) advances past the current
|
|
68
|
+
// option so pressing the same letter walks the matches; a genuine
|
|
69
|
+
// multi-character query narrows in place instead. With no cursor at all
|
|
70
|
+
// there is nothing to advance past, so the search starts at the top.
|
|
71
|
+
const currentIndex =
|
|
72
|
+
// Stryker disable next-line ConditionalExpression: equivalent — indexOf(undefined) is itself -1.
|
|
73
|
+
currentKey === undefined ? -1 : navigable.indexOf(currentKey);
|
|
74
|
+
// Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent — with no cursor the two spellings produce the same scan: startIndex -1 with offset 1 and startIndex 0 with offset 0 both begin at index 0 and step identically.
|
|
75
|
+
const hasCursor = currentIndex >= 0;
|
|
76
|
+
const startIndex = hasCursor ? currentIndex : 0;
|
|
77
|
+
const offset =
|
|
78
|
+
hasCursor && (searchQuery.length === 1 || isRepeat) ? 1 : 0;
|
|
79
|
+
|
|
80
|
+
// Stryker disable next-line EqualityOperator: equivalent — the index wraps modulo navigable.length, so an extra iteration only re-checks an already-visited option.
|
|
81
|
+
for (let i = 0; i < navigable.length; i++) {
|
|
82
|
+
const index = (startIndex + offset + i) % navigable.length;
|
|
83
|
+
const label = getLabel(navigable[index]).trim().toLowerCase();
|
|
84
|
+
if (label.startsWith(searchQuery)) {
|
|
85
|
+
event.preventDefault();
|
|
86
|
+
onMatch(navigable[index]);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
[getNavigable, getLabel, currentKey, onMatch, resetMs],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return { handleTypeahead };
|
|
95
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,27 +8,34 @@ export * from "./Carousel/index.ts";
|
|
|
8
8
|
export * from "./Checkbox/index.ts";
|
|
9
9
|
export * from "./CheckboxCard/index.ts";
|
|
10
10
|
export * from "./Collapsible/index.ts";
|
|
11
|
+
export * from "./Combobox/index.ts";
|
|
11
12
|
export * from "./ContextMenu/index.ts";
|
|
12
13
|
export * from "./DirectionProvider/index.ts";
|
|
13
14
|
export * from "./Divider/index.ts";
|
|
15
|
+
export * from "./Drawer/index.ts";
|
|
14
16
|
export * from "./Dropdown/index.ts";
|
|
15
17
|
export * from "./EmptyState/index.ts";
|
|
16
18
|
export * from "./Field/index.ts";
|
|
17
19
|
export * from "./Fieldset/index.ts";
|
|
18
20
|
export * from "./Input/index.ts";
|
|
19
21
|
export * from "./InputGroup/index.ts";
|
|
22
|
+
export * from "./Listbox/index.ts";
|
|
20
23
|
export * from "./MillerColumns/index.ts";
|
|
21
24
|
export * from "./Modal/index.ts";
|
|
25
|
+
export * from "./NavigationMenu/index.ts";
|
|
26
|
+
export * from "./Popover/index.ts";
|
|
22
27
|
export * from "./Portal/index.ts";
|
|
23
28
|
export * from "./Progress/index.ts";
|
|
24
29
|
export * from "./Radio/index.ts";
|
|
25
30
|
export * from "./RadioCard/index.ts";
|
|
26
31
|
export * from "./RadioGroup/index.ts";
|
|
32
|
+
export * from "./SegmentedControl/index.ts";
|
|
27
33
|
export * from "./Select/index.ts";
|
|
28
34
|
export * from "./SkipNav/index.ts";
|
|
29
35
|
export * from "./Slider/index.ts";
|
|
30
36
|
export { Slot } from "./Slot/index.ts";
|
|
31
37
|
export type { SlotProps } from "./Slot/index.ts";
|
|
38
|
+
export * from "./SplitButton/index.ts";
|
|
32
39
|
export * from "./Status/index.ts";
|
|
33
40
|
export * from "./Switch/index.ts";
|
|
34
41
|
export * from "./Table/index.ts";
|
|
@@ -38,5 +45,8 @@ export * from "./Toggle/index.ts";
|
|
|
38
45
|
export * from "./ToggleGroup/index.ts";
|
|
39
46
|
export * from "./Tooltip/index.ts";
|
|
40
47
|
export * from "./Tree/index.ts";
|
|
48
|
+
export * from "./useLocalStorage/index.ts";
|
|
49
|
+
export * from "./useMediaQuery/index.ts";
|
|
50
|
+
export * from "./usePagination/index.ts";
|
|
41
51
|
export * from "./VisuallyHidden/index.ts";
|
|
42
52
|
export type { HeadingLevel } from "./types.ts";
|
|
@@ -41,6 +41,13 @@ export class MockIntersectionObserver {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
observe(target: Element) {
|
|
44
|
+
// Mirror the browser: `observe` throws a TypeError when handed anything
|
|
45
|
+
// that is not an Element (e.g. `undefined` from a stale slide lookup).
|
|
46
|
+
if (!(target instanceof Element)) {
|
|
47
|
+
throw new TypeError(
|
|
48
|
+
"Failed to execute 'observe' on 'IntersectionObserver': parameter 1 is not of type 'Element'.",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
44
51
|
this.observed.add(target);
|
|
45
52
|
}
|
|
46
53
|
unobserve(target: Element) {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal jsdom polyfill for ResizeObserver.
|
|
3
|
+
*
|
|
4
|
+
* jsdom does not implement ResizeObserver. The component relies on it
|
|
5
|
+
* to keep a multi-slide page's `scroll-margin` extension (the
|
|
6
|
+
* center/end alignment fix for slidesPerPage > 1) correct across
|
|
7
|
+
* viewport resizes, since equal-share slide widths derive from the
|
|
8
|
+
* viewport's own size. Tests can grab the most-recently-constructed
|
|
9
|
+
* instance via `MockResizeObserver.latest` and call `.fire()` to
|
|
10
|
+
* simulate a resize on observed elements. When a component attaches
|
|
11
|
+
* more than one observer to the same element (e.g. the scroll-margin
|
|
12
|
+
* fix and the scroll-progress signal both observe the Viewport),
|
|
13
|
+
* `.latest` only reaches whichever was constructed last — use
|
|
14
|
+
* `MockResizeObserver.fireAll()` instead to simulate "a resize
|
|
15
|
+
* happened" without depending on construction order.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export class MockResizeObserver {
|
|
19
|
+
static instances: MockResizeObserver[] = [];
|
|
20
|
+
static get latest(): MockResizeObserver | undefined {
|
|
21
|
+
return MockResizeObserver.instances[MockResizeObserver.instances.length - 1];
|
|
22
|
+
}
|
|
23
|
+
static reset() {
|
|
24
|
+
MockResizeObserver.instances = [];
|
|
25
|
+
}
|
|
26
|
+
/** Test-only: fire every constructed observer, regardless of which
|
|
27
|
+
* element(s) it's watching or when it was created. */
|
|
28
|
+
static fireAll() {
|
|
29
|
+
MockResizeObserver.instances.forEach((observer) => observer.fire());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
callback: ResizeObserverCallback;
|
|
33
|
+
observed = new Set<Element>();
|
|
34
|
+
|
|
35
|
+
constructor(cb: ResizeObserverCallback) {
|
|
36
|
+
this.callback = cb;
|
|
37
|
+
MockResizeObserver.instances.push(this);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
observe(target: Element) {
|
|
41
|
+
// Mirror the browser: `observe` throws a TypeError when handed anything
|
|
42
|
+
// that is not an Element.
|
|
43
|
+
if (!(target instanceof Element)) {
|
|
44
|
+
throw new TypeError(
|
|
45
|
+
"Failed to execute 'observe' on 'ResizeObserver': parameter 1 is not of type 'Element'.",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
this.observed.add(target);
|
|
49
|
+
}
|
|
50
|
+
unobserve(target: Element) {
|
|
51
|
+
this.observed.delete(target);
|
|
52
|
+
}
|
|
53
|
+
disconnect() {
|
|
54
|
+
this.observed.clear();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Test-only: invoke the callback for every currently-observed target. */
|
|
58
|
+
fire() {
|
|
59
|
+
const entries = [...this.observed].map(
|
|
60
|
+
(target) =>
|
|
61
|
+
({
|
|
62
|
+
target,
|
|
63
|
+
contentRect: target.getBoundingClientRect(),
|
|
64
|
+
borderBoxSize: [],
|
|
65
|
+
contentBoxSize: [],
|
|
66
|
+
devicePixelContentBoxSize: [],
|
|
67
|
+
}) as ResizeObserverEntry,
|
|
68
|
+
);
|
|
69
|
+
if (entries.length > 0) {
|
|
70
|
+
this.callback(entries, this as unknown as ResizeObserver);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function installResizeObserverPolyfill() {
|
|
76
|
+
if (typeof window === "undefined") return;
|
|
77
|
+
(
|
|
78
|
+
window as unknown as { ResizeObserver: typeof MockResizeObserver }
|
|
79
|
+
).ResizeObserver = MockResizeObserver;
|
|
80
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
|
|
4
|
+
import { useLocalStorage } from "../useLocalStorage.ts";
|
|
5
|
+
|
|
6
|
+
const KEY = "primitiv-test-key";
|
|
7
|
+
|
|
8
|
+
function Probe({ fallback }: { fallback: string }) {
|
|
9
|
+
const [value] = useLocalStorage(KEY, fallback);
|
|
10
|
+
return <span data-testid="value">{value}</span>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("useLocalStorage reading", () => {
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
window.localStorage.clear();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should return the fallback when nothing is stored under the key", () => {
|
|
19
|
+
render(<Probe fallback="light" />);
|
|
20
|
+
|
|
21
|
+
expect(screen.getByTestId("value")).toHaveTextContent("light");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should return the stored value when one is present", () => {
|
|
25
|
+
window.localStorage.setItem(KEY, JSON.stringify("dark"));
|
|
26
|
+
|
|
27
|
+
render(<Probe fallback="light" />);
|
|
28
|
+
|
|
29
|
+
expect(screen.getByTestId("value")).toHaveTextContent("dark");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { act, render, screen } from "@testing-library/react";
|
|
3
|
+
import { userEvent } from "@testing-library/user-event";
|
|
4
|
+
|
|
5
|
+
import { useLocalStorage } from "../useLocalStorage.ts";
|
|
6
|
+
|
|
7
|
+
const KEY = "primitiv-test-key";
|
|
8
|
+
|
|
9
|
+
function Probe() {
|
|
10
|
+
const [value, setValue] = useLocalStorage(KEY, "light");
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<span data-testid="value">{value}</span>
|
|
14
|
+
<button type="button" onClick={() => setValue("dark")}>
|
|
15
|
+
set
|
|
16
|
+
</button>
|
|
17
|
+
</>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* Storage methods are stubbed on `Storage.prototype`, NOT on
|
|
23
|
+
* `window.localStorage`. jsdom's localStorage is a Proxy, so an instance spy
|
|
24
|
+
* silently never intercepts — `vi.spyOn(window.localStorage, "setItem")`
|
|
25
|
+
* records zero calls and the real write goes through. That is exactly the shape
|
|
26
|
+
* of bug that makes a test pass for the wrong reason, so each test below stores
|
|
27
|
+
* a real value first, ensuring the fallback can ONLY come from the throw path.
|
|
28
|
+
*/
|
|
29
|
+
describe("useLocalStorage resilience", () => {
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
window.localStorage.clear();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should fall back when the stored value is not valid JSON", () => {
|
|
36
|
+
// Storage is shared, long-lived and hand-editable, so a bad value has to
|
|
37
|
+
// degrade rather than throw.
|
|
38
|
+
window.localStorage.setItem(KEY, "{not json");
|
|
39
|
+
|
|
40
|
+
render(<Probe />);
|
|
41
|
+
|
|
42
|
+
expect(screen.getByTestId("value")).toHaveTextContent("light");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should fall back when reading storage throws", () => {
|
|
46
|
+
// A readable "dark" is present, so "light" can only mean the read threw.
|
|
47
|
+
window.localStorage.setItem(KEY, JSON.stringify("dark"));
|
|
48
|
+
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
|
49
|
+
throw new Error("SecurityError");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
render(<Probe />);
|
|
53
|
+
|
|
54
|
+
expect(screen.getByTestId("value")).toHaveTextContent("light");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should leave the value untouched when writing to storage throws", async () => {
|
|
58
|
+
const user = userEvent.setup();
|
|
59
|
+
render(<Probe />);
|
|
60
|
+
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
|
61
|
+
throw new Error("QuotaExceededError");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await user.click(screen.getByRole("button", { name: "set" }));
|
|
65
|
+
|
|
66
|
+
// The write failed, so storage still holds nothing and the re-read returns
|
|
67
|
+
// the fallback — the hook does not optimistically keep an unsaved value.
|
|
68
|
+
expect(screen.getByTestId("value")).toHaveTextContent("light");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should pick up a change made in another tab", () => {
|
|
72
|
+
render(<Probe />);
|
|
73
|
+
|
|
74
|
+
// What the browser dispatches when a different document writes the key.
|
|
75
|
+
act(() => {
|
|
76
|
+
window.localStorage.setItem(KEY, JSON.stringify("dark"));
|
|
77
|
+
window.dispatchEvent(new StorageEvent("storage", { key: KEY }));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(screen.getByTestId("value")).toHaveTextContent("dark");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should stop listening once the last consumer unmounts", () => {
|
|
84
|
+
const add = vi.spyOn(window, "addEventListener");
|
|
85
|
+
const remove = vi.spyOn(window, "removeEventListener");
|
|
86
|
+
|
|
87
|
+
const { unmount } = render(<Probe />);
|
|
88
|
+
unmount();
|
|
89
|
+
|
|
90
|
+
const added = add.mock.calls.filter(([type]) => type === "storage").length;
|
|
91
|
+
const removed = remove.mock.calls.filter(
|
|
92
|
+
([type]) => type === "storage",
|
|
93
|
+
).length;
|
|
94
|
+
expect(added).toBeGreaterThan(0);
|
|
95
|
+
expect(removed).toBe(added);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
+
|
|
5
|
+
import { useLocalStorage } from "../useLocalStorage.ts";
|
|
6
|
+
|
|
7
|
+
function Probe() {
|
|
8
|
+
const [value] = useLocalStorage("primitiv-theme", "light");
|
|
9
|
+
return <>{value}</>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("useLocalStorage SSR", () => {
|
|
13
|
+
it("should render the fallback when there is no window to read storage from", () => {
|
|
14
|
+
expect(typeof window).toBe("undefined");
|
|
15
|
+
|
|
16
|
+
const markup = renderToStaticMarkup(<Probe />);
|
|
17
|
+
|
|
18
|
+
expect(markup).toBe("light");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { userEvent } from "@testing-library/user-event";
|
|
4
|
+
|
|
5
|
+
import { useLocalStorage } from "../useLocalStorage.ts";
|
|
6
|
+
|
|
7
|
+
const KEY = "primitiv-test-key";
|
|
8
|
+
|
|
9
|
+
function Probe({ next }: { next: string }) {
|
|
10
|
+
const [value, setValue] = useLocalStorage(KEY, "light");
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<span data-testid="value">{value}</span>
|
|
14
|
+
<button type="button" onClick={() => setValue(next)}>
|
|
15
|
+
set
|
|
16
|
+
</button>
|
|
17
|
+
</>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Two independent consumers of the SAME key, to prove they stay in step. */
|
|
22
|
+
function TwoProbes() {
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
<Probe next="dark" />
|
|
26
|
+
<SecondReader />
|
|
27
|
+
</>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function SecondReader() {
|
|
32
|
+
const [value] = useLocalStorage(KEY, "light");
|
|
33
|
+
return <span data-testid="second">{value}</span>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("useLocalStorage writing", () => {
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
window.localStorage.clear();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should update the returned value when the setter is called", async () => {
|
|
42
|
+
const user = userEvent.setup();
|
|
43
|
+
render(<Probe next="dark" />);
|
|
44
|
+
|
|
45
|
+
await user.click(screen.getByRole("button", { name: "set" }));
|
|
46
|
+
|
|
47
|
+
expect(screen.getByTestId("value")).toHaveTextContent("dark");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should persist the new value to localStorage as JSON", async () => {
|
|
51
|
+
const user = userEvent.setup();
|
|
52
|
+
render(<Probe next="dark" />);
|
|
53
|
+
|
|
54
|
+
await user.click(screen.getByRole("button", { name: "set" }));
|
|
55
|
+
|
|
56
|
+
expect(window.localStorage.getItem(KEY)).toBe(JSON.stringify("dark"));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/*
|
|
60
|
+
* The `storage` event does NOT fire in the document that performed the write,
|
|
61
|
+
* so without an in-document notification a second consumer of the same key
|
|
62
|
+
* would keep rendering the stale value until something else re-rendered it.
|
|
63
|
+
*/
|
|
64
|
+
it("should update every consumer of the same key in the same document", async () => {
|
|
65
|
+
const user = userEvent.setup();
|
|
66
|
+
render(<TwoProbes />);
|
|
67
|
+
|
|
68
|
+
await user.click(screen.getByRole("button", { name: "set" }));
|
|
69
|
+
|
|
70
|
+
expect(screen.getByTestId("second")).toHaveTextContent("dark");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useLocalStorage } from "./useLocalStorage.ts";
|