@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,140 @@
|
|
|
1
|
+
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-document subscribers, keyed by storage key.
|
|
5
|
+
*
|
|
6
|
+
* The browser's own `storage` event only fires in OTHER documents, never the one
|
|
7
|
+
* that performed the write — so a second consumer of the same key in this
|
|
8
|
+
* document would keep rendering a stale value. These listeners cover that case;
|
|
9
|
+
* the `storage` event covers other tabs.
|
|
10
|
+
*/
|
|
11
|
+
const listeners = new Map<string, Set<() => void>>();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The listener set for a key, created on first use.
|
|
15
|
+
*
|
|
16
|
+
* Empty sets are deliberately never pruned. Pruning them would mean `notify`
|
|
17
|
+
* had to guard against a missing set — a branch nothing can reach, since the
|
|
18
|
+
* only way to obtain the setter is from a mounted hook that has already
|
|
19
|
+
* subscribed. One empty `Set` per key, for a handful of static keys, is a
|
|
20
|
+
* better trade than an untestable defensive branch.
|
|
21
|
+
*/
|
|
22
|
+
function listenersFor(key: string): Set<() => void> {
|
|
23
|
+
let forKey = listeners.get(key);
|
|
24
|
+
if (!forKey) {
|
|
25
|
+
forKey = new Set();
|
|
26
|
+
listeners.set(key, forKey);
|
|
27
|
+
}
|
|
28
|
+
return forKey;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function notify(key: string): void {
|
|
32
|
+
for (const listener of listenersFor(key)) listener();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function read(key: string): string | null {
|
|
36
|
+
try {
|
|
37
|
+
return window.localStorage.getItem(key);
|
|
38
|
+
} catch {
|
|
39
|
+
// Storage can throw rather than merely be empty — Safari private mode and
|
|
40
|
+
// "block all cookies" both do. Treat it as "nothing stored".
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The server has no storage, so nothing is stored. */
|
|
46
|
+
function getServerSnapshot(): null {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Reads and writes a JSON-serialisable value in `localStorage`, re-rendering
|
|
52
|
+
* the consumer whenever it changes — including when another component, or
|
|
53
|
+
* another browser tab, changes the same key.
|
|
54
|
+
*
|
|
55
|
+
* Built on {@link https://react.dev/reference/react/useSyncExternalStore |
|
|
56
|
+
* `useSyncExternalStore`}, mirroring {@link useMediaQuery}: `localStorage` is
|
|
57
|
+
* external mutable state, so subscribing to it is more correct than copying it
|
|
58
|
+
* into component state and hoping the copies stay in step.
|
|
59
|
+
*
|
|
60
|
+
* **The snapshot is the raw string, not the parsed value.** `getSnapshot` must
|
|
61
|
+
* return a referentially stable result or React re-renders forever, and
|
|
62
|
+
* `JSON.parse` yields a fresh object every call. Returning the raw string — a
|
|
63
|
+
* primitive — is stable by definition, and the parse happens in a `useMemo`
|
|
64
|
+
* keyed on it.
|
|
65
|
+
*
|
|
66
|
+
* **Server rendering.** There is no `localStorage` on the server, so the server
|
|
67
|
+
* snapshot is `null` and the hook returns `fallback`. The client then reads the
|
|
68
|
+
* real value after hydration, which means a persisted choice can flash to its
|
|
69
|
+
* stored state on first paint — unavoidable without a cookie or a blocking
|
|
70
|
+
* inline script, and the same trade-off `useMediaQuery` makes.
|
|
71
|
+
*
|
|
72
|
+
* **Malformed data falls back.** Anything that fails to parse is treated as
|
|
73
|
+
* absent rather than thrown: storage is shared, long-lived and editable by
|
|
74
|
+
* hand, so a bad value must not break the page.
|
|
75
|
+
*
|
|
76
|
+
* @example Persisting a theme choice
|
|
77
|
+
* ```tsx
|
|
78
|
+
* const [theme, setTheme] = useLocalStorage<"light" | "dark">(
|
|
79
|
+
* "primitiv-theme",
|
|
80
|
+
* "light",
|
|
81
|
+
* );
|
|
82
|
+
* return (
|
|
83
|
+
* <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
|
|
84
|
+
* Switch theme
|
|
85
|
+
* </button>
|
|
86
|
+
* );
|
|
87
|
+
* ```
|
|
88
|
+
*
|
|
89
|
+
* @param key - The `localStorage` key to read and write.
|
|
90
|
+
* @param fallback - Returned when the key is absent, unreadable, or holds
|
|
91
|
+
* malformed JSON.
|
|
92
|
+
* @returns A `[value, setValue]` tuple. `setValue` writes JSON to storage and
|
|
93
|
+
* notifies every consumer of the same key.
|
|
94
|
+
*/
|
|
95
|
+
export function useLocalStorage<T>(
|
|
96
|
+
key: string,
|
|
97
|
+
fallback: T,
|
|
98
|
+
): [T, (next: T) => void] {
|
|
99
|
+
const subscribe = useCallback(
|
|
100
|
+
(onStoreChange: () => void) => {
|
|
101
|
+
const forKey = listenersFor(key);
|
|
102
|
+
forKey.add(onStoreChange);
|
|
103
|
+
window.addEventListener("storage", onStoreChange);
|
|
104
|
+
|
|
105
|
+
return () => {
|
|
106
|
+
forKey.delete(onStoreChange);
|
|
107
|
+
window.removeEventListener("storage", onStoreChange);
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
[key],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const getSnapshot = useCallback(() => read(key), [key]);
|
|
114
|
+
|
|
115
|
+
const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
116
|
+
|
|
117
|
+
const value = useMemo<T>(() => {
|
|
118
|
+
if (raw === null) return fallback;
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(raw) as T;
|
|
121
|
+
} catch {
|
|
122
|
+
return fallback;
|
|
123
|
+
}
|
|
124
|
+
}, [raw, fallback]);
|
|
125
|
+
|
|
126
|
+
const setValue = useCallback(
|
|
127
|
+
(next: T) => {
|
|
128
|
+
try {
|
|
129
|
+
window.localStorage.setItem(key, JSON.stringify(next));
|
|
130
|
+
} catch {
|
|
131
|
+
// Writing can fail on a full or blocked store. Still notify, so the
|
|
132
|
+
// in-memory read path stays consistent with whatever storage now holds.
|
|
133
|
+
}
|
|
134
|
+
notify(key);
|
|
135
|
+
},
|
|
136
|
+
[key],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return [value, setValue];
|
|
140
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# useMediaQuery
|
|
2
|
+
|
|
3
|
+
Tracks whether a CSS media query currently matches, re-rendering the
|
|
4
|
+
consumer whenever it changes.
|
|
5
|
+
|
|
6
|
+
```tsx
|
|
7
|
+
import { useMediaQuery } from "@primitiv-ui/react";
|
|
8
|
+
|
|
9
|
+
function NavigationMenu() {
|
|
10
|
+
const isDesktop = useMediaQuery("(min-width: 64rem)");
|
|
11
|
+
|
|
12
|
+
return isDesktop ? <DesktopNav /> : <MobileMenu />;
|
|
13
|
+
}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Signature
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
function useMediaQuery(query: string): boolean;
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
| Param | Type | Notes |
|
|
23
|
+
| ------- | -------- | -------------------------------------------------- |
|
|
24
|
+
| `query` | `string` | A media query string, e.g. `"(min-width: 40rem)"`. |
|
|
25
|
+
|
|
26
|
+
Returns whether `query` currently matches.
|
|
27
|
+
|
|
28
|
+
## Generic, not tied to Primitiv's breakpoint scale
|
|
29
|
+
|
|
30
|
+
`useMediaQuery` doesn't know about Primitiv's own `xs`/`sm`/`md`/`lg`/`xl`/`2xl`
|
|
31
|
+
tokens or any other design values — pass whatever query string you need,
|
|
32
|
+
including features other than width:
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
const prefersDark = useMediaQuery("(prefers-color-scheme: dark)");
|
|
36
|
+
const isCompact = useMediaQuery("(max-width: 40rem)");
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
If you're using the styled registry components and want Primitiv's own
|
|
40
|
+
breakpoint values, `primitiv tokens` generates a `breakpoints` constants file
|
|
41
|
+
into your project alongside the CSS token layer — build the query string from
|
|
42
|
+
that rather than hardcoding the numbers:
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
import { useMediaQuery } from "@primitiv-ui/react";
|
|
46
|
+
import { breakpoints } from "./styles/primitiv/breakpoints";
|
|
47
|
+
|
|
48
|
+
const isCompact = useMediaQuery(`(max-width: ${breakpoints.sm})`);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Server rendering
|
|
52
|
+
|
|
53
|
+
`useMediaQuery` renders `false` during server rendering and on the client's
|
|
54
|
+
very first paint before hydration confirms the real value — there is no
|
|
55
|
+
`window`/`matchMedia` available on the server, so `false` is the safest
|
|
56
|
+
default rather than guessing. Design around a possible flash/reflow on
|
|
57
|
+
hydration if you're conditionally rendering off the result, the same as any
|
|
58
|
+
other client-only media query hook.
|
|
59
|
+
|
|
60
|
+
## Live updates
|
|
61
|
+
|
|
62
|
+
Unlike a one-time `window.matchMedia(query).matches` read, the hook
|
|
63
|
+
subscribes to the query's real `MediaQueryList` and re-renders on `change` —
|
|
64
|
+
resizing the window, rotating a device, or the query itself changing between
|
|
65
|
+
renders (the hook unsubscribes from the old query and resubscribes to the new
|
|
66
|
+
one automatically) all update the returned value.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { useMediaQuery } from "../useMediaQuery.ts";
|
|
4
|
+
|
|
5
|
+
function mockMatchMedia(matches: boolean) {
|
|
6
|
+
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
|
7
|
+
matches,
|
|
8
|
+
media: query,
|
|
9
|
+
onchange: null,
|
|
10
|
+
addEventListener: vi.fn(),
|
|
11
|
+
removeEventListener: vi.fn(),
|
|
12
|
+
dispatchEvent: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function MediaQueryProbe({ query }: { query: string }) {
|
|
17
|
+
const matches = useMediaQuery(query);
|
|
18
|
+
return <span data-testid="result">{matches ? "true" : "false"}</span>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("useMediaQuery matching", () => {
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
// @ts-expect-error: restore by deletion so other suites get jsdom default.
|
|
24
|
+
delete window.matchMedia;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should return true when the query currently matches", () => {
|
|
28
|
+
mockMatchMedia(true);
|
|
29
|
+
|
|
30
|
+
render(<MediaQueryProbe query="(min-width: 40rem)" />);
|
|
31
|
+
|
|
32
|
+
expect(screen.getByTestId("result")).toHaveTextContent("true");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should return false when the query does not currently match", () => {
|
|
36
|
+
mockMatchMedia(false);
|
|
37
|
+
|
|
38
|
+
render(<MediaQueryProbe query="(min-width: 40rem)" />);
|
|
39
|
+
|
|
40
|
+
expect(screen.getByTestId("result")).toHaveTextContent("false");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
+
import { useMediaQuery } from "../useMediaQuery.ts";
|
|
5
|
+
|
|
6
|
+
function Probe() {
|
|
7
|
+
const matches = useMediaQuery("(min-width: 40rem)");
|
|
8
|
+
return matches ? <>matches</> : <>no-match</>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("useMediaQuery SSR", () => {
|
|
12
|
+
it("should render a static false snapshot when there is no window", () => {
|
|
13
|
+
expect(typeof window).toBe("undefined");
|
|
14
|
+
|
|
15
|
+
const markup = renderToStaticMarkup(<Probe />);
|
|
16
|
+
|
|
17
|
+
expect(markup).toBe("no-match");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { act, render, screen } from "@testing-library/react";
|
|
3
|
+
import { useMediaQuery } from "../useMediaQuery.ts";
|
|
4
|
+
|
|
5
|
+
type ChangeListener = () => void;
|
|
6
|
+
|
|
7
|
+
function createMatchMediaMock(initialMatches: Record<string, boolean>) {
|
|
8
|
+
const listeners = new Map<string, Set<ChangeListener>>();
|
|
9
|
+
|
|
10
|
+
const matchMedia = vi.fn((query: string) => ({
|
|
11
|
+
get matches() {
|
|
12
|
+
return initialMatches[query] ?? false;
|
|
13
|
+
},
|
|
14
|
+
media: query,
|
|
15
|
+
onchange: null,
|
|
16
|
+
addEventListener: vi.fn((_type: "change", listener: ChangeListener) => {
|
|
17
|
+
if (!listeners.has(query)) listeners.set(query, new Set());
|
|
18
|
+
listeners.get(query)?.add(listener);
|
|
19
|
+
}),
|
|
20
|
+
removeEventListener: vi.fn((_type: "change", listener: ChangeListener) => {
|
|
21
|
+
listeners.get(query)?.delete(listener);
|
|
22
|
+
}),
|
|
23
|
+
addListener: vi.fn(),
|
|
24
|
+
removeListener: vi.fn(),
|
|
25
|
+
dispatchEvent: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
matchMedia,
|
|
30
|
+
change(query: string, matches: boolean) {
|
|
31
|
+
initialMatches[query] = matches;
|
|
32
|
+
listeners.get(query)?.forEach((listener) => listener());
|
|
33
|
+
},
|
|
34
|
+
listenerCount(query: string) {
|
|
35
|
+
return listeners.get(query)?.size ?? 0;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function MediaQueryProbe({ query }: { query: string }) {
|
|
41
|
+
const matches = useMediaQuery(query);
|
|
42
|
+
return <span data-testid="result">{matches ? "true" : "false"}</span>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("useMediaQuery live subscription", () => {
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
// @ts-expect-error: restore by deletion so other suites get jsdom default.
|
|
48
|
+
delete window.matchMedia;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should update when the media query's match state changes", () => {
|
|
52
|
+
const mock = createMatchMediaMock({ "(min-width: 40rem)": false });
|
|
53
|
+
window.matchMedia = mock.matchMedia;
|
|
54
|
+
render(<MediaQueryProbe query="(min-width: 40rem)" />);
|
|
55
|
+
|
|
56
|
+
act(() => {
|
|
57
|
+
mock.change("(min-width: 40rem)", true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(screen.getByTestId("result")).toHaveTextContent("true");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should remove its change listener on unmount", () => {
|
|
64
|
+
const mock = createMatchMediaMock({ "(min-width: 40rem)": false });
|
|
65
|
+
window.matchMedia = mock.matchMedia;
|
|
66
|
+
const { unmount } = render(<MediaQueryProbe query="(min-width: 40rem)" />);
|
|
67
|
+
expect(mock.listenerCount("(min-width: 40rem)")).toBe(1);
|
|
68
|
+
|
|
69
|
+
unmount();
|
|
70
|
+
|
|
71
|
+
expect(mock.listenerCount("(min-width: 40rem)")).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should resubscribe when the query argument changes", () => {
|
|
75
|
+
const mock = createMatchMediaMock({
|
|
76
|
+
"(min-width: 40rem)": false,
|
|
77
|
+
"(min-width: 64rem)": true,
|
|
78
|
+
});
|
|
79
|
+
window.matchMedia = mock.matchMedia;
|
|
80
|
+
const { rerender } = render(<MediaQueryProbe query="(min-width: 40rem)" />);
|
|
81
|
+
expect(mock.listenerCount("(min-width: 40rem)")).toBe(1);
|
|
82
|
+
|
|
83
|
+
rerender(<MediaQueryProbe query="(min-width: 64rem)" />);
|
|
84
|
+
|
|
85
|
+
expect(screen.getByTestId("result")).toHaveTextContent("true");
|
|
86
|
+
expect(mock.listenerCount("(min-width: 40rem)")).toBe(0);
|
|
87
|
+
expect(mock.listenerCount("(min-width: 64rem)")).toBe(1);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./useMediaQuery.ts";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
function getServerSnapshot(): boolean {
|
|
4
|
+
return false;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tracks whether a CSS media query currently matches, re-rendering the
|
|
9
|
+
* consumer whenever it changes.
|
|
10
|
+
*
|
|
11
|
+
* Built on {@link https://react.dev/reference/react/useSyncExternalStore |
|
|
12
|
+
* `useSyncExternalStore`}, so it subscribes to the query's real
|
|
13
|
+
* `MediaQueryList` (`change` events, cleaned up on unmount and resubscribed
|
|
14
|
+
* whenever `query` itself changes) rather than polling, and renders a static
|
|
15
|
+
* `false` during server rendering — there is no `window`/`matchMedia` on the
|
|
16
|
+
* server, so `false` is the safest default until the client mounts and can
|
|
17
|
+
* read the real value. This is a generic primitive: it does not know about
|
|
18
|
+
* Primitiv's own breakpoint scale or any other token values — pass whatever
|
|
19
|
+
* query string the consumer needs.
|
|
20
|
+
*
|
|
21
|
+
* @example Responding to a breakpoint
|
|
22
|
+
* ```tsx
|
|
23
|
+
* const isDesktop = useMediaQuery("(min-width: 64rem)");
|
|
24
|
+
* return isDesktop ? <DesktopNav /> : <MobileMenu />;
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* @example Any media feature, not just width
|
|
28
|
+
* ```tsx
|
|
29
|
+
* const prefersDark = useMediaQuery("(prefers-color-scheme: dark)");
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @param query - A media query string, e.g. `"(min-width: 40rem)"`.
|
|
33
|
+
* @returns Whether `query` currently matches. `false` on the server and on
|
|
34
|
+
* the client's very first paint before hydration confirms the real value.
|
|
35
|
+
*/
|
|
36
|
+
export function useMediaQuery(query: string): boolean {
|
|
37
|
+
const subscribe = useCallback(
|
|
38
|
+
(onStoreChange: () => void) => {
|
|
39
|
+
const mediaQueryList = window.matchMedia(query);
|
|
40
|
+
mediaQueryList.addEventListener("change", onStoreChange);
|
|
41
|
+
return () => mediaQueryList.removeEventListener("change", onStoreChange);
|
|
42
|
+
},
|
|
43
|
+
[query],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const getSnapshot = useCallback(() => window.matchMedia(query).matches, [query]);
|
|
47
|
+
|
|
48
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
49
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# usePagination
|
|
2
|
+
|
|
3
|
+
Headless pagination state: the page arithmetic every paged surface needs, with
|
|
4
|
+
no opinion about where the data comes from or how the row is rendered.
|
|
5
|
+
|
|
6
|
+
```tsx
|
|
7
|
+
import { usePagination } from "@primitiv-ui/react";
|
|
8
|
+
|
|
9
|
+
const { page, pageCount, items, next, previous, canNext, canPrevious } =
|
|
10
|
+
usePagination({ totalItems: 237, pageSize: 10 });
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Works with any data source
|
|
14
|
+
|
|
15
|
+
The hook never fetches, holds or slices data — it hands back **indices**, which
|
|
16
|
+
is what makes it source-agnostic.
|
|
17
|
+
|
|
18
|
+
**Client-side** — slice an array you already have:
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
const { startIndex, endIndex } = usePagination({
|
|
22
|
+
totalItems: rows.length,
|
|
23
|
+
pageSize: 10,
|
|
24
|
+
});
|
|
25
|
+
const visible = rows.slice(startIndex, endIndex);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Server-side** — feed the same indices to a query. Here the server owns the
|
|
29
|
+
total, so pass `totalItems` through as it arrives:
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
const { page, startIndex, pageSize } = usePagination({
|
|
33
|
+
totalItems: data?.total,
|
|
34
|
+
pageSize: 10,
|
|
35
|
+
});
|
|
36
|
+
const { data } = useQuery(["rows", page], () =>
|
|
37
|
+
fetch(`/api/rows?offset=${startIndex}&limit=10`),
|
|
38
|
+
);
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
If the server reports a page **count** rather than an item total, give
|
|
42
|
+
`pageCount` directly and skip `totalItems` entirely.
|
|
43
|
+
|
|
44
|
+
`startIndex`/`endIndex` are a **half-open** range — `[start, end)` — so they
|
|
45
|
+
drop into `Array.slice` and into `LIMIT`/`OFFSET` with no off-by-one at the call
|
|
46
|
+
site.
|
|
47
|
+
|
|
48
|
+
## Controlled and uncontrolled
|
|
49
|
+
|
|
50
|
+
Uncontrolled by default, holding its own page:
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
const pagination = usePagination({ pageCount: 20, defaultPage: 3 });
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Pass `page` to drive it yourself — from a URL query, say:
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
const pagination = usePagination({
|
|
60
|
+
pageCount: 20,
|
|
61
|
+
page: Number(searchParams.get("page") ?? 1),
|
|
62
|
+
onPageChange: (next) => setSearchParams({ page: String(next) }),
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`onPageChange` always reports the **clamped** page, never a raw out-of-range
|
|
67
|
+
request, and is not called when the page hasn't actually moved.
|
|
68
|
+
|
|
69
|
+
## Out-of-range pages are clamped, not rejected
|
|
70
|
+
|
|
71
|
+
A page index restored from a URL or `localStorage` can easily outlive the data
|
|
72
|
+
it pointed at. Every route into the page — `defaultPage`, a controlled `page`,
|
|
73
|
+
and `setPage` — is clamped to `1...pageCount`, so `page` is always safe to read
|
|
74
|
+
and to index with.
|
|
75
|
+
|
|
76
|
+
`pageCount` is never less than `1`: a 1-based page needs somewhere to stand, and
|
|
77
|
+
reporting `0` pages would put `page` out of range the moment it was read.
|
|
78
|
+
|
|
79
|
+
## Options
|
|
80
|
+
|
|
81
|
+
| Option | Type | Default | Notes |
|
|
82
|
+
|---|---|---|---|
|
|
83
|
+
| `totalItems` | `number` | — | Total items across all pages. Pairs with `pageSize`. |
|
|
84
|
+
| `pageSize` | `number` | — | Items per page. Pairs with `totalItems`. |
|
|
85
|
+
| `pageCount` | `number` | — | Supply directly instead of deriving from the pair. |
|
|
86
|
+
| `page` | `number` | — | Controlled current page, 1-based. |
|
|
87
|
+
| `defaultPage` | `number` | `1` | Starting page when uncontrolled. |
|
|
88
|
+
| `onPageChange` | `(page: number) => void` | — | Fired with the clamped page. |
|
|
89
|
+
| `window` | `"paged" \| "sliding"` | `"paged"` | See Windowing below. |
|
|
90
|
+
| `blockSize` | `number` | `5` | `paged` only — pages per block. |
|
|
91
|
+
| `siblingCount` | `number` | `1` | `sliding` only — pages either side of the current one. |
|
|
92
|
+
| `boundaryCount` | `number` | `1` | Pages pinned at each end, both strategies. |
|
|
93
|
+
|
|
94
|
+
Given neither `pageCount` nor a complete `totalItems` + `pageSize` pair, the
|
|
95
|
+
hook reports a single page.
|
|
96
|
+
|
|
97
|
+
## Returns
|
|
98
|
+
|
|
99
|
+
| Key | Type | Notes |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| `page` | `number` | Current page, always within `1...pageCount`. |
|
|
102
|
+
| `pageCount` | `number` | Never less than 1. |
|
|
103
|
+
| `setPage` | `(page: number) => void` | Out-of-range requests clamp. |
|
|
104
|
+
| `next` / `previous` | `() => void` | No-ops at the respective end. |
|
|
105
|
+
| `first` / `last` | `() => void` | Jump to either end. |
|
|
106
|
+
| `canPrevious` / `canNext` | `boolean` | Wire to a control's `disabled`. |
|
|
107
|
+
| `startIndex` / `endIndex` | `number` | Half-open slice bounds; `0` when no `pageSize`. |
|
|
108
|
+
| `items` | `PaginationRangeItem[]` | The range to render — see below. |
|
|
109
|
+
|
|
110
|
+
## Windowing — why the default is `paged`
|
|
111
|
+
|
|
112
|
+
As the current page moves, something has to decide which numbers stay on screen.
|
|
113
|
+
|
|
114
|
+
**`paged`** (the default) holds a fixed block and only advances when the current
|
|
115
|
+
page steps outside it:
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
page 6: 1 ... 6 7 8 9 10 ... 20
|
|
119
|
+
page 7: 1 ... 6 7 8 9 10 ... 20 ← identical; only the active cell moved
|
|
120
|
+
page 10: 1 ... 6 7 8 9 10 ... 20
|
|
121
|
+
page 11: 1 ... 11 12 13 14 15 ... 20 ← one deliberate jump
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**`sliding`** follows the current page continuously:
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
page 6: 1 ... 5 6 7 ... 20
|
|
128
|
+
page 7: 1 ... 6 7 8 ... 20 ← every cell re-labelled
|
|
129
|
+
page 8: 1 ... 7 8 9 ... 20 ← again
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Sliding is the more common default elsewhere, but it re-labels the numbers
|
|
133
|
+
directly under the reader's cursor on every single step — the cell you are
|
|
134
|
+
about to click has become a different page by the time you get there, and the
|
|
135
|
+
whole row appears to flash. Paged trades that for one visible jump per block,
|
|
136
|
+
which is both calmer and safer to click.
|
|
137
|
+
|
|
138
|
+
Set `blockSize` to control how many pages a block holds (default 5). Choose
|
|
139
|
+
`sliding` explicitly if you want the conventional behaviour:
|
|
140
|
+
|
|
141
|
+
```tsx
|
|
142
|
+
usePagination({ totalItems: 500, pageSize: 10, window: "sliding", siblingCount: 2 });
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`siblingCount` applies only to `sliding`; `blockSize` only to `paged`.
|
|
146
|
+
`boundaryCount` applies to both.
|
|
147
|
+
|
|
148
|
+
## `items` and `paginationRange`
|
|
149
|
+
|
|
150
|
+
`items` is the range to render: each entry is either a page or a `gap` carrying
|
|
151
|
+
the pages it hides, so an overflow menu can offer them.
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
items.map((item) =>
|
|
155
|
+
item.type === "page" ? renderPage(item.page) : renderGap(item.pages),
|
|
156
|
+
);
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
A run of exactly **one** hidden page is returned as that page instead — hiding a
|
|
160
|
+
single number behind a menu costs a click and saves no width.
|
|
161
|
+
|
|
162
|
+
`paginationRange(page, pageCount, options?)` is exported separately and is
|
|
163
|
+
pure, so a consumer holding its own page state can reuse the arithmetic without
|
|
164
|
+
the hook. It takes the same `window` / `blockSize` / `siblingCount` /
|
|
165
|
+
`boundaryCount` options.
|
|
166
|
+
|
|
167
|
+
## Styled surface
|
|
168
|
+
|
|
169
|
+
The `pagination` registry component composes this hook's output into a styled
|
|
170
|
+
row. See `registry/components/pagination/README.md`.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { usePagination } from "../usePagination.ts";
|
|
5
|
+
import type { UsePaginationOptions } from "../usePagination.ts";
|
|
6
|
+
|
|
7
|
+
function NavigationProbe(options: UsePaginationOptions) {
|
|
8
|
+
const { page, next, previous, first, last, canPrevious, canNext } = usePagination(options);
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
<span data-testid="page">{page}</span>
|
|
12
|
+
<span data-testid="flags">
|
|
13
|
+
{canPrevious ? "prev" : "-"}/{canNext ? "next" : "-"}
|
|
14
|
+
</span>
|
|
15
|
+
<button type="button" onClick={previous}>
|
|
16
|
+
previous
|
|
17
|
+
</button>
|
|
18
|
+
<button type="button" onClick={next}>
|
|
19
|
+
next
|
|
20
|
+
</button>
|
|
21
|
+
<button type="button" onClick={first}>
|
|
22
|
+
first
|
|
23
|
+
</button>
|
|
24
|
+
<button type="button" onClick={last}>
|
|
25
|
+
last
|
|
26
|
+
</button>
|
|
27
|
+
</>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("usePagination navigation", () => {
|
|
32
|
+
it("should step forward one page", async () => {
|
|
33
|
+
const user = userEvent.setup();
|
|
34
|
+
render(<NavigationProbe pageCount={10} defaultPage={3} />);
|
|
35
|
+
|
|
36
|
+
await user.click(screen.getByRole("button", { name: "next" }));
|
|
37
|
+
|
|
38
|
+
expect(screen.getByTestId("page")).toHaveTextContent("4");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should step back one page", async () => {
|
|
42
|
+
const user = userEvent.setup();
|
|
43
|
+
render(<NavigationProbe pageCount={10} defaultPage={3} />);
|
|
44
|
+
|
|
45
|
+
await user.click(screen.getByRole("button", { name: "previous" }));
|
|
46
|
+
|
|
47
|
+
expect(screen.getByTestId("page")).toHaveTextContent("2");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should stay on the last page when stepping forward from it", async () => {
|
|
51
|
+
const user = userEvent.setup();
|
|
52
|
+
render(<NavigationProbe pageCount={10} defaultPage={10} />);
|
|
53
|
+
|
|
54
|
+
await user.click(screen.getByRole("button", { name: "next" }));
|
|
55
|
+
|
|
56
|
+
expect(screen.getByTestId("page")).toHaveTextContent("10");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("should stay on the first page when stepping back from it", async () => {
|
|
60
|
+
const user = userEvent.setup();
|
|
61
|
+
render(<NavigationProbe pageCount={10} defaultPage={1} />);
|
|
62
|
+
|
|
63
|
+
await user.click(screen.getByRole("button", { name: "previous" }));
|
|
64
|
+
|
|
65
|
+
expect(screen.getByTestId("page")).toHaveTextContent("1");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should jump to the first page", async () => {
|
|
69
|
+
const user = userEvent.setup();
|
|
70
|
+
render(<NavigationProbe pageCount={10} defaultPage={7} />);
|
|
71
|
+
|
|
72
|
+
await user.click(screen.getByRole("button", { name: "first" }));
|
|
73
|
+
|
|
74
|
+
expect(screen.getByTestId("page")).toHaveTextContent("1");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should jump to the last page", async () => {
|
|
78
|
+
const user = userEvent.setup();
|
|
79
|
+
render(<NavigationProbe pageCount={10} defaultPage={2} />);
|
|
80
|
+
|
|
81
|
+
await user.click(screen.getByRole("button", { name: "last" }));
|
|
82
|
+
|
|
83
|
+
expect(screen.getByTestId("page")).toHaveTextContent("10");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("should report both directions available mid-range", () => {
|
|
87
|
+
render(<NavigationProbe pageCount={10} defaultPage={5} />);
|
|
88
|
+
|
|
89
|
+
expect(screen.getByTestId("flags")).toHaveTextContent("prev/next");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should report no previous on the first page", () => {
|
|
93
|
+
render(<NavigationProbe pageCount={10} defaultPage={1} />);
|
|
94
|
+
|
|
95
|
+
expect(screen.getByTestId("flags")).toHaveTextContent("-/next");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should report no next on the last page", () => {
|
|
99
|
+
render(<NavigationProbe pageCount={10} defaultPage={10} />);
|
|
100
|
+
|
|
101
|
+
expect(screen.getByTestId("flags")).toHaveTextContent("prev/-");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should report neither direction when there is only one page", () => {
|
|
105
|
+
render(<NavigationProbe pageCount={1} />);
|
|
106
|
+
|
|
107
|
+
expect(screen.getByTestId("flags")).toHaveTextContent("-/-");
|
|
108
|
+
});
|
|
109
|
+
});
|