@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,67 @@
|
|
|
1
|
+
import { createContext } from "react";
|
|
2
|
+
import type { Context, Provider } from "react";
|
|
3
|
+
|
|
4
|
+
import { createStrictContext } from "../utils/index.ts";
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
NavigationMenuContextValue,
|
|
8
|
+
NavigationMenuItemContextValue,
|
|
9
|
+
NavigationMenuPanelContextValue,
|
|
10
|
+
} from "./types";
|
|
11
|
+
|
|
12
|
+
const navigationMenuContextPair = createStrictContext<NavigationMenuContextValue>(
|
|
13
|
+
"Component must be rendered as a child of NavigationMenu.Root",
|
|
14
|
+
"NavigationMenuContext",
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
/** Strict React context carrying the {@link NavigationMenuContextValue} from
|
|
18
|
+
* `NavigationMenu.Root` to its descendants. `null` outside a Root. */
|
|
19
|
+
export const NavigationMenuContext: Context<NavigationMenuContextValue | null> =
|
|
20
|
+
navigationMenuContextPair[0];
|
|
21
|
+
/** Reads the {@link NavigationMenuContextValue}; throws outside a
|
|
22
|
+
* `NavigationMenu.Root`. */
|
|
23
|
+
export const useNavigationMenuContext: () => NavigationMenuContextValue =
|
|
24
|
+
navigationMenuContextPair[1];
|
|
25
|
+
|
|
26
|
+
/** Provider for {@link NavigationMenuContext}, used by `NavigationMenu.Root`. */
|
|
27
|
+
const NavigationMenuProvider: Provider<NavigationMenuContextValue | null> =
|
|
28
|
+
NavigationMenuContext.Provider;
|
|
29
|
+
|
|
30
|
+
export { NavigationMenuProvider };
|
|
31
|
+
|
|
32
|
+
const navigationMenuItemContextPair =
|
|
33
|
+
createStrictContext<NavigationMenuItemContextValue>(
|
|
34
|
+
"Component must be rendered as a child of NavigationMenu.Item",
|
|
35
|
+
"NavigationMenuItemContext",
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
/** Strict React context carrying the {@link NavigationMenuItemContextValue}
|
|
39
|
+
* from `NavigationMenu.Item` to its Trigger and Content. `null` outside an
|
|
40
|
+
* Item. */
|
|
41
|
+
export const NavigationMenuItemContext: Context<NavigationMenuItemContextValue | null> =
|
|
42
|
+
navigationMenuItemContextPair[0];
|
|
43
|
+
/** Reads the {@link NavigationMenuItemContextValue}; throws outside a
|
|
44
|
+
* `NavigationMenu.Item`. */
|
|
45
|
+
export const useNavigationMenuItemContext: () => NavigationMenuItemContextValue =
|
|
46
|
+
navigationMenuItemContextPair[1];
|
|
47
|
+
|
|
48
|
+
/** Provider for {@link NavigationMenuItemContext}, used by
|
|
49
|
+
* `NavigationMenu.Item`. */
|
|
50
|
+
const NavigationMenuItemProvider: Provider<NavigationMenuItemContextValue | null> =
|
|
51
|
+
NavigationMenuItemContext.Provider;
|
|
52
|
+
|
|
53
|
+
export { NavigationMenuItemProvider };
|
|
54
|
+
|
|
55
|
+
/** Marks its subtree as being inside a `NavigationMenu.Content` panel.
|
|
56
|
+
* Deliberately **not** strict: the default `false` is the meaningful "I am a
|
|
57
|
+
* top-level entry" answer, so a Link outside any panel must read it without
|
|
58
|
+
* throwing. */
|
|
59
|
+
export const NavigationMenuPanelContext: Context<NavigationMenuPanelContextValue> =
|
|
60
|
+
createContext<NavigationMenuPanelContextValue>(false);
|
|
61
|
+
|
|
62
|
+
/** Provider for {@link NavigationMenuPanelContext}, used by
|
|
63
|
+
* `NavigationMenu.Content`. */
|
|
64
|
+
const NavigationMenuPanelProvider: Provider<NavigationMenuPanelContextValue> =
|
|
65
|
+
NavigationMenuPanelContext.Provider;
|
|
66
|
+
|
|
67
|
+
export { NavigationMenuPanelProvider };
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# NavigationMenu
|
|
2
|
+
|
|
3
|
+
The **desktop dropdown site nav**: a `<nav>` landmark wrapping a list of
|
|
4
|
+
top-level entries, each either a plain link or a trigger that discloses a
|
|
5
|
+
panel. One panel is open at a time.
|
|
6
|
+
|
|
7
|
+
Implements the [ARIA APG Disclosure Navigation
|
|
8
|
+
Menu](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/)
|
|
9
|
+
pattern — **not** a menubar. These are links to pages, so the semantics are
|
|
10
|
+
list-and-link and every top-level entry stays in the tab order.
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { NavigationMenu } from "@primitiv-ui/react";
|
|
14
|
+
|
|
15
|
+
<NavigationMenu.Root>
|
|
16
|
+
<NavigationMenu.List>
|
|
17
|
+
<NavigationMenu.Item value="concepts">
|
|
18
|
+
<NavigationMenu.Trigger>Concepts</NavigationMenu.Trigger>
|
|
19
|
+
<NavigationMenu.Content>
|
|
20
|
+
<NavigationMenu.Link href="/tokens">Tokens</NavigationMenu.Link>
|
|
21
|
+
</NavigationMenu.Content>
|
|
22
|
+
</NavigationMenu.Item>
|
|
23
|
+
<NavigationMenu.Item>
|
|
24
|
+
<NavigationMenu.Link href="/changelog">Changelog</NavigationMenu.Link>
|
|
25
|
+
</NavigationMenu.Item>
|
|
26
|
+
</NavigationMenu.List>
|
|
27
|
+
</NavigationMenu.Root>;
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Scope: desktop only, by design
|
|
31
|
+
|
|
32
|
+
This component is the **desktop** presentation. The mobile nav is deliberately
|
|
33
|
+
not a mode of it: several sections expand *in place* on mobile, versus one
|
|
34
|
+
panel at a time on desktop, and pushing both state models into one primitive
|
|
35
|
+
makes it larger without making the consumer's job smaller.
|
|
36
|
+
|
|
37
|
+
Mobile is instead a composition of [`Drawer`](../Drawer/README.md) +
|
|
38
|
+
[`Collapsible`](../Collapsible/README.md), reusing this component's
|
|
39
|
+
`NavigationMenu.Link`. What stays single-sourced across both:
|
|
40
|
+
|
|
41
|
+
| Concern | Where it lives | Duplicated? |
|
|
42
|
+
| --- | --- | --- |
|
|
43
|
+
| Nav data (sections → children) | one module in your app, `.map()`ed by both | **No** |
|
|
44
|
+
| Active state / `aria-current` | `NavigationMenu.Link`, used verbatim in both | **No** |
|
|
45
|
+
| Panel/row wrapper elements | `List`/`Item`/`Trigger`/`Content` vs `Collapsible.*` | Yes — ~15 lines each |
|
|
46
|
+
|
|
47
|
+
There is also an accessibility argument for the split: rendering one shared
|
|
48
|
+
tree and hiding half of it by breakpoint puts duplicate landmarks and
|
|
49
|
+
duplicate `id`s in the accessibility tree. See RFC 0019 §3–4a.
|
|
50
|
+
|
|
51
|
+
## Sub-components
|
|
52
|
+
|
|
53
|
+
| Export | Role | Notes |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| `NavigationMenu.Root` | `<nav>` + state owner | `aria-label="Main"` by default; uncontrolled (`defaultValue`) or controlled (`value` + `onValueChange`), where `""` means closed |
|
|
56
|
+
| `NavigationMenu.List` | `<ul>` | Top-level entries; carries `data-orientation` |
|
|
57
|
+
| `NavigationMenu.Item` | `<li>` | Its `value` is what makes the entry a disclosure — omit it for a plain link entry |
|
|
58
|
+
| `NavigationMenu.Trigger` | `<button>` | `aria-expanded`/`aria-controls`, hover-intent, keyboard; supports `asChild` and ref composition |
|
|
59
|
+
| `NavigationMenu.Content` | The panel | `hidden` while closed; `forceMount` for CSS animation; portals into a `Viewport` when one exists |
|
|
60
|
+
| `NavigationMenu.Viewport` | Shared panel host | **Optional.** All panels render into this one box so the open one morphs into the next |
|
|
61
|
+
| `NavigationMenu.Indicator` | Marker | **Optional.** Publishes the open trigger's measured geometry as custom properties; supports `asChild` so the marker can be an icon |
|
|
62
|
+
| `NavigationMenu.Link` | `<a>` | `active` → `aria-current="page"` + `data-active`; closes the menu on click; supports `asChild` |
|
|
63
|
+
|
|
64
|
+
## `Item` value: disclosure vs plain link
|
|
65
|
+
|
|
66
|
+
`value` on `NavigationMenu.Item` is the whole distinction:
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
{/* A disclosure — has a panel */}
|
|
70
|
+
<NavigationMenu.Item value="concepts">
|
|
71
|
+
<NavigationMenu.Trigger>Concepts</NavigationMenu.Trigger>
|
|
72
|
+
<NavigationMenu.Content>...</NavigationMenu.Content>
|
|
73
|
+
</NavigationMenu.Item>
|
|
74
|
+
|
|
75
|
+
{/* A plain link — no panel, no value */}
|
|
76
|
+
<NavigationMenu.Item>
|
|
77
|
+
<NavigationMenu.Link href="/changelog">Changelog</NavigationMenu.Link>
|
|
78
|
+
</NavigationMenu.Item>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A `Trigger` or `Content` inside a value-less `Item` **throws** with the fix in
|
|
82
|
+
the message, rather than silently deriving an id that could never be opened.
|
|
83
|
+
|
|
84
|
+
Note `value=""` is *not* the same as omitting it: the empty string is the
|
|
85
|
+
"nothing is open" sentinel, so an `Item` that adopts it gets a `Trigger` and
|
|
86
|
+
`Content` that can never report open. Give every disclosure entry a real value.
|
|
87
|
+
|
|
88
|
+
## State modes
|
|
89
|
+
|
|
90
|
+
- **Uncontrolled** — pass `defaultValue` to start with one panel open, or omit
|
|
91
|
+
it to start closed.
|
|
92
|
+
- **Controlled** — pass `value` and `onValueChange` together. Closing calls
|
|
93
|
+
`onValueChange("")`.
|
|
94
|
+
|
|
95
|
+
The two shapes are discriminated at the type level: passing `defaultValue`
|
|
96
|
+
alongside `value` is a type error.
|
|
97
|
+
|
|
98
|
+
A common controlled use is closing the menu on every route change:
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
const [open, setOpen] = useState("");
|
|
102
|
+
const { pathname } = useLocation();
|
|
103
|
+
useEffect(() => setOpen(""), [pathname]);
|
|
104
|
+
|
|
105
|
+
<NavigationMenu.Root value={open} onValueChange={setOpen}>...</NavigationMenu.Root>;
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Hover intent
|
|
109
|
+
|
|
110
|
+
| Prop | Default | Behaviour |
|
|
111
|
+
| --- | --- | --- |
|
|
112
|
+
| `openOnHover` | `true` | Whether hover opens a panel at all |
|
|
113
|
+
| `delayDuration` | `200` | ms of hover before the **first** panel opens; `0` opens immediately |
|
|
114
|
+
| `closeDelay` | `150` | ms the panel survives after the pointer leaves the `<nav>` |
|
|
115
|
+
|
|
116
|
+
Two details worth knowing:
|
|
117
|
+
|
|
118
|
+
- **Switching is instant.** `delayDuration` guards only the first open. Once a
|
|
119
|
+
panel is open, crossing to a sibling trigger swaps panels with no delay —
|
|
120
|
+
waiting again feels broken.
|
|
121
|
+
- **The close intent lives on the `<nav>`, not the trigger.** That is what lets
|
|
122
|
+
the pointer travel from a trigger down into its own panel without dismissing
|
|
123
|
+
it. Returning to the nav within `closeDelay` cancels the pending close.
|
|
124
|
+
|
|
125
|
+
Set `openOnHover={false}` for a click-only nav. Hover-to-open has no touch
|
|
126
|
+
equivalent, so this is the right choice if the same markup serves touch.
|
|
127
|
+
|
|
128
|
+
### Click after hover
|
|
129
|
+
|
|
130
|
+
A pointer that arrives to click fires `pointerenter` first, which with
|
|
131
|
+
hover-to-open opens the panel *before* the click lands. The trigger therefore
|
|
132
|
+
toggles against what was open when the pointer **arrived**, so a click never
|
|
133
|
+
undoes the user's own hover. A second click on a still-hovered trigger closes,
|
|
134
|
+
as expected.
|
|
135
|
+
|
|
136
|
+
## Keyboard interaction
|
|
137
|
+
|
|
138
|
+
| Key | Behaviour |
|
|
139
|
+
| --- | --- |
|
|
140
|
+
| `Enter` / `Space` | Toggle the focused trigger's panel |
|
|
141
|
+
| `ArrowRight` / `ArrowLeft` | Move between top-level entries (horizontal), mirrored under `dir="rtl"` |
|
|
142
|
+
| `ArrowUp` / `ArrowDown` | Move between top-level entries (vertical) |
|
|
143
|
+
| `Home` / `End` | First / last top-level entry |
|
|
144
|
+
| `ArrowDown` (horizontal), `ArrowRight`/`ArrowLeft` (vertical) | Open the panel and move focus to its first link |
|
|
145
|
+
| `Escape` | Close the open panel and return focus to its trigger |
|
|
146
|
+
|
|
147
|
+
Movement **wraps** at the ends.
|
|
148
|
+
|
|
149
|
+
**Not a roving tabindex.** Every top-level entry keeps `tabIndex` untouched and
|
|
150
|
+
stays in the tab order — a keyboard user must be able to Tab through page links
|
|
151
|
+
without discovering that arrow keys are required. `useRovingTabindex` is used
|
|
152
|
+
internally for its orientation/RTL-aware keymap only.
|
|
153
|
+
|
|
154
|
+
**Panel links are not top-level entries.** A `Link` inside a `Content` is
|
|
155
|
+
reached by Tab and is excluded from the top-level arrow order, so `Home`/`End`
|
|
156
|
+
inside a panel don't jump out of it.
|
|
157
|
+
|
|
158
|
+
`Escape` is handled once on the `<nav>` so it works from anywhere inside the
|
|
159
|
+
menu, including a link deep in an open panel, and returns focus to the trigger
|
|
160
|
+
so the user is never stranded on an element that just became `hidden`.
|
|
161
|
+
|
|
162
|
+
## Viewport — the shared panel box
|
|
163
|
+
|
|
164
|
+
Mount a `Viewport` when you want the desktop "one panel morphs into the next"
|
|
165
|
+
behaviour. Every `Content` portals into it, so a CSS transition on its size
|
|
166
|
+
animates between panels instead of each entry expanding separately:
|
|
167
|
+
|
|
168
|
+
```tsx
|
|
169
|
+
<NavigationMenu.Root>
|
|
170
|
+
<NavigationMenu.List>...</NavigationMenu.List>
|
|
171
|
+
<NavigationMenu.Viewport className="viewport" />
|
|
172
|
+
</NavigationMenu.Root>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
```css
|
|
176
|
+
.viewport { transition: height 200ms; }
|
|
177
|
+
.viewport[data-value="registry"] { height: 12rem; }
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Authoring does not change: always nest each `Content` inside its `Item`, next
|
|
181
|
+
to its `Trigger`. Omit the `Viewport` entirely and panels render in place.
|
|
182
|
+
|
|
183
|
+
## Anchoring the Viewport to the open trigger
|
|
184
|
+
|
|
185
|
+
Every `Trigger` publishes its own `anchor-name` (a plain CSS property, set
|
|
186
|
+
inline), and `Root` publishes whichever one is currently open as
|
|
187
|
+
`--primitiv-navigation-menu-active-trigger-anchor` on the `<nav>` — unset
|
|
188
|
+
while nothing is open. Together they let a stylesheet position `Viewport`
|
|
189
|
+
relative to whichever trigger opened it, via CSS anchor positioning, with
|
|
190
|
+
**no consumer wiring**: unlike a single-instance overlay (`Dropdown`,
|
|
191
|
+
`Popover`), where the consumer hand-authors a matching `anchor-name` /
|
|
192
|
+
`position-anchor` pair, here the library already knows which of several
|
|
193
|
+
triggers is the right one and publishes the pairing itself.
|
|
194
|
+
|
|
195
|
+
```css
|
|
196
|
+
.viewport {
|
|
197
|
+
position: absolute;
|
|
198
|
+
position-anchor: var(--primitiv-navigation-menu-active-trigger-anchor);
|
|
199
|
+
position-area: block-end; /* centers under the anchor by default */
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
This is purely a positioning *building block* — the library ships no styles,
|
|
204
|
+
so nothing above is applied for you. It only does anything in a browser that
|
|
205
|
+
supports CSS anchor positioning; write a plain fallback (e.g. insets pinned to
|
|
206
|
+
the nav's own edge) alongside it for browsers that don't.
|
|
207
|
+
|
|
208
|
+
## Indicator — geometry, not styling
|
|
209
|
+
|
|
210
|
+
No styles ship with the library, so the `Indicator` cannot position itself —
|
|
211
|
+
but it is the only part that can know *where* the open trigger is. It measures
|
|
212
|
+
that trigger and publishes the result as two inline custom properties:
|
|
213
|
+
|
|
214
|
+
| Custom property | Horizontal | Vertical |
|
|
215
|
+
| --- | --- | --- |
|
|
216
|
+
| `--primitiv-navigation-menu-indicator-position` | trigger `offsetLeft` | trigger `offsetTop` |
|
|
217
|
+
| `--primitiv-navigation-menu-indicator-size` | trigger `offsetWidth` | trigger `offsetHeight` |
|
|
218
|
+
|
|
219
|
+
```css
|
|
220
|
+
.indicator {
|
|
221
|
+
position: absolute;
|
|
222
|
+
inset-block-end: 0;
|
|
223
|
+
block-size: 2px;
|
|
224
|
+
inline-size: var(--primitiv-navigation-menu-indicator-size);
|
|
225
|
+
translate: var(--primitiv-navigation-menu-indicator-position) 0;
|
|
226
|
+
transition: translate 200ms, inline-size 200ms;
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Both are re-measured when the open entry changes and on window `resize`.
|
|
231
|
+
When the open value names an entry with no rendered trigger, neither property
|
|
232
|
+
is set — better an unpositioned marker than one parked at `0`.
|
|
233
|
+
|
|
234
|
+
`Indicator` also supports `asChild`, so the marker can be an icon rather than a
|
|
235
|
+
styled box — the `data-*` hooks and the geometry properties are merged onto your
|
|
236
|
+
element either way:
|
|
237
|
+
|
|
238
|
+
```tsx
|
|
239
|
+
<NavigationMenu.Indicator asChild>
|
|
240
|
+
<ChevronUpIcon aria-hidden />
|
|
241
|
+
</NavigationMenu.Indicator>
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## Panel motion — which way the pointer travelled
|
|
245
|
+
|
|
246
|
+
When the open entry changes, the two panels involved report the direction of
|
|
247
|
+
travel as `data-motion`, so a stylesheet can slide them instead of cross-fading
|
|
248
|
+
every switch:
|
|
249
|
+
|
|
250
|
+
| Value | Meaning |
|
|
251
|
+
| --- | --- |
|
|
252
|
+
| `from-end` | Entering, and the previous entry was earlier in the list |
|
|
253
|
+
| `from-start` | Entering, and the previous entry was later |
|
|
254
|
+
| `to-start` | Leaving, and the newly-open entry is later |
|
|
255
|
+
| `to-end` | Leaving, and the newly-open entry is earlier |
|
|
256
|
+
|
|
257
|
+
The attribute is **absent** for the two transitions with no direction — the first
|
|
258
|
+
open (nothing to travel from) and the full close (nothing to travel to) — and for
|
|
259
|
+
an entry with no registered trigger, since a direction derived from an unknown
|
|
260
|
+
position would be a guess. Those cases just fade.
|
|
261
|
+
|
|
262
|
+
## Viewport size — the one-box morph
|
|
263
|
+
|
|
264
|
+
`Viewport` measures the open panel and publishes it, the same split the
|
|
265
|
+
`Indicator` uses:
|
|
266
|
+
|
|
267
|
+
| Custom property | Value |
|
|
268
|
+
| --- | --- |
|
|
269
|
+
| `--primitiv-navigation-menu-viewport-width` | the open panel's `offsetWidth` |
|
|
270
|
+
| `--primitiv-navigation-menu-viewport-height` | its `offsetHeight` |
|
|
271
|
+
|
|
272
|
+
```css
|
|
273
|
+
.viewport {
|
|
274
|
+
inline-size: var(--primitiv-navigation-menu-viewport-width, auto);
|
|
275
|
+
block-size: var(--primitiv-navigation-menu-viewport-height, auto);
|
|
276
|
+
transition: inline-size 200ms, block-size 200ms;
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
The measurement is **kept through the close** rather than cleared: clearing it
|
|
281
|
+
would collapse the box at the very moment the exit needs its size. Re-measured on
|
|
282
|
+
window `resize`, like the Indicator.
|
|
283
|
+
|
|
284
|
+
## Link active state
|
|
285
|
+
|
|
286
|
+
`NavigationMenu.Link` does **no route matching**. You own the router, so you
|
|
287
|
+
own the comparison:
|
|
288
|
+
|
|
289
|
+
```tsx
|
|
290
|
+
<NavigationMenu.Link href="/tokens" active={pathname === "/tokens"}>
|
|
291
|
+
Tokens
|
|
292
|
+
</NavigationMenu.Link>
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
**A top-level link also closes on hover** (when `openOnHover` is on), so
|
|
296
|
+
travelling along the bar past a plain entry behaves like travelling onto another
|
|
297
|
+
trigger — without it, moving from an open trigger onto a link beside it leaves
|
|
298
|
+
that panel hanging over the page. Links *inside* a panel are exempt, for the
|
|
299
|
+
obvious reason.
|
|
300
|
+
|
|
301
|
+
Clicking any `Link` closes the open panel, whether the link sits in a panel or
|
|
302
|
+
beside one — leaving a panel hanging open over the page the user just navigated
|
|
303
|
+
to is the bug this avoids. Veto it by calling `event.preventDefault()` in your
|
|
304
|
+
own `onClick`.
|
|
305
|
+
|
|
306
|
+
## `asChild`
|
|
307
|
+
|
|
308
|
+
`Trigger`, `Link` and `Indicator` all support `asChild`, merging their props onto a
|
|
309
|
+
consumer element via [`Slot`](../Slot/README.md) — handlers compose, `style`
|
|
310
|
+
shallow-merges with the child winning, `className` concatenates, refs compose.
|
|
311
|
+
|
|
312
|
+
```tsx
|
|
313
|
+
<NavigationMenu.Link asChild active={isActive}>
|
|
314
|
+
<RouterLink to="/tokens">Tokens</RouterLink>
|
|
315
|
+
</NavigationMenu.Link>
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
`Trigger`'s internal registry ref composes with a consumer `ref`, so arrow-key
|
|
319
|
+
travel keeps working through either escape hatch.
|
|
320
|
+
|
|
321
|
+
## Styling hooks
|
|
322
|
+
|
|
323
|
+
| Element | Attributes |
|
|
324
|
+
| --- | --- |
|
|
325
|
+
| `Root` (`<nav>`) | `data-orientation`, `--primitiv-navigation-menu-active-trigger-anchor` |
|
|
326
|
+
| `List` (`<ul>`) | `data-orientation` |
|
|
327
|
+
| `Trigger` | `data-state="open" \| "closed"`, `anchor-name` (inline style) |
|
|
328
|
+
| `Content` | `data-state="open" \| "closed"`, `data-motion` |
|
|
329
|
+
| `Viewport` | `data-state`, `data-orientation`, `data-value`, measured-size custom properties |
|
|
330
|
+
| `Indicator` | `data-state`, `data-orientation`, `data-value`, geometry custom properties |
|
|
331
|
+
| `Link` | `data-active=""` when active (omitted otherwise) |
|
|
332
|
+
|
|
333
|
+
## Animation
|
|
334
|
+
|
|
335
|
+
`Content`, `Viewport` and `Indicator` each take `forceMount`, which swaps the
|
|
336
|
+
`hidden` attribute for `aria-hidden` while closed. `hidden` cannot be animated
|
|
337
|
+
away from; `aria-hidden` keeps the element out of the accessibility tree while
|
|
338
|
+
leaving CSS free to transition it:
|
|
339
|
+
|
|
340
|
+
```tsx
|
|
341
|
+
<NavigationMenu.Content forceMount className="panel" />
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
```css
|
|
345
|
+
.panel { opacity: 0; transition: opacity 150ms; }
|
|
346
|
+
.panel[data-state="open"] { opacity: 1; }
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
## Reading direction
|
|
350
|
+
|
|
351
|
+
`dir` (`"ltr"` / `"rtl"`) sets the horizontal arrow-key direction and the
|
|
352
|
+
`<nav>`'s `dir` attribute. When omitted it is inherited from the nearest
|
|
353
|
+
[`DirectionProvider`](../DirectionProvider/README.md), falling back to `"ltr"`.
|
|
354
|
+
Only the horizontal arrow pair mirrors; `Home`/`End` stay anchored to the start
|
|
355
|
+
and end of the list.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
|
|
4
|
+
import { getTriggerAnchorName, toAnchorIdentFragment } from "../utils";
|
|
5
|
+
|
|
6
|
+
import { ThreeEntryNav } from "./NavigationMenu.fixtures";
|
|
7
|
+
|
|
8
|
+
describe("toAnchorIdentFragment", () => {
|
|
9
|
+
it("replaces characters invalid in a CSS custom-ident with a hyphen", () => {
|
|
10
|
+
expect(toAnchorIdentFragment(":r0:-trigger-concepts")).toBe(
|
|
11
|
+
"-r0--trigger-concepts",
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("leaves an already-safe id untouched", () => {
|
|
16
|
+
expect(toAnchorIdentFragment("nav1-trigger-concepts")).toBe(
|
|
17
|
+
"nav1-trigger-concepts",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("getTriggerAnchorName", () => {
|
|
23
|
+
it("builds a namespaced anchor-name from the trigger id", () => {
|
|
24
|
+
expect(getTriggerAnchorName(":r0:-trigger-concepts")).toBe(
|
|
25
|
+
"--primitiv-navigation-menu-trigger--r0--trigger-concepts",
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("NavigationMenu anchor positioning", () => {
|
|
31
|
+
it("gives each trigger its own anchor-name, derived from its own id", () => {
|
|
32
|
+
render(<ThreeEntryNav />);
|
|
33
|
+
|
|
34
|
+
const concepts = screen.getByRole("button", { name: "Concepts" });
|
|
35
|
+
const registry = screen.getByRole("button", { name: "Registry & CLI" });
|
|
36
|
+
|
|
37
|
+
expect(concepts.style.getPropertyValue("anchor-name")).toBe(
|
|
38
|
+
getTriggerAnchorName(concepts.id),
|
|
39
|
+
);
|
|
40
|
+
expect(registry.style.getPropertyValue("anchor-name")).toBe(
|
|
41
|
+
getTriggerAnchorName(registry.id),
|
|
42
|
+
);
|
|
43
|
+
expect(concepts.style.getPropertyValue("anchor-name")).not.toBe(
|
|
44
|
+
registry.style.getPropertyValue("anchor-name"),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("publishes the open trigger's anchor-name as the nav's active-trigger-anchor", async () => {
|
|
49
|
+
const user = userEvent.setup();
|
|
50
|
+
render(<ThreeEntryNav />);
|
|
51
|
+
|
|
52
|
+
const concepts = screen.getByRole("button", { name: "Concepts" });
|
|
53
|
+
await user.click(concepts);
|
|
54
|
+
|
|
55
|
+
const nav = screen.getByRole("navigation");
|
|
56
|
+
expect(
|
|
57
|
+
nav.style.getPropertyValue(
|
|
58
|
+
"--primitiv-navigation-menu-active-trigger-anchor",
|
|
59
|
+
),
|
|
60
|
+
).toBe(getTriggerAnchorName(concepts.id));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("switches the active-trigger-anchor when a different trigger opens", async () => {
|
|
64
|
+
const user = userEvent.setup();
|
|
65
|
+
render(<ThreeEntryNav />);
|
|
66
|
+
|
|
67
|
+
await user.click(screen.getByRole("button", { name: "Concepts" }));
|
|
68
|
+
const registry = screen.getByRole("button", { name: "Registry & CLI" });
|
|
69
|
+
await user.click(registry);
|
|
70
|
+
|
|
71
|
+
const nav = screen.getByRole("navigation");
|
|
72
|
+
expect(
|
|
73
|
+
nav.style.getPropertyValue(
|
|
74
|
+
"--primitiv-navigation-menu-active-trigger-anchor",
|
|
75
|
+
),
|
|
76
|
+
).toBe(getTriggerAnchorName(registry.id));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("clears the active-trigger-anchor once the panel closes", async () => {
|
|
80
|
+
const user = userEvent.setup();
|
|
81
|
+
render(<ThreeEntryNav />);
|
|
82
|
+
|
|
83
|
+
const concepts = screen.getByRole("button", { name: "Concepts" });
|
|
84
|
+
await user.click(concepts);
|
|
85
|
+
await user.click(concepts);
|
|
86
|
+
|
|
87
|
+
const nav = screen.getByRole("navigation");
|
|
88
|
+
expect(
|
|
89
|
+
nav.style.getPropertyValue(
|
|
90
|
+
"--primitiv-navigation-menu-active-trigger-anchor",
|
|
91
|
+
),
|
|
92
|
+
).toBe("");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("keeps anchor names distinct across two independent Root instances", async () => {
|
|
96
|
+
const user = userEvent.setup();
|
|
97
|
+
render(
|
|
98
|
+
<>
|
|
99
|
+
<ThreeEntryNav aria-label="First" />
|
|
100
|
+
<ThreeEntryNav aria-label="Second" />
|
|
101
|
+
</>,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const [firstConcepts, secondConcepts] = screen.getAllByRole("button", {
|
|
105
|
+
name: "Concepts",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(firstConcepts.style.getPropertyValue("anchor-name")).not.toBe(
|
|
109
|
+
secondConcepts.style.getPropertyValue("anchor-name"),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const [firstNav, secondNav] = screen.getAllByRole("navigation");
|
|
113
|
+
|
|
114
|
+
// Opened one at a time: clicking the second nav's trigger moves focus out
|
|
115
|
+
// of the first nav's subtree, which correctly closes its own panel — so
|
|
116
|
+
// each nav's active-trigger-anchor is checked right after its own open,
|
|
117
|
+
// rather than asserting both stay open together.
|
|
118
|
+
await user.click(firstConcepts);
|
|
119
|
+
expect(
|
|
120
|
+
firstNav.style.getPropertyValue(
|
|
121
|
+
"--primitiv-navigation-menu-active-trigger-anchor",
|
|
122
|
+
),
|
|
123
|
+
).toBe(getTriggerAnchorName(firstConcepts.id));
|
|
124
|
+
|
|
125
|
+
await user.click(secondConcepts);
|
|
126
|
+
expect(
|
|
127
|
+
secondNav.style.getPropertyValue(
|
|
128
|
+
"--primitiv-navigation-menu-active-trigger-anchor",
|
|
129
|
+
),
|
|
130
|
+
).toBe(getTriggerAnchorName(secondConcepts.id));
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { useRef } from "react";
|
|
4
|
+
|
|
5
|
+
import { NavigationMenu } from "../NavigationMenu";
|
|
6
|
+
|
|
7
|
+
describe("NavigationMenu — asChild", () => {
|
|
8
|
+
it("renders a consumer element as the trigger, keeping the disclosure ARIA", async () => {
|
|
9
|
+
const user = userEvent.setup();
|
|
10
|
+
render(
|
|
11
|
+
<NavigationMenu.Root>
|
|
12
|
+
<NavigationMenu.List>
|
|
13
|
+
<NavigationMenu.Item value="concepts">
|
|
14
|
+
<NavigationMenu.Trigger asChild>
|
|
15
|
+
<span role="button" tabIndex={0}>
|
|
16
|
+
Concepts
|
|
17
|
+
</span>
|
|
18
|
+
</NavigationMenu.Trigger>
|
|
19
|
+
<NavigationMenu.Content data-testid="panel">
|
|
20
|
+
<NavigationMenu.Link href="/tokens">Tokens</NavigationMenu.Link>
|
|
21
|
+
</NavigationMenu.Content>
|
|
22
|
+
</NavigationMenu.Item>
|
|
23
|
+
</NavigationMenu.List>
|
|
24
|
+
</NavigationMenu.Root>,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const trigger = screen.getByRole("button", { name: "Concepts" });
|
|
28
|
+
|
|
29
|
+
expect(trigger.tagName).toBe("SPAN");
|
|
30
|
+
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
|
31
|
+
|
|
32
|
+
await user.click(trigger);
|
|
33
|
+
|
|
34
|
+
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
|
35
|
+
expect(screen.getByTestId("panel")).toBeVisible();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("composes an external trigger ref with the internal one", async () => {
|
|
39
|
+
const user = userEvent.setup();
|
|
40
|
+
|
|
41
|
+
function WithRef() {
|
|
42
|
+
const ref = useRef<HTMLButtonElement>(null);
|
|
43
|
+
return (
|
|
44
|
+
<NavigationMenu.Root>
|
|
45
|
+
<NavigationMenu.List>
|
|
46
|
+
<NavigationMenu.Item value="concepts">
|
|
47
|
+
<NavigationMenu.Trigger ref={ref}>Concepts</NavigationMenu.Trigger>
|
|
48
|
+
<NavigationMenu.Content data-testid="panel" />
|
|
49
|
+
</NavigationMenu.Item>
|
|
50
|
+
<NavigationMenu.Item>
|
|
51
|
+
<NavigationMenu.Link href="/changelog">
|
|
52
|
+
Changelog
|
|
53
|
+
</NavigationMenu.Link>
|
|
54
|
+
</NavigationMenu.Item>
|
|
55
|
+
</NavigationMenu.List>
|
|
56
|
+
<button type="button" onClick={() => ref.current?.focus()}>
|
|
57
|
+
Focus it
|
|
58
|
+
</button>
|
|
59
|
+
</NavigationMenu.Root>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
render(<WithRef />);
|
|
64
|
+
await user.click(screen.getByRole("button", { name: "Focus it" }));
|
|
65
|
+
|
|
66
|
+
// The external ref reaches the DOM node...
|
|
67
|
+
expect(screen.getByRole("button", { name: "Concepts" })).toHaveFocus();
|
|
68
|
+
|
|
69
|
+
// ...and the internal one still drives arrow-key travel.
|
|
70
|
+
await user.keyboard("{ArrowRight}");
|
|
71
|
+
expect(screen.getByRole("link", { name: "Changelog" })).toHaveFocus();
|
|
72
|
+
});
|
|
73
|
+
});
|