@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,890 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
FocusEvent,
|
|
4
|
+
KeyboardEvent,
|
|
5
|
+
PointerEvent,
|
|
6
|
+
ReactElement,
|
|
7
|
+
ReactPortal,
|
|
8
|
+
Ref,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { createPortal } from "react-dom";
|
|
11
|
+
|
|
12
|
+
import { useDirection } from "../DirectionProvider/index.ts";
|
|
13
|
+
import { Slot, composeEventHandlers, composeRefs } from "../Slot/index.ts";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
useNavigationMenuEntry,
|
|
17
|
+
useNavigationMenuIndicator,
|
|
18
|
+
useNavigationMenuLink,
|
|
19
|
+
useNavigationMenuRoot,
|
|
20
|
+
useNavigationMenuTrigger,
|
|
21
|
+
useNavigationMenuViewport,
|
|
22
|
+
} from "./hooks/index.ts";
|
|
23
|
+
import {
|
|
24
|
+
NavigationMenuItemProvider,
|
|
25
|
+
NavigationMenuPanelProvider,
|
|
26
|
+
NavigationMenuProvider,
|
|
27
|
+
useNavigationMenuContext,
|
|
28
|
+
} from "./NavigationMenuContext";
|
|
29
|
+
import { getPanelMotion } from "./utils";
|
|
30
|
+
import type {
|
|
31
|
+
NavigationMenuContentProps,
|
|
32
|
+
NavigationMenuIndicatorProps,
|
|
33
|
+
NavigationMenuItemContextValue,
|
|
34
|
+
NavigationMenuItemProps,
|
|
35
|
+
NavigationMenuLinkProps,
|
|
36
|
+
NavigationMenuListProps,
|
|
37
|
+
NavigationMenuRootProps,
|
|
38
|
+
NavigationMenuTriggerProps,
|
|
39
|
+
NavigationMenuViewportProps,
|
|
40
|
+
} from "./types";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The root of a NavigationMenu — renders the `<nav>` landmark, owns which panel
|
|
44
|
+
* is open, and provides context to every descendant.
|
|
45
|
+
*
|
|
46
|
+
* The `<nav>` defaults to `aria-label="Main"` so assistive technology announces
|
|
47
|
+
* it as the primary navigation landmark, distinguishing it from any other
|
|
48
|
+
* `<nav>` on the page. Override `aria-label` if your product uses different
|
|
49
|
+
* terminology, or if this is a secondary nav.
|
|
50
|
+
*
|
|
51
|
+
* **State.** Exactly one panel is open at a time, identified by the enclosing
|
|
52
|
+
* {@link NavigationMenuItem | `Item`}'s `value`. **The empty string means
|
|
53
|
+
* nothing is open** — there is no separate `open` flag. Two modes, statically
|
|
54
|
+
* discriminated at the type level so TypeScript accepts only one shape:
|
|
55
|
+
*
|
|
56
|
+
* - **Uncontrolled** — pass
|
|
57
|
+
* {@link NavigationMenuRootProps.defaultValue | `defaultValue`}, or omit it to
|
|
58
|
+
* start closed. The component owns the open value.
|
|
59
|
+
* - **Controlled** — pass {@link NavigationMenuRootProps.value | `value`} *and*
|
|
60
|
+
* {@link NavigationMenuRootProps.onValueChange | `onValueChange`} together.
|
|
61
|
+
* Every open and close request defers to the callback, which is called with
|
|
62
|
+
* `""` to close.
|
|
63
|
+
*
|
|
64
|
+
* **Hover intent.** With
|
|
65
|
+
* {@link NavigationMenuRootProps.openOnHover | `openOnHover`} (the default),
|
|
66
|
+
* hovering a trigger opens its panel after
|
|
67
|
+
* {@link NavigationMenuRootProps.delayDuration | `delayDuration`} — but only
|
|
68
|
+
* for the *first* open. Once one panel is open, crossing to a sibling trigger
|
|
69
|
+
* switches instantly. Leaving the `<nav>` closes after
|
|
70
|
+
* {@link NavigationMenuRootProps.closeDelay | `closeDelay`}, and coming back
|
|
71
|
+
* within that window cancels the close. The close intent lives on the `<nav>`
|
|
72
|
+
* rather than on each trigger, which is what lets the pointer travel from a
|
|
73
|
+
* trigger into its own panel without dismissing it.
|
|
74
|
+
*
|
|
75
|
+
* **Escape** is handled here, once, so it works from anywhere inside the menu —
|
|
76
|
+
* including a link deep in an open panel. It closes the panel and returns focus
|
|
77
|
+
* to that panel's trigger, so the user is never left focused on an element that
|
|
78
|
+
* just became `hidden`.
|
|
79
|
+
*
|
|
80
|
+
* **Styling hooks.** `data-orientation="horizontal" | "vertical"`,
|
|
81
|
+
* `--primitiv-navigation-menu-active-trigger-anchor` (the open trigger's
|
|
82
|
+
* `anchor-name`, for CSS-anchor-positioning the {@link NavigationMenuViewport
|
|
83
|
+
* | `Viewport`} — see its docs).
|
|
84
|
+
*
|
|
85
|
+
* **Reading direction.** {@link NavigationMenuRootProps.dir | `dir`} sets the
|
|
86
|
+
* horizontal arrow-key direction and the container's `dir` attribute. When
|
|
87
|
+
* omitted it is inherited from the nearest {@link DirectionProvider}, falling
|
|
88
|
+
* back to `"ltr"`.
|
|
89
|
+
*
|
|
90
|
+
* @extends HTMLElement
|
|
91
|
+
*
|
|
92
|
+
* @example Uncontrolled, with a shared viewport and an indicator
|
|
93
|
+
* ```tsx
|
|
94
|
+
* <NavigationMenu.Root>
|
|
95
|
+
* <NavigationMenu.List>
|
|
96
|
+
* <NavigationMenu.Item value="concepts">
|
|
97
|
+
* <NavigationMenu.Trigger>Concepts</NavigationMenu.Trigger>
|
|
98
|
+
* <NavigationMenu.Content>
|
|
99
|
+
* <NavigationMenu.Link href="/tokens">Tokens</NavigationMenu.Link>
|
|
100
|
+
* </NavigationMenu.Content>
|
|
101
|
+
* </NavigationMenu.Item>
|
|
102
|
+
* <NavigationMenu.Item>
|
|
103
|
+
* <NavigationMenu.Link href="/changelog">Changelog</NavigationMenu.Link>
|
|
104
|
+
* </NavigationMenu.Item>
|
|
105
|
+
* </NavigationMenu.List>
|
|
106
|
+
* <NavigationMenu.Indicator />
|
|
107
|
+
* <NavigationMenu.Viewport />
|
|
108
|
+
* </NavigationMenu.Root>
|
|
109
|
+
* ```
|
|
110
|
+
*
|
|
111
|
+
* @example Controlled, closing the menu on every route change
|
|
112
|
+
* ```tsx
|
|
113
|
+
* const [open, setOpen] = useState("");
|
|
114
|
+
* const { pathname } = useLocation();
|
|
115
|
+
* useEffect(() => setOpen(""), [pathname]);
|
|
116
|
+
*
|
|
117
|
+
* <NavigationMenu.Root value={open} onValueChange={setOpen}>
|
|
118
|
+
* ...
|
|
119
|
+
* </NavigationMenu.Root>
|
|
120
|
+
* ```
|
|
121
|
+
*
|
|
122
|
+
* @example Click-only, for a nav that also has to work under touch
|
|
123
|
+
* ```tsx
|
|
124
|
+
* <NavigationMenu.Root openOnHover={false} aria-label="Docs">
|
|
125
|
+
* ...
|
|
126
|
+
* </NavigationMenu.Root>
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
export function NavigationMenuRoot({
|
|
130
|
+
children,
|
|
131
|
+
orientation = "horizontal",
|
|
132
|
+
dir,
|
|
133
|
+
openOnHover = true,
|
|
134
|
+
delayDuration = 200,
|
|
135
|
+
closeDelay = 150,
|
|
136
|
+
defaultValue,
|
|
137
|
+
value,
|
|
138
|
+
onValueChange,
|
|
139
|
+
onPointerEnter,
|
|
140
|
+
onPointerLeave,
|
|
141
|
+
onKeyDown,
|
|
142
|
+
onBlur,
|
|
143
|
+
...rest
|
|
144
|
+
}: NavigationMenuRootProps): ReactElement {
|
|
145
|
+
const resolvedDir = dir ?? useDirection();
|
|
146
|
+
const {
|
|
147
|
+
contextValue,
|
|
148
|
+
cancelClose,
|
|
149
|
+
closeWithDelay,
|
|
150
|
+
closeOnFocusOutside,
|
|
151
|
+
handleKeyDown: handleEscapeKeyDown,
|
|
152
|
+
style,
|
|
153
|
+
} = useNavigationMenuRoot({
|
|
154
|
+
orientation,
|
|
155
|
+
dir: resolvedDir,
|
|
156
|
+
openOnHover,
|
|
157
|
+
delayDuration,
|
|
158
|
+
closeDelay,
|
|
159
|
+
defaultValue,
|
|
160
|
+
value,
|
|
161
|
+
onValueChange,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// The close intent belongs to the whole nav, not to any one trigger: leaving
|
|
165
|
+
// a trigger for its own panel must not dismiss it, and only leaving the
|
|
166
|
+
// `<nav>` means the user is done with the menu.
|
|
167
|
+
const handlePointerEnter = composeEventHandlers<PointerEvent<HTMLElement>>(
|
|
168
|
+
onPointerEnter,
|
|
169
|
+
cancelClose,
|
|
170
|
+
);
|
|
171
|
+
const handlePointerLeave = composeEventHandlers<PointerEvent<HTMLElement>>(
|
|
172
|
+
onPointerLeave,
|
|
173
|
+
closeWithDelay,
|
|
174
|
+
);
|
|
175
|
+
const handleKeyDown = composeEventHandlers<KeyboardEvent<HTMLElement>>(
|
|
176
|
+
onKeyDown,
|
|
177
|
+
handleEscapeKeyDown,
|
|
178
|
+
);
|
|
179
|
+
// Closes an open panel when keyboard focus leaves the nav for good — Tab
|
|
180
|
+
// past the last entry, or into an unrelated element such as a second,
|
|
181
|
+
// independent NavigationMenu. See useNavigationMenuRoot's closeOnFocusOutside.
|
|
182
|
+
const handleBlur = composeEventHandlers<FocusEvent<HTMLElement>>(
|
|
183
|
+
onBlur,
|
|
184
|
+
closeOnFocusOutside,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<NavigationMenuProvider value={contextValue}>
|
|
189
|
+
<nav
|
|
190
|
+
aria-label="Main"
|
|
191
|
+
dir={resolvedDir}
|
|
192
|
+
data-orientation={orientation}
|
|
193
|
+
onPointerEnter={handlePointerEnter}
|
|
194
|
+
onPointerLeave={handlePointerLeave}
|
|
195
|
+
onKeyDown={handleKeyDown}
|
|
196
|
+
onBlur={handleBlur}
|
|
197
|
+
{...rest}
|
|
198
|
+
// Placed after `...rest` so it isn't clobbered by a bare consumer
|
|
199
|
+
// `style` — a consumer's own value for this property (unlikely, but
|
|
200
|
+
// possible) still wins inside this merge.
|
|
201
|
+
style={{ ...style, ...rest.style }}
|
|
202
|
+
>
|
|
203
|
+
{children}
|
|
204
|
+
</nav>
|
|
205
|
+
</NavigationMenuProvider>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Runtime-dead: the compound alias below (the same object via Object.assign)
|
|
210
|
+
// overwrites this to "NavigationMenu" at load, so the value is never observable.
|
|
211
|
+
// The assignment stays because it declares `displayName` on
|
|
212
|
+
// `typeof NavigationMenuRoot`, which TNavigationMenuCompound extends.
|
|
213
|
+
// Stryker disable next-line StringLiteral: overwritten by the compound alias — an equivalent mutant.
|
|
214
|
+
NavigationMenuRoot.displayName = "NavigationMenuRoot";
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The list of top-level entries — renders a `<ul>`.
|
|
218
|
+
*
|
|
219
|
+
* A plain list, not a `role="menubar"`: these are links to pages, so the
|
|
220
|
+
* correct semantics are list-and-link, and screen-reader users get the
|
|
221
|
+
* "navigation, list of N items" announcement they expect rather than
|
|
222
|
+
* application-menu semantics that promise keyboard behaviour a nav doesn't
|
|
223
|
+
* have.
|
|
224
|
+
*
|
|
225
|
+
* **Styling hooks.** `data-orientation="horizontal" | "vertical"`, inherited
|
|
226
|
+
* from {@link NavigationMenuRoot | `NavigationMenu.Root`}.
|
|
227
|
+
*
|
|
228
|
+
* @extends HTMLUListElement
|
|
229
|
+
*
|
|
230
|
+
* @example
|
|
231
|
+
* ```tsx
|
|
232
|
+
* <NavigationMenu.List>
|
|
233
|
+
* <NavigationMenu.Item value="concepts">...</NavigationMenu.Item>
|
|
234
|
+
* </NavigationMenu.List>
|
|
235
|
+
* ```
|
|
236
|
+
*/
|
|
237
|
+
export function NavigationMenuList({
|
|
238
|
+
children,
|
|
239
|
+
...rest
|
|
240
|
+
}: NavigationMenuListProps): ReactElement {
|
|
241
|
+
const { orientation } = useNavigationMenuContext();
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<ul data-orientation={orientation} {...rest}>
|
|
245
|
+
{children}
|
|
246
|
+
</ul>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** @internal */
|
|
251
|
+
NavigationMenuList.displayName = "NavigationMenuList";
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* One top-level entry — renders an `<li>` and publishes its
|
|
255
|
+
* {@link NavigationMenuItemProps.value | `value`} to its
|
|
256
|
+
* {@link NavigationMenuTrigger | `Trigger`} and
|
|
257
|
+
* {@link NavigationMenuContent | `Content`}.
|
|
258
|
+
*
|
|
259
|
+
* **`value` is what makes an entry a disclosure.** Give it a value and the
|
|
260
|
+
* entry can hold a `Trigger` + `Content` pair. Omit it and the entry is a plain
|
|
261
|
+
* link — the shape the wireframe's *Changelog* row takes. A `Trigger` or
|
|
262
|
+
* `Content` inside a value-less Item throws, because it would derive an id that
|
|
263
|
+
* could never be opened.
|
|
264
|
+
*
|
|
265
|
+
* @extends HTMLLIElement
|
|
266
|
+
*
|
|
267
|
+
* @example A panelled entry
|
|
268
|
+
* ```tsx
|
|
269
|
+
* <NavigationMenu.Item value="concepts">
|
|
270
|
+
* <NavigationMenu.Trigger>Concepts</NavigationMenu.Trigger>
|
|
271
|
+
* <NavigationMenu.Content>...</NavigationMenu.Content>
|
|
272
|
+
* </NavigationMenu.Item>
|
|
273
|
+
* ```
|
|
274
|
+
*
|
|
275
|
+
* @example A plain link entry — no value
|
|
276
|
+
* ```tsx
|
|
277
|
+
* <NavigationMenu.Item>
|
|
278
|
+
* <NavigationMenu.Link href="/changelog">Changelog</NavigationMenu.Link>
|
|
279
|
+
* </NavigationMenu.Item>
|
|
280
|
+
* ```
|
|
281
|
+
*/
|
|
282
|
+
export function NavigationMenuItem({
|
|
283
|
+
children,
|
|
284
|
+
value,
|
|
285
|
+
...rest
|
|
286
|
+
}: NavigationMenuItemProps): ReactElement {
|
|
287
|
+
const { registerItem } = useNavigationMenuContext();
|
|
288
|
+
const itemRef = useRef<HTMLLIElement | null>(null);
|
|
289
|
+
const itemContextValue = useMemo<NavigationMenuItemContextValue>(
|
|
290
|
+
() => ({ value }),
|
|
291
|
+
[value],
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
// Only a disclosure entry joins the value-keyed registry: a plain link entry has
|
|
295
|
+
// no value, and nothing derives a direction from it.
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
// The guard is here to narrow the type — registering `undefined` would also be
|
|
298
|
+
// harmless, since the only consumer compares *positions* of real values and a
|
|
299
|
+
// key nothing can match never changes their relative order.
|
|
300
|
+
// Stryker disable next-line ConditionalExpression: equivalent — see above.
|
|
301
|
+
if (value === undefined) return;
|
|
302
|
+
registerItem(value, itemRef.current);
|
|
303
|
+
// Unregistering can't be observed through the published order: a replaced key
|
|
304
|
+
// still points at the same <li>, so a leaked one sorts to the same position as
|
|
305
|
+
// its replacement. It is here to stop the registry growing without bound.
|
|
306
|
+
// Stryker disable next-line ArrowFunction: equivalent — see above.
|
|
307
|
+
return () => registerItem(value, null);
|
|
308
|
+
}, [value, registerItem]);
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<NavigationMenuItemProvider value={itemContextValue}>
|
|
312
|
+
<li ref={itemRef} {...rest}>
|
|
313
|
+
{children}
|
|
314
|
+
</li>
|
|
315
|
+
</NavigationMenuItemProvider>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** @internal */
|
|
320
|
+
NavigationMenuItem.displayName = "NavigationMenuItem";
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The control that opens an entry's panel — renders a `<button>` with
|
|
324
|
+
* `aria-expanded`, `aria-controls`, and the hover-intent and keyboard handlers
|
|
325
|
+
* wired up.
|
|
326
|
+
*
|
|
327
|
+
* The ids used for `aria-controls` and the matching `Content`'s
|
|
328
|
+
* `aria-labelledby` are derived from the root's `useId()` plus the Item's
|
|
329
|
+
* `value`, so they are stable and unique even with several navs on a page.
|
|
330
|
+
*
|
|
331
|
+
* **Keyboard support** (ARIA APG *Disclosure Navigation Menu*):
|
|
332
|
+
*
|
|
333
|
+
* | Key | Behaviour |
|
|
334
|
+
* | --- | --- |
|
|
335
|
+
* | `Enter` / `Space` | Toggle this entry's panel |
|
|
336
|
+
* | `ArrowRight` / `ArrowLeft` | Move between top-level entries (horizontal), mirrored under RTL |
|
|
337
|
+
* | `ArrowUp` / `ArrowDown` | Move between top-level entries (vertical) |
|
|
338
|
+
* | `Home` / `End` | Jump to the first / last top-level entry |
|
|
339
|
+
* | `ArrowDown` (horizontal), `ArrowRight`/`ArrowLeft` (vertical) | Open the panel and move focus to its first link |
|
|
340
|
+
* | `Escape` | Close and return focus here |
|
|
341
|
+
*
|
|
342
|
+
* Movement **wraps** at the ends. Every top-level entry stays in the tab order
|
|
343
|
+
* — this is deliberately **not** a roving tabindex, because a keyboard user
|
|
344
|
+
* must be able to Tab through page links without discovering that arrows are
|
|
345
|
+
* required.
|
|
346
|
+
*
|
|
347
|
+
* **Clicking is not the same as hovering.** A pointer that arrives to click
|
|
348
|
+
* fires `pointerenter` first, which with hover-to-open opens the panel before
|
|
349
|
+
* the click lands. The trigger therefore toggles against what was open when the
|
|
350
|
+
* pointer *arrived*, so a click never undoes the user's own hover.
|
|
351
|
+
*
|
|
352
|
+
* **`asChild` prop.** Pass `asChild` to render an arbitrary element instead of
|
|
353
|
+
* the `<button>`; all ARIA attributes, handlers, and the internal registry ref
|
|
354
|
+
* are merged onto the child following the {@link Slot} composition rules
|
|
355
|
+
* (handlers compose, `style` shallow-merges with the child winning, `className`
|
|
356
|
+
* concatenates, refs compose). The child must be a single element accepting a
|
|
357
|
+
* `ref`.
|
|
358
|
+
*
|
|
359
|
+
* **Styling hooks.** `data-state="open" | "closed"`. Also publishes its own
|
|
360
|
+
* `anchor-name` (a plain inline style, not a data attribute) so the shared
|
|
361
|
+
* {@link NavigationMenuViewport | `Viewport`} can be CSS-anchor-positioned to
|
|
362
|
+
* whichever trigger opened it — no consumer wiring required.
|
|
363
|
+
*
|
|
364
|
+
* @extends HTMLButtonElement
|
|
365
|
+
*
|
|
366
|
+
* @example
|
|
367
|
+
* ```tsx
|
|
368
|
+
* <NavigationMenu.Trigger>Concepts</NavigationMenu.Trigger>
|
|
369
|
+
* ```
|
|
370
|
+
*
|
|
371
|
+
* @example Label plus a chevron the stylesheet rotates on open
|
|
372
|
+
* ```tsx
|
|
373
|
+
* <NavigationMenu.Trigger>
|
|
374
|
+
* <span>Concepts</span>
|
|
375
|
+
* <ChevronDownIcon aria-hidden />
|
|
376
|
+
* </NavigationMenu.Trigger>
|
|
377
|
+
* ```
|
|
378
|
+
*/
|
|
379
|
+
export function NavigationMenuTrigger<
|
|
380
|
+
T extends HTMLElement = HTMLButtonElement,
|
|
381
|
+
>({
|
|
382
|
+
ref: externalRef,
|
|
383
|
+
children,
|
|
384
|
+
asChild = false,
|
|
385
|
+
onClick,
|
|
386
|
+
onPointerEnter,
|
|
387
|
+
onPointerLeave,
|
|
388
|
+
onKeyDown,
|
|
389
|
+
...rest
|
|
390
|
+
}: NavigationMenuTriggerProps<T>): ReactElement {
|
|
391
|
+
const {
|
|
392
|
+
triggerRef,
|
|
393
|
+
triggerId,
|
|
394
|
+
panelId,
|
|
395
|
+
open,
|
|
396
|
+
state,
|
|
397
|
+
anchorName,
|
|
398
|
+
handleClick,
|
|
399
|
+
handlePointerEnter,
|
|
400
|
+
handlePointerLeave,
|
|
401
|
+
handleKeyDown,
|
|
402
|
+
} = useNavigationMenuTrigger({
|
|
403
|
+
onClick,
|
|
404
|
+
onPointerEnter,
|
|
405
|
+
onPointerLeave,
|
|
406
|
+
onKeyDown,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// The external ref is cast to the internal ref's element type: React's
|
|
410
|
+
// RefObject is invariant, but at runtime the callback receives whichever DOM
|
|
411
|
+
// element actually rendered — the button, or the asChild child.
|
|
412
|
+
//
|
|
413
|
+
// `composeRefs` accepts `undefined`, so always composing would reach the same
|
|
414
|
+
// ref target; the ternary is here to avoid handing React a fresh callback-ref
|
|
415
|
+
// identity on every render, which would detach and re-attach the node each
|
|
416
|
+
// time. That churn settles within the same commit, so no assertion can see it.
|
|
417
|
+
// Stryker disable next-line ConditionalExpression: the always-compose variant is equivalent — same ref target, only unobservable re-attach churn.
|
|
418
|
+
const composedRef = externalRef
|
|
419
|
+
? composeRefs(triggerRef, externalRef as Ref<HTMLButtonElement>)
|
|
420
|
+
: triggerRef;
|
|
421
|
+
|
|
422
|
+
const triggerProps = {
|
|
423
|
+
ref: composedRef,
|
|
424
|
+
id: triggerId,
|
|
425
|
+
"aria-expanded": open,
|
|
426
|
+
"aria-controls": panelId,
|
|
427
|
+
"data-state": state,
|
|
428
|
+
onClick: handleClick,
|
|
429
|
+
onPointerEnter: handlePointerEnter,
|
|
430
|
+
onPointerLeave: handlePointerLeave,
|
|
431
|
+
onKeyDown: handleKeyDown,
|
|
432
|
+
...rest,
|
|
433
|
+
// Placed after `...rest` so it isn't clobbered by a bare consumer `style` —
|
|
434
|
+
// a consumer's own anchor-name (if any) still wins inside this merge.
|
|
435
|
+
style: { anchorName, ...rest.style },
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
if (asChild) {
|
|
439
|
+
return <Slot {...triggerProps}>{children}</Slot>;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return (
|
|
443
|
+
<button type="button" {...triggerProps}>
|
|
444
|
+
{children}
|
|
445
|
+
</button>
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** @internal */
|
|
450
|
+
NavigationMenuTrigger.displayName = "NavigationMenuTrigger";
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* An entry's panel — renders a `<div>` linked to its trigger by
|
|
454
|
+
* `aria-labelledby`, hidden with the native `hidden` attribute while closed.
|
|
455
|
+
*
|
|
456
|
+
* Inactive panels stay **mounted**, which preserves their state (scroll
|
|
457
|
+
* position, form input) across opens. Render the `Content` conditionally
|
|
458
|
+
* yourself if you need true unmount semantics.
|
|
459
|
+
*
|
|
460
|
+
* **Projection.** When a {@link NavigationMenuViewport | `Viewport`} is present
|
|
461
|
+
* anywhere in the Root, the panel is portalled into it instead of rendering
|
|
462
|
+
* where it was authored — that is what lets one open panel morph into the next
|
|
463
|
+
* rather than each entry growing its own box. Without a Viewport it renders in
|
|
464
|
+
* place. Authoring is identical either way: always nest the `Content` inside its
|
|
465
|
+
* `Item`, next to its `Trigger`.
|
|
466
|
+
*
|
|
467
|
+
* **Panel links are not top-level entries.** A
|
|
468
|
+
* {@link NavigationMenuLink | `Link`} inside a `Content` is reached by Tab and
|
|
469
|
+
* is deliberately excluded from the top-level arrow-key order, so `Home`/`End`
|
|
470
|
+
* inside a panel don't jump out of it.
|
|
471
|
+
*
|
|
472
|
+
* **Styling hooks.** `data-state="open" | "closed"`.
|
|
473
|
+
*
|
|
474
|
+
* @extends HTMLDivElement
|
|
475
|
+
*
|
|
476
|
+
* @example
|
|
477
|
+
* ```tsx
|
|
478
|
+
* <NavigationMenu.Content>
|
|
479
|
+
* <NavigationMenu.Link href="/tokens">Tokens</NavigationMenu.Link>
|
|
480
|
+
* <NavigationMenu.Link href="/themes">Themes</NavigationMenu.Link>
|
|
481
|
+
* </NavigationMenu.Content>
|
|
482
|
+
* ```
|
|
483
|
+
*
|
|
484
|
+
* @example forceMount, for a CSS-animated panel
|
|
485
|
+
* ```tsx
|
|
486
|
+
* <NavigationMenu.Content forceMount className="panel">...</NavigationMenu.Content>
|
|
487
|
+
* ```
|
|
488
|
+
* ```css
|
|
489
|
+
* .panel { opacity: 0; transition: opacity 150ms; }
|
|
490
|
+
* .panel[data-state="open"] { opacity: 1; }
|
|
491
|
+
* ```
|
|
492
|
+
*
|
|
493
|
+
* **`data-motion`.** When the open entry changes, the entering and leaving panels
|
|
494
|
+
* report the direction of travel — `from-start` / `from-end` / `to-start` /
|
|
495
|
+
* `to-end` — so a stylesheet can slide them rather than cross-fading every
|
|
496
|
+
* switch. Absent for the first open and the full close, which have no direction.
|
|
497
|
+
*/
|
|
498
|
+
export function NavigationMenuContent({
|
|
499
|
+
children,
|
|
500
|
+
forceMount = false,
|
|
501
|
+
...rest
|
|
502
|
+
}: NavigationMenuContentProps): ReactElement | ReactPortal {
|
|
503
|
+
const { viewport, openValue, previousValue, itemValues } =
|
|
504
|
+
useNavigationMenuContext();
|
|
505
|
+
const { triggerId, panelId, open, state, value } = useNavigationMenuEntry();
|
|
506
|
+
// Which way this panel is travelling, so a stylesheet can slide it in the
|
|
507
|
+
// direction of the pointer rather than cross-fading every switch. Absent for
|
|
508
|
+
// the first open and the full close, which have no direction.
|
|
509
|
+
const motion = getPanelMotion({
|
|
510
|
+
value,
|
|
511
|
+
openValue,
|
|
512
|
+
previousValue,
|
|
513
|
+
itemValues,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const panel = (
|
|
517
|
+
<NavigationMenuPanelProvider value={true}>
|
|
518
|
+
<div
|
|
519
|
+
id={panelId}
|
|
520
|
+
aria-labelledby={triggerId}
|
|
521
|
+
data-state={state}
|
|
522
|
+
data-motion={motion}
|
|
523
|
+
hidden={forceMount ? undefined : !open}
|
|
524
|
+
aria-hidden={forceMount && !open ? true : undefined}
|
|
525
|
+
{...rest}
|
|
526
|
+
>
|
|
527
|
+
{children}
|
|
528
|
+
</div>
|
|
529
|
+
</NavigationMenuPanelProvider>
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
// With a Viewport every panel lives in that one box, which is what lets the
|
|
533
|
+
// open panel morph into the next instead of each entry growing its own.
|
|
534
|
+
// Without one the panel simply stays where it was authored.
|
|
535
|
+
return viewport ? createPortal(panel, viewport) : panel;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** @internal */
|
|
539
|
+
NavigationMenuContent.displayName = "NavigationMenuContent";
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Publishes the open panel's measured size as
|
|
543
|
+
* `--primitiv-navigation-menu-viewport-width` / `-height`, so a stylesheet can
|
|
544
|
+
* transition the shared box between panels. The measurement is kept through the
|
|
545
|
+
* close — clearing it would collapse the box just as the exit needs its size.
|
|
546
|
+
*
|
|
547
|
+
* The single shared box every {@link NavigationMenuContent | `Content`} renders
|
|
548
|
+
* into — renders a `<div>`, hidden while nothing is open.
|
|
549
|
+
*
|
|
550
|
+
* Optional. Mount one when you want the desktop "one panel morphs into the
|
|
551
|
+
* next" behaviour: because all panels live in this element, a CSS transition on
|
|
552
|
+
* its size animates between them instead of each entry expanding separately.
|
|
553
|
+
* Omit it and panels render in place, which is the simpler layout.
|
|
554
|
+
*
|
|
555
|
+
* Place it as a sibling of {@link NavigationMenuList | `List`}, inside the
|
|
556
|
+
* Root. It has no children of its own to author — panels arrive by portal,
|
|
557
|
+
* projected into an internal element it renders for exactly that.
|
|
558
|
+
*
|
|
559
|
+
* **Styling hooks.** `data-state="open" | "closed"`,
|
|
560
|
+
* `data-orientation="horizontal" | "vertical"`, and `data-value` carrying the
|
|
561
|
+
* open entry's value, so a stylesheet can size or theme the box per panel
|
|
562
|
+
* without the consumer threading state back in.
|
|
563
|
+
*
|
|
564
|
+
* @extends HTMLDivElement
|
|
565
|
+
*
|
|
566
|
+
* @example
|
|
567
|
+
* ```tsx
|
|
568
|
+
* <NavigationMenu.Root>
|
|
569
|
+
* <NavigationMenu.List>...</NavigationMenu.List>
|
|
570
|
+
* <NavigationMenu.Viewport className="viewport" />
|
|
571
|
+
* </NavigationMenu.Root>
|
|
572
|
+
* ```
|
|
573
|
+
*
|
|
574
|
+
* @example Sizing the shared box per panel
|
|
575
|
+
* ```css
|
|
576
|
+
* .viewport { transition: height 200ms; }
|
|
577
|
+
* .viewport[data-value="registry"] { height: 12rem; }
|
|
578
|
+
* ```
|
|
579
|
+
*/
|
|
580
|
+
export function NavigationMenuViewport({
|
|
581
|
+
forceMount = false,
|
|
582
|
+
...rest
|
|
583
|
+
}: NavigationMenuViewportProps): ReactElement {
|
|
584
|
+
const { registerViewport } = useNavigationMenuContext();
|
|
585
|
+
const { open, openValue, orientation, style } = useNavigationMenuViewport();
|
|
586
|
+
|
|
587
|
+
return (
|
|
588
|
+
<div
|
|
589
|
+
data-orientation={orientation}
|
|
590
|
+
data-state={open ? "open" : "closed"}
|
|
591
|
+
// The open entry's value, so a stylesheet can size or theme the shared
|
|
592
|
+
// box per panel without the consumer threading state back in.
|
|
593
|
+
data-value={openValue || undefined}
|
|
594
|
+
hidden={forceMount ? undefined : !open}
|
|
595
|
+
// The measured size of the open panel, so the shared box can transition
|
|
596
|
+
// between panels. A consumer `style` merges over the top.
|
|
597
|
+
style={{ ...style, ...rest.style }}
|
|
598
|
+
{...rest}
|
|
599
|
+
>
|
|
600
|
+
{/* The portal target, split from the outer box above: the outer one
|
|
601
|
+
hosts the hover-forgiveness collar (a ::before that must extend past
|
|
602
|
+
its own edges to widen the hit area — see the registry stylesheet),
|
|
603
|
+
which an overflow clip on that same box would cut off. This inner
|
|
604
|
+
element is what actually clips a sliding panel to the visible box,
|
|
605
|
+
and is what Content portals into — `viewport` in context is this
|
|
606
|
+
node, not the outer one. className is hardcoded (the same pattern
|
|
607
|
+
Carousel's own internal track div uses): it names an implementation
|
|
608
|
+
detail no consumer ever targets via a prop, not a themable part. */}
|
|
609
|
+
<div
|
|
610
|
+
ref={registerViewport}
|
|
611
|
+
className="primitiv-navigation-menu__viewport-clip"
|
|
612
|
+
data-navigation-menu-viewport-clip=""
|
|
613
|
+
/>
|
|
614
|
+
</div>
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** @internal */
|
|
619
|
+
NavigationMenuViewport.displayName = "NavigationMenuViewport";
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* The marker that tracks the open trigger — an underline, arrow, or highlight —
|
|
623
|
+
* rendered as a `<div>`, hidden while nothing is open.
|
|
624
|
+
*
|
|
625
|
+
* Optional, and purely a measurement carrier: because no styles ship with the
|
|
626
|
+
* library, the Indicator cannot position itself, but it is the only part that
|
|
627
|
+
* can know *where* the open trigger is. It measures that trigger and publishes
|
|
628
|
+
* the result as two inline custom properties, leaving the stylesheet to decide
|
|
629
|
+
* whether tracking means a `translate`, a `left`, or a border:
|
|
630
|
+
*
|
|
631
|
+
* - `--primitiv-navigation-menu-indicator-position` — the trigger's
|
|
632
|
+
* `offsetLeft` when horizontal, `offsetTop` when vertical.
|
|
633
|
+
* - `--primitiv-navigation-menu-indicator-size` — its `offsetWidth` when
|
|
634
|
+
* horizontal, `offsetHeight` when vertical.
|
|
635
|
+
*
|
|
636
|
+
* Both are re-measured whenever the open entry changes and on window `resize`,
|
|
637
|
+
* so the marker doesn't drift off its trigger after the nav rewraps. When the
|
|
638
|
+
* open value names an entry with no rendered trigger, neither property is set —
|
|
639
|
+
* better an unpositioned marker than one parked at `0`.
|
|
640
|
+
*
|
|
641
|
+
* **`asChild` prop.** Pass `asChild` to make the marker an element of your own
|
|
642
|
+
* — an `<svg>` arrow, an icon component — instead of a styled `<div>`. The
|
|
643
|
+
* `data-*` hooks and the geometry custom properties are merged onto the child
|
|
644
|
+
* via {@link Slot}, so an icon tracks the trigger exactly as the default box
|
|
645
|
+
* does.
|
|
646
|
+
*
|
|
647
|
+
* **Styling hooks.** `data-state="open" | "closed"`,
|
|
648
|
+
* `data-orientation="horizontal" | "vertical"`, `data-value`.
|
|
649
|
+
*
|
|
650
|
+
* @extends HTMLDivElement
|
|
651
|
+
*
|
|
652
|
+
* @example
|
|
653
|
+
* ```tsx
|
|
654
|
+
* <NavigationMenu.Indicator className="indicator" />
|
|
655
|
+
* ```
|
|
656
|
+
*
|
|
657
|
+
* @example asChild — an arrow icon rather than a styled box
|
|
658
|
+
* ```tsx
|
|
659
|
+
* <NavigationMenu.Indicator asChild>
|
|
660
|
+
* <ChevronUpIcon aria-hidden />
|
|
661
|
+
* </NavigationMenu.Indicator>
|
|
662
|
+
* ```
|
|
663
|
+
* ```css
|
|
664
|
+
* .indicator {
|
|
665
|
+
* position: absolute;
|
|
666
|
+
* inset-block-end: 0;
|
|
667
|
+
* block-size: 2px;
|
|
668
|
+
* inline-size: var(--primitiv-navigation-menu-indicator-size);
|
|
669
|
+
* translate: var(--primitiv-navigation-menu-indicator-position) 0;
|
|
670
|
+
* transition: translate 200ms, inline-size 200ms;
|
|
671
|
+
* }
|
|
672
|
+
* ```
|
|
673
|
+
*/
|
|
674
|
+
export function NavigationMenuIndicator({
|
|
675
|
+
children,
|
|
676
|
+
forceMount = false,
|
|
677
|
+
asChild = false,
|
|
678
|
+
style,
|
|
679
|
+
...rest
|
|
680
|
+
}: NavigationMenuIndicatorProps): ReactElement {
|
|
681
|
+
const {
|
|
682
|
+
open,
|
|
683
|
+
openValue,
|
|
684
|
+
orientation,
|
|
685
|
+
style: geometryStyle,
|
|
686
|
+
} = useNavigationMenuIndicator();
|
|
687
|
+
|
|
688
|
+
const indicatorProps = {
|
|
689
|
+
"data-orientation": orientation,
|
|
690
|
+
"data-state": open ? ("open" as const) : ("closed" as const),
|
|
691
|
+
"data-value": openValue || undefined,
|
|
692
|
+
hidden: forceMount ? undefined : !open,
|
|
693
|
+
// Consumer style last: the geometry is ours to publish, but everything
|
|
694
|
+
// else about the marker belongs to the stylesheet.
|
|
695
|
+
style: { ...geometryStyle, ...style },
|
|
696
|
+
...rest,
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
if (asChild) {
|
|
700
|
+
return <Slot {...indicatorProps}>{children}</Slot>;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return <div {...indicatorProps}>{children}</div>;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** @internal */
|
|
707
|
+
NavigationMenuIndicator.displayName = "NavigationMenuIndicator";
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* A link to a page — renders an `<a>`.
|
|
711
|
+
*
|
|
712
|
+
* The one part **shared by both presentations** of the nav. On desktop it is
|
|
713
|
+
* used inside a {@link NavigationMenuContent | `Content`} panel and as a
|
|
714
|
+
* top-level {@link NavigationMenuItem | `Item`}'s only child; in the composed
|
|
715
|
+
* mobile nav (a `Drawer` + `Collapsible` per section) it is imported directly,
|
|
716
|
+
* so active-state logic is written once rather than per presentation.
|
|
717
|
+
*
|
|
718
|
+
* **Active state.** {@link NavigationMenuLinkProps.active | `active`} sets
|
|
719
|
+
* `aria-current="page"` and the `data-active` styling hook. No route matching
|
|
720
|
+
* happens here — the consumer owns the router, so the consumer owns the
|
|
721
|
+
* comparison.
|
|
722
|
+
*
|
|
723
|
+
* **Clicking closes the menu**, whether the link sits in a panel or beside one.
|
|
724
|
+
* Leaving a panel hanging open over the page the user just navigated to is the
|
|
725
|
+
* bug this avoids. Consumers can veto it by calling `event.preventDefault()` in
|
|
726
|
+
* their own `onClick`.
|
|
727
|
+
*
|
|
728
|
+
* **Position decides keyboard reachability.** Directly inside an `Item`, the
|
|
729
|
+
* link is a top-level entry and joins the arrow-key travel order. Inside a
|
|
730
|
+
* `Content`, it is reached by Tab and stays out of that order.
|
|
731
|
+
*
|
|
732
|
+
* **`asChild` prop.** Pass `asChild` to render a routing library's link
|
|
733
|
+
* component instead of the native `<a>`, with `aria-current`, `data-active`, the
|
|
734
|
+
* dismissing click handler and the ref merged onto it via {@link Slot}.
|
|
735
|
+
*
|
|
736
|
+
* **Styling hooks.** `data-active=""` when active (omitted otherwise), so CSS
|
|
737
|
+
* can target `[data-active]`.
|
|
738
|
+
*
|
|
739
|
+
* @extends HTMLAnchorElement
|
|
740
|
+
*
|
|
741
|
+
* @example
|
|
742
|
+
* ```tsx
|
|
743
|
+
* <NavigationMenu.Link href="/tokens" active={pathname === "/tokens"}>
|
|
744
|
+
* Tokens
|
|
745
|
+
* </NavigationMenu.Link>
|
|
746
|
+
* ```
|
|
747
|
+
*
|
|
748
|
+
* @example With a router link
|
|
749
|
+
* ```tsx
|
|
750
|
+
* <NavigationMenu.Link asChild active={isActive}>
|
|
751
|
+
* <RouterLink to="/tokens">Tokens</RouterLink>
|
|
752
|
+
* </NavigationMenu.Link>
|
|
753
|
+
* ```
|
|
754
|
+
*/
|
|
755
|
+
export function NavigationMenuLink({
|
|
756
|
+
children,
|
|
757
|
+
active = false,
|
|
758
|
+
asChild = false,
|
|
759
|
+
onKeyDown,
|
|
760
|
+
onClick,
|
|
761
|
+
onPointerEnter,
|
|
762
|
+
...rest
|
|
763
|
+
}: NavigationMenuLinkProps): ReactElement {
|
|
764
|
+
const { linkRef, handleKeyDown, handleClick, handlePointerEnter } =
|
|
765
|
+
useNavigationMenuLink({
|
|
766
|
+
onKeyDown,
|
|
767
|
+
onClick,
|
|
768
|
+
onPointerEnter,
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
const linkProps = {
|
|
772
|
+
ref: linkRef,
|
|
773
|
+
"aria-current": active ? ("page" as const) : undefined,
|
|
774
|
+
"data-active": active ? "" : undefined,
|
|
775
|
+
onKeyDown: handleKeyDown,
|
|
776
|
+
onClick: handleClick,
|
|
777
|
+
onPointerEnter: handlePointerEnter,
|
|
778
|
+
...rest,
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
if (asChild) {
|
|
782
|
+
return <Slot {...linkProps}>{children}</Slot>;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return <a {...linkProps}>{children}</a>;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** @internal */
|
|
789
|
+
NavigationMenuLink.displayName = "NavigationMenuLink";
|
|
790
|
+
|
|
791
|
+
/** Type of the {@link NavigationMenu} compound: the callable root plus its
|
|
792
|
+
* attached sub-components. */
|
|
793
|
+
export type TNavigationMenuCompound = typeof NavigationMenuRoot & {
|
|
794
|
+
Root: typeof NavigationMenuRoot;
|
|
795
|
+
List: typeof NavigationMenuList;
|
|
796
|
+
Item: typeof NavigationMenuItem;
|
|
797
|
+
Trigger: typeof NavigationMenuTrigger;
|
|
798
|
+
Content: typeof NavigationMenuContent;
|
|
799
|
+
Viewport: typeof NavigationMenuViewport;
|
|
800
|
+
Indicator: typeof NavigationMenuIndicator;
|
|
801
|
+
Link: typeof NavigationMenuLink;
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Headless, accessible **NavigationMenu** — the desktop dropdown site nav, built
|
|
806
|
+
* on the [ARIA APG Disclosure Navigation Menu
|
|
807
|
+
* pattern](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/):
|
|
808
|
+
* a `<nav>` landmark wrapping a list of top-level entries, each either a plain
|
|
809
|
+
* link or a trigger that discloses a panel. One panel is open at a time. Zero
|
|
810
|
+
* styles ship.
|
|
811
|
+
*
|
|
812
|
+
* `NavigationMenu` is both callable (an alias of
|
|
813
|
+
* {@link NavigationMenuRoot | `NavigationMenu.Root`}) and carries its
|
|
814
|
+
* sub-components as static properties. Prefer the namespaced form in application
|
|
815
|
+
* code for readability and grep-ability:
|
|
816
|
+
*
|
|
817
|
+
* - {@link NavigationMenuRoot | `NavigationMenu.Root`} — `<nav>` landmark, state owner, context provider.
|
|
818
|
+
* - {@link NavigationMenuList | `NavigationMenu.List`} — `<ul>` of top-level entries.
|
|
819
|
+
* - {@link NavigationMenuItem | `NavigationMenu.Item`} — `<li>`; its `value` makes the entry a disclosure.
|
|
820
|
+
* - {@link NavigationMenuTrigger | `NavigationMenu.Trigger`} — `<button>` that opens a panel.
|
|
821
|
+
* - {@link NavigationMenuContent | `NavigationMenu.Content`} — the panel, projected into a `Viewport` when one exists.
|
|
822
|
+
* - {@link NavigationMenuViewport | `NavigationMenu.Viewport`} — *optional* shared host so panels morph in one box.
|
|
823
|
+
* - {@link NavigationMenuIndicator | `NavigationMenu.Indicator`} — *optional* marker tracking the open trigger.
|
|
824
|
+
* - {@link NavigationMenuLink | `NavigationMenu.Link`} — `<a>` with `aria-current`; shared with the mobile nav.
|
|
825
|
+
*
|
|
826
|
+
* **Scope: this is the desktop presentation.** The mobile nav is deliberately
|
|
827
|
+
* *not* a mode of this component — its interaction model differs (several
|
|
828
|
+
* sections expanded in place, rather than one panel at a time), so it is built
|
|
829
|
+
* as a composition of `Drawer` + `Collapsible` reusing this component's
|
|
830
|
+
* `NavigationMenu.Link`. Both presentations map over the same nav data, which
|
|
831
|
+
* stays the consumer's. See RFC 0019 §3–4a for the reasoning.
|
|
832
|
+
*
|
|
833
|
+
* @example Minimal usage
|
|
834
|
+
* ```tsx
|
|
835
|
+
* import { NavigationMenu } from "@primitiv-ui/react";
|
|
836
|
+
*
|
|
837
|
+
* export function SiteNav() {
|
|
838
|
+
* return (
|
|
839
|
+
* <NavigationMenu.Root>
|
|
840
|
+
* <NavigationMenu.List>
|
|
841
|
+
* <NavigationMenu.Item value="concepts">
|
|
842
|
+
* <NavigationMenu.Trigger>Concepts</NavigationMenu.Trigger>
|
|
843
|
+
* <NavigationMenu.Content>
|
|
844
|
+
* <NavigationMenu.Link href="/tokens">Tokens</NavigationMenu.Link>
|
|
845
|
+
* </NavigationMenu.Content>
|
|
846
|
+
* </NavigationMenu.Item>
|
|
847
|
+
* <NavigationMenu.Item>
|
|
848
|
+
* <NavigationMenu.Link href="/changelog">Changelog</NavigationMenu.Link>
|
|
849
|
+
* </NavigationMenu.Item>
|
|
850
|
+
* </NavigationMenu.List>
|
|
851
|
+
* </NavigationMenu.Root>
|
|
852
|
+
* );
|
|
853
|
+
* }
|
|
854
|
+
* ```
|
|
855
|
+
*
|
|
856
|
+
* @example Styling with any system
|
|
857
|
+
* Because no styles ship, target the rendered elements or the `data-*` hooks
|
|
858
|
+
* with whatever system you use:
|
|
859
|
+
*
|
|
860
|
+
* ```css
|
|
861
|
+
* nav [data-state="closed"] { display: none; }
|
|
862
|
+
* nav button[data-state="open"] > svg { rotate: 180deg; }
|
|
863
|
+
* nav a[data-active] { font-weight: 600; }
|
|
864
|
+
* ```
|
|
865
|
+
*
|
|
866
|
+
* @see {@link NavigationMenuRoot} for the state modes, hover-intent timings, and Escape handling.
|
|
867
|
+
* @see {@link NavigationMenuItem} for why `value` is what makes an entry a disclosure.
|
|
868
|
+
* @see {@link NavigationMenuTrigger} for the full keyboard-interaction table.
|
|
869
|
+
* @see {@link NavigationMenuViewport} for the shared-panel morph.
|
|
870
|
+
* @see {@link NavigationMenuIndicator} for the geometry custom properties.
|
|
871
|
+
* @see {@link NavigationMenuLink} for the active-state contract shared with the mobile nav.
|
|
872
|
+
*/
|
|
873
|
+
const NavigationMenuCompound: TNavigationMenuCompound = Object.assign(
|
|
874
|
+
NavigationMenuRoot,
|
|
875
|
+
{
|
|
876
|
+
Root: NavigationMenuRoot,
|
|
877
|
+
List: NavigationMenuList,
|
|
878
|
+
Item: NavigationMenuItem,
|
|
879
|
+
Trigger: NavigationMenuTrigger,
|
|
880
|
+
Content: NavigationMenuContent,
|
|
881
|
+
Viewport: NavigationMenuViewport,
|
|
882
|
+
Indicator: NavigationMenuIndicator,
|
|
883
|
+
Link: NavigationMenuLink,
|
|
884
|
+
},
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
/** @internal */
|
|
888
|
+
NavigationMenuCompound.displayName = "NavigationMenu";
|
|
889
|
+
|
|
890
|
+
export { NavigationMenuCompound as NavigationMenu };
|