@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,56 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { usePagination } from "../usePagination.ts";
|
|
4
|
+
import type { UsePaginationOptions } from "../usePagination.ts";
|
|
5
|
+
|
|
6
|
+
function PageCountProbe(options: UsePaginationOptions) {
|
|
7
|
+
const { page, pageCount } = usePagination(options);
|
|
8
|
+
return (
|
|
9
|
+
<span data-testid="result">
|
|
10
|
+
{page}/{pageCount}
|
|
11
|
+
</span>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("usePagination page count", () => {
|
|
16
|
+
it("should derive pageCount by dividing totalItems by pageSize", () => {
|
|
17
|
+
render(<PageCountProbe totalItems={200} pageSize={10} />);
|
|
18
|
+
|
|
19
|
+
expect(screen.getByTestId("result")).toHaveTextContent("1/20");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should round a partial last page up so its items stay reachable", () => {
|
|
23
|
+
render(<PageCountProbe totalItems={237} pageSize={10} />);
|
|
24
|
+
|
|
25
|
+
expect(screen.getByTestId("result")).toHaveTextContent("1/24");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should take pageCount directly when the consumer supplies it", () => {
|
|
29
|
+
render(<PageCountProbe pageCount={7} />);
|
|
30
|
+
|
|
31
|
+
expect(screen.getByTestId("result")).toHaveTextContent("1/7");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should fall back to one page when given no sizing information at all", () => {
|
|
35
|
+
// Neither an explicit pageCount nor a totalItems/pageSize pair — there is
|
|
36
|
+
// nothing to derive a count from, so the range collapses to a single page
|
|
37
|
+
// rather than reporting 0 (which `page` could never sit inside).
|
|
38
|
+
render(<PageCountProbe />);
|
|
39
|
+
|
|
40
|
+
expect(screen.getByTestId("result")).toHaveTextContent("1/1");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should fall back to one page when only half the sizing pair is given", () => {
|
|
44
|
+
render(<PageCountProbe totalItems={200} />);
|
|
45
|
+
|
|
46
|
+
expect(screen.getByTestId("result")).toHaveTextContent("1/1");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should report a single empty page when there are no items at all", () => {
|
|
50
|
+
// A range has to have somewhere to stand: reporting 0 pages would make
|
|
51
|
+
// `page` (1-based) immediately out of range for every consumer.
|
|
52
|
+
render(<PageCountProbe totalItems={0} pageSize={10} />);
|
|
53
|
+
|
|
54
|
+
expect(screen.getByTestId("result")).toHaveTextContent("1/1");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it, vi } 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 PageStateProbe(options: UsePaginationOptions & { goTo?: number }) {
|
|
8
|
+
const { goTo, ...rest } = options;
|
|
9
|
+
const { page, setPage } = usePagination(rest);
|
|
10
|
+
return (
|
|
11
|
+
<>
|
|
12
|
+
<span data-testid="page">{page}</span>
|
|
13
|
+
<button type="button" onClick={() => setPage(goTo ?? 1)}>
|
|
14
|
+
go
|
|
15
|
+
</button>
|
|
16
|
+
</>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("usePagination page state", () => {
|
|
21
|
+
it("should start on page 1 when no default is given", () => {
|
|
22
|
+
render(<PageStateProbe pageCount={10} />);
|
|
23
|
+
|
|
24
|
+
expect(screen.getByTestId("page")).toHaveTextContent("1");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should start on defaultPage when uncontrolled", () => {
|
|
28
|
+
render(<PageStateProbe pageCount={10} defaultPage={4} />);
|
|
29
|
+
|
|
30
|
+
expect(screen.getByTestId("page")).toHaveTextContent("4");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should move to the requested page", async () => {
|
|
34
|
+
const user = userEvent.setup();
|
|
35
|
+
render(<PageStateProbe pageCount={10} goTo={6} />);
|
|
36
|
+
|
|
37
|
+
await user.click(screen.getByRole("button", { name: "go" }));
|
|
38
|
+
|
|
39
|
+
expect(screen.getByTestId("page")).toHaveTextContent("6");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should clamp a request above the last page down to the last page", async () => {
|
|
43
|
+
const user = userEvent.setup();
|
|
44
|
+
render(<PageStateProbe pageCount={10} goTo={99} />);
|
|
45
|
+
|
|
46
|
+
await user.click(screen.getByRole("button", { name: "go" }));
|
|
47
|
+
|
|
48
|
+
expect(screen.getByTestId("page")).toHaveTextContent("10");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should clamp a request below the first page up to page 1", async () => {
|
|
52
|
+
const user = userEvent.setup();
|
|
53
|
+
render(<PageStateProbe pageCount={10} defaultPage={5} goTo={0} />);
|
|
54
|
+
|
|
55
|
+
await user.click(screen.getByRole("button", { name: "go" }));
|
|
56
|
+
|
|
57
|
+
expect(screen.getByTestId("page")).toHaveTextContent("1");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should clamp an out-of-range defaultPage", () => {
|
|
61
|
+
// A page index restored from a URL or localStorage can easily outlive the
|
|
62
|
+
// data it pointed at.
|
|
63
|
+
render(<PageStateProbe pageCount={10} defaultPage={999} />);
|
|
64
|
+
|
|
65
|
+
expect(screen.getByTestId("page")).toHaveTextContent("10");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should notify onPageChange with the clamped page, not the raw request", async () => {
|
|
69
|
+
const onPageChange = vi.fn();
|
|
70
|
+
const user = userEvent.setup();
|
|
71
|
+
render(<PageStateProbe pageCount={10} goTo={99} onPageChange={onPageChange} />);
|
|
72
|
+
|
|
73
|
+
await user.click(screen.getByRole("button", { name: "go" }));
|
|
74
|
+
|
|
75
|
+
expect(onPageChange).toHaveBeenCalledWith(10);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should not notify onPageChange when the page has not actually changed", async () => {
|
|
79
|
+
const onPageChange = vi.fn();
|
|
80
|
+
const user = userEvent.setup();
|
|
81
|
+
render(<PageStateProbe pageCount={10} defaultPage={3} goTo={3} onPageChange={onPageChange} />);
|
|
82
|
+
|
|
83
|
+
await user.click(screen.getByRole("button", { name: "go" }));
|
|
84
|
+
|
|
85
|
+
expect(onPageChange).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { paginationRange } from "../usePagination.ts";
|
|
3
|
+
|
|
4
|
+
/** Render a range compactly: pages as numbers, gaps as the pages they hide. */
|
|
5
|
+
function shape(items: ReturnType<typeof paginationRange>): string {
|
|
6
|
+
return items
|
|
7
|
+
.map((item) => (item.type === "page" ? String(item.page) : `[${item.pages.join(",")}]`))
|
|
8
|
+
.join(" ");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("paginationRange", () => {
|
|
12
|
+
it("should list every page when the window is wide enough to cover the range", () => {
|
|
13
|
+
// Note the defaults do NOT show all 5: siblingCount 1 + boundaryCount 1
|
|
14
|
+
// leaves {1,2,5} visible, collapsing [3,4]. Covering a 5-page range takes a
|
|
15
|
+
// wider window — asserted below so the default's tighter shape is a
|
|
16
|
+
// deliberate, visible choice rather than an accident.
|
|
17
|
+
expect(shape(paginationRange(3, 5, { window: "sliding", siblingCount: 2 }))).toBe("1 2 3 4 5");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should collapse even a short range at the default window", () => {
|
|
21
|
+
expect(shape(paginationRange(1, 5, { window: "sliding" }))).toBe("1 2 [3,4] 5");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should collapse the tail when the current page is near the start", () => {
|
|
25
|
+
expect(shape(paginationRange(2, 10, { window: "sliding" }))).toBe("1 2 3 [4,5,6,7,8,9] 10");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should collapse the head when the current page is near the end", () => {
|
|
29
|
+
expect(shape(paginationRange(9, 10, { window: "sliding" }))).toBe("1 [2,3,4,5,6,7] 8 9 10");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should collapse both ends when the current page is mid-range", () => {
|
|
33
|
+
const items = paginationRange(50, 100, { window: "sliding" });
|
|
34
|
+
|
|
35
|
+
expect(items.map((i) => i.type)).toEqual([
|
|
36
|
+
"page", // 1
|
|
37
|
+
"gap", // 2-48
|
|
38
|
+
"page", // 49
|
|
39
|
+
"page", // 50 (current)
|
|
40
|
+
"page", // 51
|
|
41
|
+
"gap", // 52-99
|
|
42
|
+
"page", // 100
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should hide exactly the pages between the two shown either side of a gap", () => {
|
|
47
|
+
const items = paginationRange(50, 100, { window: "sliding" });
|
|
48
|
+
const [leading] = items.filter((i) => i.type === "gap");
|
|
49
|
+
|
|
50
|
+
// The gap must be contiguous and abut its neighbours — an off-by-one here
|
|
51
|
+
// would silently drop a page from the menu, unreachable anywhere.
|
|
52
|
+
expect(leading).toEqual({
|
|
53
|
+
type: "gap",
|
|
54
|
+
pages: Array.from({ length: 47 }, (_, i) => i + 2),
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("should show a lone hidden page instead of collapsing it", () => {
|
|
59
|
+
// Hiding one number behind a menu costs a click and saves no width — the
|
|
60
|
+
// ellipsis cell is the same square as the number it would replace.
|
|
61
|
+
expect(shape(paginationRange(1, 5, { window: "sliding", siblingCount: 1, boundaryCount: 2 }))).toBe("1 2 3 4 5");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should widen the window around the current page with siblingCount", () => {
|
|
65
|
+
expect(shape(paginationRange(50, 100, { window: "sliding", siblingCount: 2 }))).toContain("48 49 50 51 52");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should pin more pages at each end with boundaryCount", () => {
|
|
69
|
+
const items = shape(paginationRange(50, 100, { window: "sliding", siblingCount: 1, boundaryCount: 2 }));
|
|
70
|
+
|
|
71
|
+
expect(items.startsWith("1 2 ")).toBe(true);
|
|
72
|
+
expect(items.endsWith(" 99 100")).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should handle a single page without emitting a gap", () => {
|
|
76
|
+
expect(shape(paginationRange(1, 1, { window: "sliding" }))).toBe("1");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should not emit pages beyond the range when the count is tiny", () => {
|
|
80
|
+
expect(shape(paginationRange(1, 2, { window: "sliding", siblingCount: 3, boundaryCount: 3 }))).toBe("1 2");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { usePagination } from "../usePagination.ts";
|
|
4
|
+
import type { UsePaginationOptions } from "../usePagination.ts";
|
|
5
|
+
|
|
6
|
+
function SliceProbe(options: UsePaginationOptions) {
|
|
7
|
+
const { startIndex, endIndex } = usePagination(options);
|
|
8
|
+
return (
|
|
9
|
+
<span data-testid="slice">
|
|
10
|
+
{startIndex}..{endIndex}
|
|
11
|
+
</span>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("usePagination slice bounds", () => {
|
|
16
|
+
it("should give a zero-based half-open range for the first page", () => {
|
|
17
|
+
// Half-open so it drops straight into Array.slice and into a
|
|
18
|
+
// LIMIT/OFFSET query without an off-by-one at the call site.
|
|
19
|
+
render(<SliceProbe totalItems={200} pageSize={10} />);
|
|
20
|
+
|
|
21
|
+
expect(screen.getByTestId("slice")).toHaveTextContent("0..10");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should offset the range by the current page", () => {
|
|
25
|
+
render(<SliceProbe totalItems={200} pageSize={10} defaultPage={3} />);
|
|
26
|
+
|
|
27
|
+
expect(screen.getByTestId("slice")).toHaveTextContent("20..30");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should stop the last page's range at the item count, not the page size", () => {
|
|
31
|
+
// 237 items at 10/page: the last page holds 7, so endIndex must not run
|
|
32
|
+
// past the data.
|
|
33
|
+
render(<SliceProbe totalItems={237} pageSize={10} defaultPage={24} />);
|
|
34
|
+
|
|
35
|
+
expect(screen.getByTestId("slice")).toHaveTextContent("230..237");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should collapse to an empty range when there are no items", () => {
|
|
39
|
+
render(<SliceProbe totalItems={0} pageSize={10} />);
|
|
40
|
+
|
|
41
|
+
expect(screen.getByTestId("slice")).toHaveTextContent("0..0");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should not clamp the range when the total is unknown", () => {
|
|
45
|
+
// A server that reports pageCount but not an exact total is common. With
|
|
46
|
+
// no total to clamp against, the range is just a full page wide.
|
|
47
|
+
render(<SliceProbe pageCount={10} pageSize={10} defaultPage={3} />);
|
|
48
|
+
|
|
49
|
+
expect(screen.getByTestId("slice")).toHaveTextContent("20..30");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should report an empty range when the page size is unknown", () => {
|
|
53
|
+
// pageCount given directly — the hook has no idea how big a page is, so it
|
|
54
|
+
// cannot invent slice bounds.
|
|
55
|
+
render(<SliceProbe pageCount={10} defaultPage={3} />);
|
|
56
|
+
|
|
57
|
+
expect(screen.getByTestId("slice")).toHaveTextContent("0..0");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { paginationRange } from "../usePagination.ts";
|
|
3
|
+
|
|
4
|
+
function shape(items: ReturnType<typeof paginationRange>): string {
|
|
5
|
+
return items
|
|
6
|
+
.map((item) => (item.type === "page" ? String(item.page) : `[${item.pages.join(",")}]`))
|
|
7
|
+
.join(" ");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("paginationRange paged windowing", () => {
|
|
11
|
+
it("should hold the same block of pages steady while the current page moves within it", () => {
|
|
12
|
+
// The whole point: stepping 6 -> 7 -> 8 must not re-label a single cell,
|
|
13
|
+
// only move which one is active. A sliding window re-renders the numbers
|
|
14
|
+
// under the reader's cursor on every step, which reads as a flash.
|
|
15
|
+
const at6 = shape(paginationRange(6, 20, { window: "paged", blockSize: 5 }));
|
|
16
|
+
const at7 = shape(paginationRange(7, 20, { window: "paged", blockSize: 5 }));
|
|
17
|
+
const at8 = shape(paginationRange(8, 20, { window: "paged", blockSize: 5 }));
|
|
18
|
+
|
|
19
|
+
expect(at7).toBe(at6);
|
|
20
|
+
expect(at8).toBe(at6);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should show the block containing the current page, plus the boundaries", () => {
|
|
24
|
+
expect(shape(paginationRange(7, 20, { window: "paged", blockSize: 5 }))).toBe(
|
|
25
|
+
"1 [2,3,4,5] 6 7 8 9 10 [11,...,19] 20".replace(
|
|
26
|
+
"11,...,19",
|
|
27
|
+
"11,12,13,14,15,16,17,18,19",
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should advance to the next block when stepping past the block's last page", () => {
|
|
33
|
+
const atLast = shape(paginationRange(10, 20, { window: "paged", blockSize: 5 }));
|
|
34
|
+
const atNext = shape(paginationRange(11, 20, { window: "paged", blockSize: 5 }));
|
|
35
|
+
|
|
36
|
+
expect(atLast).toContain("6 7 8 9 10");
|
|
37
|
+
expect(atNext).toContain("11 12 13 14 15");
|
|
38
|
+
expect(atNext).not.toBe(atLast);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should fall back to the previous block when stepping before the block's first page", () => {
|
|
42
|
+
const atFirst = shape(paginationRange(6, 20, { window: "paged", blockSize: 5 }));
|
|
43
|
+
const atPrevious = shape(paginationRange(5, 20, { window: "paged", blockSize: 5 }));
|
|
44
|
+
|
|
45
|
+
expect(atFirst).toContain("6 7 8 9 10");
|
|
46
|
+
expect(atPrevious).toContain("1 2 3 4 5");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should not run the final block past the last page", () => {
|
|
50
|
+
// 20 pages at blockSize 5 divides evenly; 22 does not, so the last block
|
|
51
|
+
// holds only 21 and 22.
|
|
52
|
+
expect(shape(paginationRange(21, 22, { window: "paged", blockSize: 5 }))).toContain("21 22");
|
|
53
|
+
expect(shape(paginationRange(21, 22, { window: "paged", blockSize: 5 }))).not.toContain("23");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should still collapse a lone hidden page into a plain page", () => {
|
|
57
|
+
// Block 2-6 with boundary 1: nothing hidden at the head, so no gap.
|
|
58
|
+
expect(shape(paginationRange(3, 12, { window: "paged", blockSize: 5 }))).toBe(
|
|
59
|
+
"1 2 3 4 5 [6,7,8,9,10,11] 12",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should keep sliding behaviour available for consumers who prefer it", () => {
|
|
64
|
+
expect(shape(paginationRange(7, 20, { window: "sliding" }))).toBe(
|
|
65
|
+
"1 [2,3,4,5] 6 7 8 [9,...,19] 20".replace("9,...,19", "9,10,11,12,13,14,15,16,17,18,19"),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./usePagination.ts";
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
|
+
import { useControllableState } from "../hooks/useControllableState.ts";
|
|
3
|
+
|
|
4
|
+
/** One entry in a rendered page range: either a page, or a collapsed run. */
|
|
5
|
+
export type PaginationRangeItem =
|
|
6
|
+
| { type: "page"; page: number }
|
|
7
|
+
| { type: "gap"; pages: number[] };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How the visible page window behaves as the current page moves.
|
|
11
|
+
*
|
|
12
|
+
* - **paged** — the window holds a fixed block of pages and only advances when
|
|
13
|
+
* the current page steps outside it. Within a block nothing re-labels; only
|
|
14
|
+
* which cell is active changes. Stepping past the block's last page loads the
|
|
15
|
+
* next block in one deliberate jump.
|
|
16
|
+
* - **sliding** — the window follows the current page continuously, keeping
|
|
17
|
+
* `siblingCount` pages either side of it. The conventional behaviour, but the
|
|
18
|
+
* numbers under the reader's cursor re-label on EVERY step, which reads as a
|
|
19
|
+
* flash.
|
|
20
|
+
*/
|
|
21
|
+
export type PaginationWindow = "paged" | "sliding";
|
|
22
|
+
|
|
23
|
+
/** Shape of the range — see {@link paginationRange}. */
|
|
24
|
+
export type PaginationRangeOptions = {
|
|
25
|
+
/**
|
|
26
|
+
* Which windowing strategy to use.
|
|
27
|
+
* @default "paged"
|
|
28
|
+
*/
|
|
29
|
+
window?: PaginationWindow;
|
|
30
|
+
/**
|
|
31
|
+
* `paged` only — how many pages a block holds.
|
|
32
|
+
* @default 5
|
|
33
|
+
*/
|
|
34
|
+
blockSize?: number;
|
|
35
|
+
/**
|
|
36
|
+
* `sliding` only — pages shown either side of the current one.
|
|
37
|
+
* @default 1
|
|
38
|
+
*/
|
|
39
|
+
siblingCount?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Pages pinned at each end of the range, in both strategies.
|
|
42
|
+
* @default 1
|
|
43
|
+
*/
|
|
44
|
+
boundaryCount?: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Work out which pages to show, and which to collapse behind an overflow menu.
|
|
49
|
+
*
|
|
50
|
+
* Pins `boundaryCount` pages at each end, adds the window around the current
|
|
51
|
+
* page (see {@link PaginationWindow}), then walks the sorted result and
|
|
52
|
+
* collapses each remaining run into a `gap` carrying the pages it hides, so a
|
|
53
|
+
* menu can offer them.
|
|
54
|
+
*
|
|
55
|
+
* A run of exactly ONE page is returned as that page instead: hiding a single
|
|
56
|
+
* number behind a menu costs the reader a click and saves no width, since the
|
|
57
|
+
* ellipsis cell is the same square as the number it would replace. Same
|
|
58
|
+
* reasoning as `breadcrumb-overflow`, which only truncates once there is more
|
|
59
|
+
* than one crumb to hide.
|
|
60
|
+
*
|
|
61
|
+
* Pure — exported separately from the hook so a consumer holding its own page
|
|
62
|
+
* state can still reuse the arithmetic.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```ts
|
|
66
|
+
* paginationRange(7, 20);
|
|
67
|
+
* // 1 [2,3,4,5] 6 7 8 9 10 [11...19] 20 — the 6-10 block, steady while you
|
|
68
|
+
* // move within it
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export function paginationRange(
|
|
72
|
+
page: number,
|
|
73
|
+
pageCount: number,
|
|
74
|
+
options: PaginationRangeOptions = {},
|
|
75
|
+
): PaginationRangeItem[] {
|
|
76
|
+
const { window = "paged", blockSize = 5, siblingCount = 1, boundaryCount = 1 } = options;
|
|
77
|
+
|
|
78
|
+
const visible = new Set<number>();
|
|
79
|
+
for (let i = 1; i <= Math.min(boundaryCount, pageCount); i++) visible.add(i);
|
|
80
|
+
for (let i = Math.max(1, pageCount - boundaryCount + 1); i <= pageCount; i++) visible.add(i);
|
|
81
|
+
|
|
82
|
+
if (window === "paged") {
|
|
83
|
+
// Which block the current page falls in. Because this is derived from the
|
|
84
|
+
// page rather than centred on it, every page in the block yields the same
|
|
85
|
+
// window — that is what stops the cells re-labelling as you step.
|
|
86
|
+
const blockStart = Math.floor((page - 1) / blockSize) * blockSize + 1;
|
|
87
|
+
const blockEnd = Math.min(blockStart + blockSize - 1, pageCount);
|
|
88
|
+
for (let i = blockStart; i <= blockEnd; i++) visible.add(i);
|
|
89
|
+
} else {
|
|
90
|
+
for (let i = Math.max(1, page - siblingCount); i <= Math.min(pageCount, page + siblingCount); i++) {
|
|
91
|
+
visible.add(i);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const sorted = [...visible].sort((a, b) => a - b);
|
|
96
|
+
const items: PaginationRangeItem[] = [];
|
|
97
|
+
|
|
98
|
+
sorted.forEach((current, i) => {
|
|
99
|
+
if (i > 0) {
|
|
100
|
+
const missing: number[] = [];
|
|
101
|
+
for (let p = sorted[i - 1] + 1; p < current; p++) missing.push(p);
|
|
102
|
+
if (missing.length === 1) items.push({ type: "page", page: missing[0] });
|
|
103
|
+
else if (missing.length > 1) items.push({ type: "gap", pages: missing });
|
|
104
|
+
}
|
|
105
|
+
items.push({ type: "page", page: current });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return items;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type UsePaginationOptions = {
|
|
112
|
+
/**
|
|
113
|
+
* How many pages there are. Supply this directly when the page count comes
|
|
114
|
+
* from elsewhere (a server response, say); otherwise give `totalItems` and
|
|
115
|
+
* `pageSize` and let the hook derive it.
|
|
116
|
+
*/
|
|
117
|
+
pageCount?: number;
|
|
118
|
+
/** Total number of items across every page. Pairs with `pageSize`. */
|
|
119
|
+
totalItems?: number;
|
|
120
|
+
/** How many items each page holds. Pairs with `totalItems`. */
|
|
121
|
+
pageSize?: number;
|
|
122
|
+
/**
|
|
123
|
+
* The current page, 1-based — supply this to drive the hook yourself
|
|
124
|
+
* (controlled). Leave it out to let the hook hold its own state.
|
|
125
|
+
*/
|
|
126
|
+
page?: number;
|
|
127
|
+
/**
|
|
128
|
+
* Starting page when uncontrolled. Read once on mount.
|
|
129
|
+
* @default 1
|
|
130
|
+
*/
|
|
131
|
+
defaultPage?: number;
|
|
132
|
+
/**
|
|
133
|
+
* Notified whenever the page changes, with the **clamped** page — never a
|
|
134
|
+
* raw out-of-range request. Not called when the page hasn't actually moved.
|
|
135
|
+
*/
|
|
136
|
+
onPageChange?: (page: number) => void;
|
|
137
|
+
/**
|
|
138
|
+
* How the visible window behaves as the page moves. See
|
|
139
|
+
* {@link PaginationWindow} — `paged` holds a block steady, `sliding` follows
|
|
140
|
+
* the current page.
|
|
141
|
+
* @default "paged"
|
|
142
|
+
*/
|
|
143
|
+
window?: PaginationWindow;
|
|
144
|
+
/**
|
|
145
|
+
* `paged` only — how many pages a block holds.
|
|
146
|
+
* @default 5
|
|
147
|
+
*/
|
|
148
|
+
blockSize?: number;
|
|
149
|
+
/**
|
|
150
|
+
* `sliding` only — pages shown either side of the current one.
|
|
151
|
+
* @default 1
|
|
152
|
+
*/
|
|
153
|
+
siblingCount?: number;
|
|
154
|
+
/**
|
|
155
|
+
* How many pages `items` pins at each end of the range.
|
|
156
|
+
* @default 1
|
|
157
|
+
*/
|
|
158
|
+
boundaryCount?: number;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* What {@link usePagination} hands back.
|
|
163
|
+
*/
|
|
164
|
+
export type UsePaginationResult = {
|
|
165
|
+
/** The current page, 1-based. Always within `1...pageCount`. */
|
|
166
|
+
page: number;
|
|
167
|
+
/** How many pages there are. Never less than 1. */
|
|
168
|
+
pageCount: number;
|
|
169
|
+
/** Go to a page. Out-of-range requests are clamped, not rejected. */
|
|
170
|
+
setPage: (page: number) => void;
|
|
171
|
+
/** Step forward one page. A no-op on the last page. */
|
|
172
|
+
next: () => void;
|
|
173
|
+
/** Step back one page. A no-op on the first page. */
|
|
174
|
+
previous: () => void;
|
|
175
|
+
/** Jump to page 1. */
|
|
176
|
+
first: () => void;
|
|
177
|
+
/** Jump to the last page. */
|
|
178
|
+
last: () => void;
|
|
179
|
+
/** Whether there is a page before this one — wire to a prev control's `disabled`. */
|
|
180
|
+
canPrevious: boolean;
|
|
181
|
+
/** Whether there is a page after this one — wire to a next control's `disabled`. */
|
|
182
|
+
canNext: boolean;
|
|
183
|
+
/**
|
|
184
|
+
* Zero-based index of this page's first item. Together with `endIndex` this
|
|
185
|
+
* is a **half-open** range, so it drops straight into `Array.slice(start,
|
|
186
|
+
* end)` or a LIMIT/OFFSET query with no off-by-one at the call site.
|
|
187
|
+
*
|
|
188
|
+
* Both are `0` when the hook has no `pageSize` to reason from.
|
|
189
|
+
*/
|
|
190
|
+
startIndex: number;
|
|
191
|
+
/** Zero-based index one past this page's last item. Never exceeds `totalItems`. */
|
|
192
|
+
endIndex: number;
|
|
193
|
+
/**
|
|
194
|
+
* The range to render: each entry is either a page or a collapsed `gap`
|
|
195
|
+
* carrying the pages it hides. See {@link paginationRange}.
|
|
196
|
+
*/
|
|
197
|
+
items: PaginationRangeItem[];
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
function clamp(page: number, pageCount: number): number {
|
|
201
|
+
return Math.min(Math.max(page, 1), pageCount);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Headless pagination state: the page arithmetic every paged surface needs,
|
|
206
|
+
* with no opinion about where the data comes from or how the row is rendered.
|
|
207
|
+
*
|
|
208
|
+
* @see https://primitiv-ui.dev/docs/hooks/use-pagination
|
|
209
|
+
*/
|
|
210
|
+
export function usePagination(options: UsePaginationOptions): UsePaginationResult {
|
|
211
|
+
const {
|
|
212
|
+
pageCount: explicitPageCount,
|
|
213
|
+
totalItems,
|
|
214
|
+
pageSize,
|
|
215
|
+
page: controlledPage,
|
|
216
|
+
defaultPage = 1,
|
|
217
|
+
onPageChange,
|
|
218
|
+
window = "paged",
|
|
219
|
+
blockSize = 5,
|
|
220
|
+
siblingCount = 1,
|
|
221
|
+
boundaryCount = 1,
|
|
222
|
+
} = options;
|
|
223
|
+
|
|
224
|
+
const derived =
|
|
225
|
+
explicitPageCount !== undefined
|
|
226
|
+
? explicitPageCount
|
|
227
|
+
: totalItems !== undefined && pageSize !== undefined
|
|
228
|
+
? Math.ceil(totalItems / pageSize)
|
|
229
|
+
: 1;
|
|
230
|
+
|
|
231
|
+
// A range needs somewhere to stand: with 0 pages, a 1-based `page` would be
|
|
232
|
+
// out of range the moment it is read.
|
|
233
|
+
const pageCount = Math.max(derived, 1);
|
|
234
|
+
|
|
235
|
+
const [rawPage, setRawPage] = useControllableState(controlledPage, defaultPage, onPageChange);
|
|
236
|
+
|
|
237
|
+
// Clamped on the way OUT as well as on the way in: `defaultPage` is read once
|
|
238
|
+
// on mount, and a controlled `page` comes straight from the consumer, so
|
|
239
|
+
// neither has passed through `setPage`. Both can outlive the data they
|
|
240
|
+
// pointed at (a page index restored from a URL, a list that shrank).
|
|
241
|
+
const page = clamp(rawPage, pageCount);
|
|
242
|
+
|
|
243
|
+
const setPage = useCallback(
|
|
244
|
+
(next: number) => {
|
|
245
|
+
const target = clamp(next, pageCount);
|
|
246
|
+
// useControllableState deliberately does not dedupe, so the guard lives
|
|
247
|
+
// here — a consumer re-selecting the page they are already on should not
|
|
248
|
+
// see a change notification.
|
|
249
|
+
if (target === page) return;
|
|
250
|
+
setRawPage(target);
|
|
251
|
+
},
|
|
252
|
+
[page, pageCount, setRawPage],
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// Every mover goes through setPage, so clamping and the change guard are
|
|
256
|
+
// stated once — stepping past either end is a no-op rather than an error.
|
|
257
|
+
const next = useCallback(() => setPage(page + 1), [page, setPage]);
|
|
258
|
+
const previous = useCallback(() => setPage(page - 1), [page, setPage]);
|
|
259
|
+
const first = useCallback(() => setPage(1), [setPage]);
|
|
260
|
+
const last = useCallback(() => setPage(pageCount), [pageCount, setPage]);
|
|
261
|
+
|
|
262
|
+
// Only meaningful when the hook knows a page size. Given `pageCount` alone it
|
|
263
|
+
// cannot invent slice bounds, so it reports an empty range rather than a
|
|
264
|
+
// plausible-looking guess a consumer might slice with.
|
|
265
|
+
const canSlice = pageSize !== undefined;
|
|
266
|
+
const startIndex = canSlice ? (page - 1) * pageSize : 0;
|
|
267
|
+
const endIndex = canSlice
|
|
268
|
+
? // Clamped to the real item count so the final, partial page's range stops
|
|
269
|
+
// at the data instead of a page-size multiple past it.
|
|
270
|
+
Math.min(startIndex + pageSize, totalItems ?? startIndex + pageSize)
|
|
271
|
+
: 0;
|
|
272
|
+
|
|
273
|
+
const items = useMemo(
|
|
274
|
+
() => paginationRange(page, pageCount, { window, blockSize, siblingCount, boundaryCount }),
|
|
275
|
+
[page, pageCount, window, blockSize, siblingCount, boundaryCount],
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
page,
|
|
280
|
+
pageCount,
|
|
281
|
+
setPage,
|
|
282
|
+
items,
|
|
283
|
+
startIndex,
|
|
284
|
+
endIndex,
|
|
285
|
+
next,
|
|
286
|
+
previous,
|
|
287
|
+
first,
|
|
288
|
+
last,
|
|
289
|
+
canPrevious: page > 1,
|
|
290
|
+
canNext: page < pageCount,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Elements that can receive keyboard focus and take part in the natural tab
|
|
3
|
+
* order. Two consumers rely on it: moving initial focus into a surface when it
|
|
4
|
+
* opens (e.g. `Popover`), and locating the boundary elements of a focus trap
|
|
5
|
+
* (e.g. `Modal`). `[tabindex="-1"]` is excluded because such elements are only
|
|
6
|
+
* programmatically focusable, not tabbable.
|
|
7
|
+
*/
|
|
8
|
+
export const FOCUSABLE_SELECTOR = [
|
|
9
|
+
"a[href]",
|
|
10
|
+
"button:not([disabled])",
|
|
11
|
+
"input:not([disabled])",
|
|
12
|
+
"select:not([disabled])",
|
|
13
|
+
"textarea:not([disabled])",
|
|
14
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
15
|
+
].join(",");
|
|
@@ -44,7 +44,7 @@ type GetKeyToActionMapOptions = {
|
|
|
44
44
|
* "activate") into its own movement / activation logic.
|
|
45
45
|
*
|
|
46
46
|
* Returns a plain `Record<string, RovingKeyAction>` so call sites can do
|
|
47
|
-
* `const action = map[event.key]; if (action === "next")
|
|
47
|
+
* `const action = map[event.key]; if (action === "next") ...`.
|
|
48
48
|
*
|
|
49
49
|
* @example Tabs trigger (horizontal, RTL, all features)
|
|
50
50
|
* ```ts
|
package/src/utils/index.ts
CHANGED