@primitiv-ui/react 0.1.27 → 0.1.29
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 +4 -0
- 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 +25 -4
- 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 +136 -18
- 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/types.ts +21 -0
- package/src/Carousel/Carousel.tsx +263 -15
- package/src/Carousel/README.md +749 -72
- 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 +88 -0
- 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 +457 -35
- package/src/Checkbox/Checkbox.tsx +9 -1
- 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 +13 -4
- 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 +40 -0
- 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/ContextMenu/ContextMenu.tsx +270 -4
- package/src/ContextMenu/types.ts +148 -6
- package/src/DirectionProvider/DirectionProvider.tsx +41 -9
- 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.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 +117 -0
- package/src/Dropdown/types.ts +241 -11
- package/src/EmptyState/EmptyState.tsx +74 -4
- 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/__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/types.ts +60 -12
- package/src/Fieldset/Fieldset.tsx +54 -18
- package/src/Fieldset/__tests__/Fieldset.basic-rendering.test.tsx +9 -0
- package/src/Fieldset/types.ts +20 -2
- package/src/Input/Input.tsx +14 -9
- 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/MillerColumns/MillerColumns.tsx +86 -19
- package/src/MillerColumns/types.ts +83 -27
- package/src/Modal/Modal.tsx +31 -4
- package/src/Modal/README.md +19 -10
- 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/__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/__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 +9 -1
- 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/RadioGroup.tsx +11 -0
- 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 +86 -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 +595 -68
- 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 +4 -4
- 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/SkipNav.tsx +69 -29
- package/src/SkipNav/__tests__/SkipNav.basic-rendering.test.tsx +6 -0
- package/src/SkipNav/types.ts +17 -9
- package/src/Slider/Slider.tsx +33 -1
- 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 +110 -0
- package/src/Slot/composeEventHandlers.ts +26 -2
- package/src/Slot/types.ts +16 -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/Switch.tsx +15 -2
- 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/Table.tsx +44 -10
- package/src/Table/types.ts +66 -17
- package/src/Tabs/Tabs.tsx +8 -0
- 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/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/Tooltip.tsx +98 -20
- package/src/Tooltip/hooks/__tests__/useTooltipProvider.test.tsx +40 -0
- package/src/Tooltip/hooks/useTooltipProvider.ts +13 -1
- package/src/Tooltip/types.ts +89 -8
- package/src/Tree/Tree.tsx +187 -33
- package/src/Tree/types.ts +54 -12
- 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/index.ts +4 -0
- package/src/test/intersectionObserverPolyfill.ts +7 -0
- package/src/test/resizeObserverPolyfill.ts +80 -0
- package/src/utils/focusable.ts +15 -0
- package/src/utils/index.ts +1 -0
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@ import { Tabs } from "@primitiv-ui/react";
|
|
|
31
31
|
| [ContextMenu](src/ContextMenu/README.md) | WAI-ARIA Menu pattern for right-click / long-press / context-key menus — native HTML `popover` compound component positioned at the cursor, with nested submenus, checkbox and radio items, `ItemIndicator` icon slot, groups and labels, keyboard navigation, typeahead, disabled-item skipping, `onSelect` escape hatch, and `asChild` composition |
|
|
32
32
|
| [DirectionProvider](src/DirectionProvider/README.md) | Reading-direction context — broadcasts `ltr`/`rtl` to descendants and exposes the `useDirection` hook so direction-aware components inherit `dir` instead of each being passed it explicitly; renders no DOM |
|
|
33
33
|
| [Divider](src/Divider/README.md) | WAI-ARIA separator role — horizontal or vertical content separator, with semantic and decorative use |
|
|
34
|
+
| [Drawer](src/Drawer/README.md) | Edge-anchored dialog — a thin composition over Modal that reuses its native `<dialog>` machinery (focus trap, inert background, top layer, Esc, bounding-rect click-outside, controlled/uncontrolled state, imperative API, `asChild`, `forceMount`), adding a `side` (`top`/`right`/`bottom`/`left`) axis emitted as `data-side` for the styled layer to slide the panel from that edge |
|
|
34
35
|
| [Dropdown](src/Dropdown/README.md) | WAI-ARIA Menu Button / Menu pattern — native HTML `popover` compound component with nested submenus, checkbox and radio items, `ItemIndicator` icon slot, groups and labels, keyboard navigation, typeahead, disabled-item skipping, `onSelect` escape hatch, and `asChild` composition |
|
|
35
36
|
| [EmptyState](src/EmptyState/README.md) | Placeholder for empty collections, searches, and views — stateless compound component: a `role="status"` live region announcing on appearance, an `aria-hidden` `Media` illustration slot, `Title` and `Description` copy, an `Actions` recovery slot, and `asChild` composition on every part |
|
|
36
37
|
| [Field](src/Field/README.md) | Headless form-field coordinator — provides `FieldContext` with auto-generated `id` (via `useId`) and derived `descriptionId` / `errorId`, plus `invalid` / `disabled` / `required` cascade; sub-components for `Label` (`htmlFor`-wired), `Description`, and `ErrorText` (auto-gated by invalid state, `role="alert"`); `Input` opts in automatically when nested |
|
|
@@ -39,11 +40,14 @@ import { Tabs } from "@primitiv-ui/react";
|
|
|
39
40
|
| [InputGroup](src/InputGroup/README.md) | Stateless frame for `Input` (or any framed control) with named leading and trailing adornment slots — decorative icons, currency symbols, clear buttons, password-reveal toggles; `data-input-group` / `data-input-group-adornment` styling hooks, CSS-driven disabled / invalid / focus-within state via `:has()`, and `asChild` composition on every part |
|
|
40
41
|
| [MillerColumns](src/MillerColumns/README.md) | Miller columns / cascading-lists pattern — a horizontal strip of `role="group"` columns of `role="treeitem"` items, authored by recursive composition with portal-projected child columns, controlled/uncontrolled active path, single-tabstop roving keyboard navigation, disabled items, and `asChild` composition |
|
|
41
42
|
| [Modal](src/Modal/README.md) | WAI-ARIA Modal Dialog pattern — native `<dialog>`-based compound component with Portal, bounding-rect click-outside, controlled/uncontrolled state, escape hatches, imperative API, `asChild`, and `forceMount` animation hooks |
|
|
43
|
+
| [NavigationMenu](src/NavigationMenu/README.md) | ARIA APG Disclosure Navigation Menu pattern — the desktop dropdown site nav: `<nav>` landmark + `<ul>` of entries, one panel open at a time (`""` means closed), hover-intent open with instant switching and a nav-wide close grace, axis/RTL-aware arrow travel that keeps every entry in the tab order (not a roving tabindex), cross-axis arrow to enter a panel, Escape-to-close with focus return, an optional `Viewport` panels portal into so the open one morphs into the next, an optional `Indicator` publishing the open trigger's measured geometry as custom properties, and a `Link` carrying `aria-current` that the composed mobile nav (`Drawer` + `Collapsible`) reuses |
|
|
44
|
+
| [Popover](src/Popover/README.md) | WAI-ARIA non-modal dialog — native HTML Popover API (`popover="auto"`) compound with trigger/anchor/content/close/title/description, controlled/uncontrolled state, light-dismiss sync, focus-in-on-open + Escape focus-return (no focus trap), CSS-anchor-positioning placement, and `asChild` composition |
|
|
42
45
|
| [Portal](src/Portal/README.md) | DOM-escape utility — renders children into `document.body` (or a consumer-supplied container) via `createPortal`; compose with any component that needs to break out of the current stacking context |
|
|
43
46
|
| [Progress](src/Progress/README.md) | WAI-ARIA progressbar pattern — display-only compound component reflecting a consumer-supplied `value`, with determinate/indeterminate states, configurable `max`, `getValueLabel` for `aria-valuetext`, `data-*` styling hooks on the fill `Indicator`, and `asChild` composition |
|
|
44
47
|
| [Radio](src/Radio/README.md) | Standalone — a real, visually-hidden native `<input type="radio">` in a `<label>` box, so siblings sharing a `name` form a native radio group (the browser enforces single-selection) and it participates in forms; controlled/uncontrolled state, `Indicator` dot, `disabled` with `data-disabled` styling hook, and `asChild` on the indicator (use `RadioGroup` for managed roving-tabindex sets) |
|
|
45
48
|
| [RadioCard](src/RadioCard/README.md) | WAI-ARIA Radio Group pattern in a card/tile layout — the entire card surface is the interactive element, with single-selection state, roving tabindex, arrow-key navigation, disabled-item skipping, `Indicator` slot, and `asChild` composition |
|
|
46
49
|
| [RadioGroup](src/RadioGroup/README.md) | WAI-ARIA Radio Group pattern — native `<button role="radio">` items with single-selection state, roving tabindex, arrow-key navigation, disabled-item skipping, `Indicator` slot, and `asChild` composition |
|
|
50
|
+
| [SegmentedControl](src/SegmentedControl/README.md) | Single-select value picker (WAI-ARIA Radio Group semantics) styled as a linear pill strip — native `<button role="radio">` segments with controlled/uncontrolled state, roving tabindex, arrow-key navigation, horizontal/vertical orientation, per-item **and** whole-control `disabled`, `data-state` / `data-orientation` / `data-disabled` styling hooks, and `asChild` composition (use `ToggleGroup` for on/off command toggles) |
|
|
47
51
|
| [Select](src/Select/README.md) | Headless wrapper over native `<select>` / `<option>` / `<optgroup>` — controlled/uncontrolled state via `onValueChange`, `Select.Placeholder` with auto-`defaultValue` inference, `Select.Group` for optgroup labelling, `data-disabled` styling hook, `asChild` composition, and Field-aware (opts into `FieldContext` for auto-wired `id` / `aria-describedby` / `aria-invalid` / `disabled` / `required`) |
|
|
48
52
|
| [SkipNav](src/SkipNav/README.md) | WCAG 2.4.1 Bypass Blocks technique — a stateless "skip to main content" link and its `tabIndex=-1` focus target, connected by a shared content id, working with no JavaScript at runtime |
|
|
49
53
|
| [Slider](src/Slider/README.md) | WAI-ARIA Slider & Multi-Thumb Slider pattern — an array-valued range input with any number of independently focusable thumbs, keyboard and pointer dragging, `min`/`max`/`step`, `minStepsBetweenThumbs`, horizontal/vertical orientation, RTL and `inverted` direction, `onValueCommit`, hidden inputs for form submission, `disabled` with `data-disabled` styling hook, and `asChild` composition |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@primitiv-ui/react",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.29",
|
|
4
4
|
"description": "Headless, accessible React components built on the WAI-ARIA authoring patterns. Zero styles ship with this package — bring your own (CSS, Tailwind, CSS-in-JS, design tokens).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
}
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
+
"@stryker-mutator/core": "9.6.1",
|
|
38
|
+
"@stryker-mutator/vitest-runner": "9.6.1",
|
|
37
39
|
"@testing-library/dom": "^10.4.1",
|
|
38
40
|
"@testing-library/jest-dom": "^6.9.1",
|
|
39
41
|
"@testing-library/react": "^16.3.2",
|
|
@@ -57,6 +59,9 @@
|
|
|
57
59
|
"qa": "pnpm qa:units",
|
|
58
60
|
"qa:units": "vitest run --coverage",
|
|
59
61
|
"qa:units:watch": "vitest --coverage",
|
|
62
|
+
"mutate": "stryker run",
|
|
63
|
+
"mutate:component": "node ../../scripts/mutate.mjs",
|
|
64
|
+
"mutate:local": "node ../../scripts/mutate-local.mjs",
|
|
60
65
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
61
66
|
}
|
|
62
67
|
}
|
|
@@ -5,17 +5,31 @@ import { AccessibleIconProps } from "./types";
|
|
|
5
5
|
type DecorativeIconProps = { "aria-hidden": string; focusable: string };
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Gives an icon-only control an accessible name.
|
|
8
|
+
* Gives an icon-only control an accessible name without adding a visible label.
|
|
9
9
|
*
|
|
10
|
-
* Wraps a single decorative icon
|
|
11
|
-
* `
|
|
12
|
-
* `focusable="false"` (so legacy browsers skip the `<svg>` in the tab
|
|
13
|
-
* order), and a {@link VisuallyHidden} text label is rendered alongside
|
|
14
|
-
* it. The label is announced by assistive technology while the icon
|
|
15
|
-
* stays the only thing on screen.
|
|
10
|
+
* Wraps a single decorative icon element and performs two mutations via
|
|
11
|
+
* `cloneElement`:
|
|
16
12
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
13
|
+
* - Sets `aria-hidden="true"` on the icon, removing the purely decorative
|
|
14
|
+
* graphic from the accessibility tree.
|
|
15
|
+
* - Sets `focusable="false"` on the icon, so legacy browsers (IE/Edge
|
|
16
|
+
* classic) do not place the `<svg>` in the tab order.
|
|
17
|
+
*
|
|
18
|
+
* The {@link AccessibleIconProps.label | `label`} string is rendered
|
|
19
|
+
* alongside the icon inside a {@link VisuallyHidden} span — invisible on
|
|
20
|
+
* screen but announced by assistive technology. When `AccessibleIcon` is
|
|
21
|
+
* placed inside an interactive element (`<button>`, `<a>`, etc.), the
|
|
22
|
+
* hidden label becomes that element's accessible name. A screen reader
|
|
23
|
+
* will announce, for example, "Close dialog, button" rather than nothing.
|
|
24
|
+
*
|
|
25
|
+
* **Runtime validation.** `children` must be exactly **one** React
|
|
26
|
+
* element. Passing zero, multiple, or a plain string throws a React
|
|
27
|
+
* `Children.only` error. Wrap the glyph in a single `<svg>` or component
|
|
28
|
+
* if your icon library returns a fragment.
|
|
29
|
+
*
|
|
30
|
+
* **Renders no host element.** `AccessibleIcon` returns a React fragment
|
|
31
|
+
* (`<>…</>`), so it adds no DOM node of its own. The icon element and the
|
|
32
|
+
* {@link VisuallyHidden} span are the only nodes emitted.
|
|
19
33
|
*
|
|
20
34
|
* @example Icon-only button
|
|
21
35
|
* ```tsx
|
|
@@ -25,6 +39,15 @@ type DecorativeIconProps = { "aria-hidden": string; focusable: string };
|
|
|
25
39
|
* </AccessibleIcon>
|
|
26
40
|
* </button>
|
|
27
41
|
* ```
|
|
42
|
+
*
|
|
43
|
+
* @example Icon-only link
|
|
44
|
+
* ```tsx
|
|
45
|
+
* <a href="/home">
|
|
46
|
+
* <AccessibleIcon label="Go to home page">
|
|
47
|
+
* <HomeIcon />
|
|
48
|
+
* </AccessibleIcon>
|
|
49
|
+
* </a>
|
|
50
|
+
* ```
|
|
28
51
|
*/
|
|
29
52
|
export function AccessibleIcon({
|
|
30
53
|
label,
|
|
@@ -44,4 +44,9 @@ describe("AccessibleIcon component", () => {
|
|
|
44
44
|
const label = screen.getByText("Search");
|
|
45
45
|
expect(label).toHaveStyle({ position: "absolute", overflow: "hidden" });
|
|
46
46
|
});
|
|
47
|
+
|
|
48
|
+
it('sets displayName to "AccessibleIcon" for React DevTools and stack traces', () => {
|
|
49
|
+
// Assert — an empty displayName would render it as an anonymous component.
|
|
50
|
+
expect(AccessibleIcon.displayName).toBe("AccessibleIcon");
|
|
51
|
+
});
|
|
47
52
|
});
|
|
@@ -3,10 +3,23 @@ import { ReactElement } from "react";
|
|
|
3
3
|
/**
|
|
4
4
|
* Props for {@link AccessibleIcon} — a single icon element plus the
|
|
5
5
|
* visually-hidden `label` announced as its accessible name.
|
|
6
|
+
*
|
|
7
|
+
* `AccessibleIconProps` does not extend any HTML element props: the
|
|
8
|
+
* component renders a React fragment with no host element of its own.
|
|
6
9
|
*/
|
|
7
10
|
export type AccessibleIconProps = {
|
|
8
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Text announced by assistive technology as the accessible name of the
|
|
13
|
+
* surrounding interactive control. Keep it concise and action-oriented
|
|
14
|
+
* (e.g. `"Close dialog"`, `"Go to home page"`). This string is rendered
|
|
15
|
+
* inside a {@link VisuallyHidden} span — it is never visible on screen.
|
|
16
|
+
*/
|
|
9
17
|
label: string;
|
|
10
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Exactly one React element representing the icon (typically an `<svg>`
|
|
20
|
+
* or a component that renders one). The element is cloned with
|
|
21
|
+
* `aria-hidden="true"` and `focusable="false"` applied. Passing zero,
|
|
22
|
+
* multiple, or a non-element child throws a React `Children.only` error.
|
|
23
|
+
*/
|
|
11
24
|
children: ReactElement;
|
|
12
25
|
};
|
|
@@ -52,6 +52,8 @@ import { useAccordionTrigger } from "./hooks/useAccordionTrigger";
|
|
|
52
52
|
* When omitted, it is inherited from the nearest {@link DirectionProvider},
|
|
53
53
|
* falling back to `"ltr"` when there is no provider.
|
|
54
54
|
*
|
|
55
|
+
* @extends HTMLDivElement
|
|
56
|
+
*
|
|
55
57
|
* @example Uncontrolled — single open item
|
|
56
58
|
* ```tsx
|
|
57
59
|
* <Accordion.Root defaultValue="item-1">
|
|
@@ -100,6 +102,7 @@ export function AccordionRoot({
|
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
/** @internal */
|
|
105
|
+
// Stryker disable next-line StringLiteral: overwritten by the compound alias — an equivalent mutant.
|
|
103
106
|
AccordionRoot.displayName = "AccordionRoot";
|
|
104
107
|
|
|
105
108
|
/**
|
|
@@ -112,6 +115,8 @@ AccordionRoot.displayName = "AccordionRoot";
|
|
|
112
115
|
* stable auto-generated ID is used via `useId()` — useful for fully anonymous
|
|
113
116
|
* items where the consumer doesn't need to drive state from outside.
|
|
114
117
|
*
|
|
118
|
+
* @extends HTMLDivElement
|
|
119
|
+
*
|
|
115
120
|
* @example
|
|
116
121
|
* ```tsx
|
|
117
122
|
* <Accordion.Item value="shipping">
|
|
@@ -148,6 +153,8 @@ AccordionItem.displayName = "AccordionItem";
|
|
|
148
153
|
* The WAI-ARIA Accordion pattern requires each trigger to be wrapped in a
|
|
149
154
|
* heading at the appropriate level for its position in the document outline.
|
|
150
155
|
*
|
|
156
|
+
* @extends HTMLHeadingElement
|
|
157
|
+
*
|
|
151
158
|
* @example
|
|
152
159
|
* ```tsx
|
|
153
160
|
* <Accordion.Header level={2}>
|
|
@@ -216,6 +223,8 @@ AccordionHeader.displayName = "AccordionHeader";
|
|
|
216
223
|
* - `data-state="open" | "closed"` on the rendered element.
|
|
217
224
|
* - `data-disabled="true" | "false"`.
|
|
218
225
|
*
|
|
226
|
+
* @extends HTMLButtonElement
|
|
227
|
+
*
|
|
219
228
|
* @example Basic
|
|
220
229
|
* ```tsx
|
|
221
230
|
* <Accordion.Trigger>Shipping policy</Accordion.Trigger>
|
|
@@ -293,6 +302,8 @@ AccordionTrigger.displayName = "AccordionTrigger";
|
|
|
293
302
|
* **Styling hooks.**
|
|
294
303
|
* - `data-state="open" | "closed"` on the rendered element.
|
|
295
304
|
*
|
|
305
|
+
* @extends HTMLDivElement
|
|
306
|
+
*
|
|
296
307
|
* @example Default (hidden attribute)
|
|
297
308
|
* ```tsx
|
|
298
309
|
* <Accordion.Content>Free shipping on orders over £50.</Accordion.Content>
|
|
@@ -313,10 +324,18 @@ export function AccordionContent({
|
|
|
313
324
|
const { panelId, buttonId, itemId, isExpanded } = useAccordionItemContext();
|
|
314
325
|
const { registerPanel, unregisterPanel } = useAccordionContext();
|
|
315
326
|
|
|
316
|
-
useEffect(
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
327
|
+
useEffect(
|
|
328
|
+
() => {
|
|
329
|
+
registerPanel(itemId);
|
|
330
|
+
return () => unregisterPanel(itemId);
|
|
331
|
+
},
|
|
332
|
+
// `registerPanel`/`unregisterPanel` are stable, and `itemId` is effectively
|
|
333
|
+
// immutable: changing an item's value produces a phantom-key validation
|
|
334
|
+
// throw (the collection keeps unmounted keys), so it is never a supported
|
|
335
|
+
// runtime transition. Emptying the deps is therefore equivalent.
|
|
336
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — stable/immutable dependencies.
|
|
337
|
+
[itemId, registerPanel, unregisterPanel],
|
|
338
|
+
);
|
|
320
339
|
|
|
321
340
|
return (
|
|
322
341
|
<div
|
|
@@ -352,6 +371,8 @@ AccordionContent.displayName = "AccordionContent";
|
|
|
352
371
|
* - `data-state="open" | "closed"` on the rendered `<span>`.
|
|
353
372
|
* - `aria-hidden="true"` on the rendered `<span>`.
|
|
354
373
|
*
|
|
374
|
+
* @extends HTMLSpanElement
|
|
375
|
+
*
|
|
355
376
|
* @example Inline SVG
|
|
356
377
|
* ```tsx
|
|
357
378
|
* <Accordion.Trigger>
|
|
@@ -234,4 +234,50 @@ describe("Accordion asChild tests", () => {
|
|
|
234
234
|
// Assert — no role="button" injected on enabled asChild elements
|
|
235
235
|
expect(trigger).not.toHaveAttribute("role", "button");
|
|
236
236
|
});
|
|
237
|
+
|
|
238
|
+
// A plain <div> (no native button semantics, no role) fires no synthetic
|
|
239
|
+
// click on Enter/Space, so these isolate the Trigger's own keydown handler —
|
|
240
|
+
// a native <button> or anchor would toggle via click even if the handler did
|
|
241
|
+
// nothing, masking mutations in it.
|
|
242
|
+
it("toggles on Enter for a non-native asChild element", async () => {
|
|
243
|
+
const user = userEvent.setup();
|
|
244
|
+
render(
|
|
245
|
+
<Accordion.Root>
|
|
246
|
+
<Accordion.Item value="a">
|
|
247
|
+
<Accordion.Header>
|
|
248
|
+
<Accordion.Trigger asChild>
|
|
249
|
+
<div tabIndex={0} data-testid="trigger">
|
|
250
|
+
Toggle
|
|
251
|
+
</div>
|
|
252
|
+
</Accordion.Trigger>
|
|
253
|
+
</Accordion.Header>
|
|
254
|
+
<Accordion.Content data-testid="content">Content</Accordion.Content>
|
|
255
|
+
</Accordion.Item>
|
|
256
|
+
</Accordion.Root>,
|
|
257
|
+
);
|
|
258
|
+
screen.getByTestId("trigger").focus();
|
|
259
|
+
await user.keyboard("{Enter}");
|
|
260
|
+
expect(screen.getByTestId("content")).toBeVisible();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("toggles on Space for a non-native asChild element", async () => {
|
|
264
|
+
const user = userEvent.setup();
|
|
265
|
+
render(
|
|
266
|
+
<Accordion.Root>
|
|
267
|
+
<Accordion.Item value="a">
|
|
268
|
+
<Accordion.Header>
|
|
269
|
+
<Accordion.Trigger asChild>
|
|
270
|
+
<div tabIndex={0} data-testid="trigger">
|
|
271
|
+
Toggle
|
|
272
|
+
</div>
|
|
273
|
+
</Accordion.Trigger>
|
|
274
|
+
</Accordion.Header>
|
|
275
|
+
<Accordion.Content data-testid="content">Content</Accordion.Content>
|
|
276
|
+
</Accordion.Item>
|
|
277
|
+
</Accordion.Root>,
|
|
278
|
+
);
|
|
279
|
+
screen.getByTestId("trigger").focus();
|
|
280
|
+
await user.keyboard(" ");
|
|
281
|
+
expect(screen.getByTestId("content")).toBeVisible();
|
|
282
|
+
});
|
|
237
283
|
});
|
|
@@ -3,6 +3,21 @@ import { render, screen } from "@testing-library/react";
|
|
|
3
3
|
import { Accordion } from "../Accordion";
|
|
4
4
|
import { type HeadingLevel } from "../../types";
|
|
5
5
|
|
|
6
|
+
describe("Accordion displayNames", () => {
|
|
7
|
+
it("sets a displayName on the compound and each sub-component", () => {
|
|
8
|
+
// Assert — empty displayNames would render each as anonymous in DevTools.
|
|
9
|
+
// `Accordion`, `Accordion.Root`, and the underlying function are one object
|
|
10
|
+
// (Object.assign compound), so the compound's "Accordion" alias is the
|
|
11
|
+
// observable Root displayName; the sub-components are distinct objects.
|
|
12
|
+
expect(Accordion.displayName).toBe("Accordion");
|
|
13
|
+
expect(Accordion.Item.displayName).toBe("AccordionItem");
|
|
14
|
+
expect(Accordion.Header.displayName).toBe("AccordionHeader");
|
|
15
|
+
expect(Accordion.Trigger.displayName).toBe("AccordionTrigger");
|
|
16
|
+
expect(Accordion.Content.displayName).toBe("AccordionContent");
|
|
17
|
+
expect(Accordion.TriggerIcon.displayName).toBe("AccordionTriggerIcon");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
6
21
|
describe("Accordion basic rendering tests", () => {
|
|
7
22
|
it('should apply the attribute data-orientation="vertical" to the accordion by default', () => {
|
|
8
23
|
// Arrange
|
|
@@ -279,6 +279,48 @@ describe("Accordion disabled items tests", () => {
|
|
|
279
279
|
expect(trigger2).toHaveFocus();
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
+
it("re-registers a trigger's disabled flag when it changes after mount", async () => {
|
|
283
|
+
// Arrange — all enabled at mount; the middle trigger becomes disabled after.
|
|
284
|
+
const user = userEvent.setup();
|
|
285
|
+
function Fixture({ middleDisabled }: { middleDisabled: boolean }) {
|
|
286
|
+
return (
|
|
287
|
+
<Accordion.Root>
|
|
288
|
+
<Accordion.Item value="a">
|
|
289
|
+
<Accordion.Header>
|
|
290
|
+
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
|
|
291
|
+
</Accordion.Header>
|
|
292
|
+
<Accordion.Content>Content</Accordion.Content>
|
|
293
|
+
</Accordion.Item>
|
|
294
|
+
<Accordion.Item value="b">
|
|
295
|
+
<Accordion.Header>
|
|
296
|
+
<Accordion.Trigger disabled={middleDisabled}>
|
|
297
|
+
Trigger 2
|
|
298
|
+
</Accordion.Trigger>
|
|
299
|
+
</Accordion.Header>
|
|
300
|
+
<Accordion.Content>Content</Accordion.Content>
|
|
301
|
+
</Accordion.Item>
|
|
302
|
+
<Accordion.Item value="c">
|
|
303
|
+
<Accordion.Header>
|
|
304
|
+
<Accordion.Trigger>Trigger 3</Accordion.Trigger>
|
|
305
|
+
</Accordion.Header>
|
|
306
|
+
<Accordion.Content>Content</Accordion.Content>
|
|
307
|
+
</Accordion.Item>
|
|
308
|
+
</Accordion.Root>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
const { rerender } = render(<Fixture middleDisabled={false} />);
|
|
312
|
+
|
|
313
|
+
// Act — disable Trigger 2 after mount, then arrow down from Trigger 1.
|
|
314
|
+
rerender(<Fixture middleDisabled />);
|
|
315
|
+
screen.getByRole("button", { name: "Trigger 1" }).focus();
|
|
316
|
+
await user.keyboard("[ArrowDown]");
|
|
317
|
+
|
|
318
|
+
// Assert — the registrar effect re-ran, so Trigger 2 is skipped and focus
|
|
319
|
+
// lands on Trigger 3. A stale registration would keep Trigger 2 navigable,
|
|
320
|
+
// landing focus on it (aria-disabled stays focusable).
|
|
321
|
+
expect(screen.getByRole("button", { name: "Trigger 3" })).toHaveFocus();
|
|
322
|
+
});
|
|
323
|
+
|
|
282
324
|
it("should not throw when all triggers are disabled and arrow key is pressed", async () => {
|
|
283
325
|
// Arrange
|
|
284
326
|
const user = userEvent.setup();
|
|
@@ -92,6 +92,39 @@ describe("Accordion error handling tests", () => {
|
|
|
92
92
|
}).toThrow(/AccordionTrigger.*no corresponding AccordionContent/);
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
+
it("re-validates after a panel unmounts: a trigger left without content throws", () => {
|
|
96
|
+
// Arrange — item A valid (trigger + content); item B mountable to force a
|
|
97
|
+
// re-validation later by changing the registered trigger set.
|
|
98
|
+
function Fixture({ showAContent }: { showAContent: boolean }) {
|
|
99
|
+
return (
|
|
100
|
+
<Accordion.Root>
|
|
101
|
+
<Accordion.Item value="a">
|
|
102
|
+
<Accordion.Header>
|
|
103
|
+
<Accordion.Trigger>A</Accordion.Trigger>
|
|
104
|
+
</Accordion.Header>
|
|
105
|
+
{showAContent && <Accordion.Content>A content</Accordion.Content>}
|
|
106
|
+
</Accordion.Item>
|
|
107
|
+
{!showAContent && (
|
|
108
|
+
<Accordion.Item value="b">
|
|
109
|
+
<Accordion.Header>
|
|
110
|
+
<Accordion.Trigger>B</Accordion.Trigger>
|
|
111
|
+
</Accordion.Header>
|
|
112
|
+
<Accordion.Content>B content</Accordion.Content>
|
|
113
|
+
</Accordion.Item>
|
|
114
|
+
)}
|
|
115
|
+
</Accordion.Root>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const { rerender } = render(<Fixture showAContent />);
|
|
119
|
+
|
|
120
|
+
// Act & Assert — removing A's content unregisters its panel; adding B
|
|
121
|
+
// re-runs validation, which now finds A's trigger has no content. If the
|
|
122
|
+
// panel cleanup were a no-op, A would stay registered and no throw occurs.
|
|
123
|
+
expect(() => rerender(<Fixture showAContent={false} />)).toThrow(
|
|
124
|
+
/AccordionTrigger.*no corresponding AccordionContent/,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
95
128
|
it("should not throw when AccordionItem with Trigger and Content is added dynamically", () => {
|
|
96
129
|
// Arrange
|
|
97
130
|
function DynamicAccordion({ showItem }: { showItem: boolean }) {
|
|
@@ -20,21 +20,35 @@ export function useAccordionRoot(
|
|
|
20
20
|
const accordionId = useId();
|
|
21
21
|
// updateKeysOnCleanup is false so trigger unmounts don't fire setState
|
|
22
22
|
// outside act() after a render-time validation throw.
|
|
23
|
+
// `updateKeysOnCleanup: false` avoids a setState-outside-act warning when a
|
|
24
|
+
// trigger unmounts after a render-time validation throw (see below). Flipping
|
|
25
|
+
// it to the default (true) changes only the timing of internal keys-state on
|
|
26
|
+
// unmount; it has no user-facing behavioural difference the contract exposes,
|
|
27
|
+
// so no test can distinguish it.
|
|
28
|
+
// Stryker disable next-line all
|
|
29
|
+
const triggerCollectionOptions = { updateKeysOnCleanup: false };
|
|
23
30
|
const {
|
|
24
31
|
register: registerTriggerBase,
|
|
25
32
|
itemsRef: triggersRef,
|
|
26
33
|
keys: registeredTriggerItemIds,
|
|
27
|
-
} = useCollection<string, TriggerMeta>(
|
|
34
|
+
} = useCollection<string, TriggerMeta>(triggerCollectionOptions);
|
|
28
35
|
const panelsRef = useRef<Set<string>>(new Set());
|
|
29
36
|
|
|
30
37
|
const registerTrigger = useCallback(
|
|
31
38
|
(
|
|
32
39
|
itemId: string,
|
|
33
40
|
element: HTMLButtonElement | null,
|
|
41
|
+
// The default is only reached by the cleanup call `registerTrigger(id,
|
|
42
|
+
// null)`, where `element` is null and `disabled` is discarded — so its
|
|
43
|
+
// value is never observable.
|
|
44
|
+
// Stryker disable next-line BooleanLiteral
|
|
34
45
|
disabled = false,
|
|
35
46
|
) => {
|
|
36
47
|
registerTriggerBase(itemId, element ? { element, disabled } : null);
|
|
37
48
|
},
|
|
49
|
+
// `registerTriggerBase` is a stable useCollection callback, so emptying this
|
|
50
|
+
// array yields the identical memoised function.
|
|
51
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — stable dependency.
|
|
38
52
|
[registerTriggerBase],
|
|
39
53
|
);
|
|
40
54
|
|
|
@@ -54,8 +68,14 @@ export function useAccordionRoot(
|
|
|
54
68
|
|
|
55
69
|
const focusTrigger = useCallback(
|
|
56
70
|
(itemId: string) => {
|
|
71
|
+
// `itemId` is always sourced from the navigable set (registered triggers),
|
|
72
|
+
// so a value that reaches here is guaranteed to have a live entry.
|
|
73
|
+
// Stryker disable next-line OptionalChaining: unreachable given that invariant.
|
|
57
74
|
triggersRef.current.get(itemId)?.element.focus();
|
|
58
75
|
},
|
|
76
|
+
// `triggersRef` is a stable RefObject, so emptying this array yields the
|
|
77
|
+
// identical memoised function.
|
|
78
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — stable dependency.
|
|
59
79
|
[triggersRef],
|
|
60
80
|
);
|
|
61
81
|
|
|
@@ -67,6 +87,10 @@ export function useAccordionRoot(
|
|
|
67
87
|
Set<string>
|
|
68
88
|
>(
|
|
69
89
|
controlledValue !== undefined ? new Set(controlledValue) : undefined,
|
|
90
|
+
// Forcing this condition true yields `new Set([undefined])` when no
|
|
91
|
+
// defaultValue is given; `undefined` can never equal an item id (ids are
|
|
92
|
+
// `value ?? useId()`), so it is observationally identical to the empty set.
|
|
93
|
+
// Stryker disable next-line ConditionalExpression
|
|
70
94
|
defaultValue !== undefined ? new Set([defaultValue]) : new Set(),
|
|
71
95
|
);
|
|
72
96
|
|
|
@@ -87,6 +111,9 @@ export function useAccordionRoot(
|
|
|
87
111
|
(itemId: string) => {
|
|
88
112
|
const next = computeNext(expandedItems, itemId);
|
|
89
113
|
if (isControlled) {
|
|
114
|
+
// Controlled mode requires `onValueChange` (discriminated-union prop
|
|
115
|
+
// type), so the optional call can never no-op in valid usage.
|
|
116
|
+
// Stryker disable next-line OptionalChaining: unreachable — controlled requires onValueChange.
|
|
90
117
|
onValueChange?.(Array.from(next));
|
|
91
118
|
} else {
|
|
92
119
|
setExpandedItems(next);
|
|
@@ -100,13 +127,23 @@ export function useAccordionRoot(
|
|
|
100
127
|
// runs all effects in a commit before flushing batched state updates, the
|
|
101
128
|
// panel effect always executes (and updates panelsRef) before the re-render
|
|
102
129
|
// triggered by trigger registration reaches the validation effect.
|
|
103
|
-
const registerPanel = useCallback(
|
|
104
|
-
|
|
105
|
-
|
|
130
|
+
const registerPanel = useCallback(
|
|
131
|
+
(itemId: string) => {
|
|
132
|
+
panelsRef.current.add(itemId);
|
|
133
|
+
},
|
|
134
|
+
// Empty, stable deps — filling the array yields the identical callback.
|
|
135
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — stable dependencies.
|
|
136
|
+
[],
|
|
137
|
+
);
|
|
106
138
|
|
|
107
|
-
const unregisterPanel = useCallback(
|
|
108
|
-
|
|
109
|
-
|
|
139
|
+
const unregisterPanel = useCallback(
|
|
140
|
+
(itemId: string) => {
|
|
141
|
+
panelsRef.current.delete(itemId);
|
|
142
|
+
},
|
|
143
|
+
// Empty, stable deps — filling the array yields the identical callback.
|
|
144
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — stable dependencies.
|
|
145
|
+
[],
|
|
146
|
+
);
|
|
110
147
|
|
|
111
148
|
useEffect(() => {
|
|
112
149
|
for (const triggerId of registeredTriggerItemIds) {
|
|
@@ -12,7 +12,9 @@ export function useAccordionTrigger({
|
|
|
12
12
|
ref,
|
|
13
13
|
onClick,
|
|
14
14
|
disabled,
|
|
15
|
-
|
|
15
|
+
// No default: the `AccordionTrigger` component always supplies a resolved
|
|
16
|
+
// `asChild` boolean, so a default here would be unreachable (dead) code.
|
|
17
|
+
asChild,
|
|
16
18
|
...rest
|
|
17
19
|
}: Omit<AccordionTriggerProps, "children">) {
|
|
18
20
|
const { buttonId, panelId, itemId, isExpanded } = useAccordionItemContext();
|
|
@@ -35,6 +37,12 @@ export function useAccordionTrigger({
|
|
|
35
37
|
// useRovingTabindex expects.
|
|
36
38
|
useEffect(() => {
|
|
37
39
|
registerTrigger(itemId, triggerRef.current, disabled);
|
|
40
|
+
// Because the collection uses `updateKeysOnCleanup: false`, an unmount
|
|
41
|
+
// clears only this trigger's ref entry without a keys change, and focusing
|
|
42
|
+
// a removed trigger is a no-op whether the entry is absent or detached —
|
|
43
|
+
// so this cleanup has no observable effect the contract exposes. (On a dep
|
|
44
|
+
// change the effect body re-registers first, overwriting it.)
|
|
45
|
+
// Stryker disable next-line ArrowFunction: equivalent — no observable effect.
|
|
38
46
|
return () => registerTrigger(itemId, null);
|
|
39
47
|
}, [itemId, disabled, registerTrigger]);
|
|
40
48
|
|