@primitiv-ui/react 0.1.28 → 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
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import type { ReactElement } from "react";
|
|
3
|
+
|
|
4
|
+
import { useDirection } from "../DirectionProvider/index.ts";
|
|
5
|
+
import { useRovingTabindex } from "../hooks/index.ts";
|
|
6
|
+
import { Slot, composeEventHandlers, composeRefs } from "../Slot/index.ts";
|
|
7
|
+
|
|
8
|
+
import { SegmentedControlContext } from "./SegmentedControlContext";
|
|
9
|
+
import {
|
|
10
|
+
useSegmentedControlContext,
|
|
11
|
+
useSegmentedControlRoot,
|
|
12
|
+
} from "./hooks/index.ts";
|
|
13
|
+
import {
|
|
14
|
+
SegmentedControlItemProps,
|
|
15
|
+
SegmentedControlRootProps,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The root of a SegmentedControl — a `<div role="radiogroup">` that owns
|
|
20
|
+
* the selected value and provides {@link SegmentedControlContext} to
|
|
21
|
+
* descendant {@link SegmentedControlItem | `SegmentedControl.Item`}s. It is
|
|
22
|
+
* the single-select value picker of the design system (the same WAI-ARIA
|
|
23
|
+
* radio-group semantics as {@link RadioGroupRoot | `RadioGroup`}), styled as
|
|
24
|
+
* a linear strip of segments rather than a stack of radios.
|
|
25
|
+
*
|
|
26
|
+
* Supports two state modes, statically discriminated at the type level:
|
|
27
|
+
*
|
|
28
|
+
* - **Uncontrolled** — pass
|
|
29
|
+
* {@link SegmentedControlRootProps.defaultValue | `defaultValue`} (or omit
|
|
30
|
+
* for nothing selected on mount). The component owns the value.
|
|
31
|
+
* - **Controlled** — pass
|
|
32
|
+
* {@link SegmentedControlRootProps.value | `value`} *and*
|
|
33
|
+
* {@link SegmentedControlRootProps.onValueChange | `onValueChange`}
|
|
34
|
+
* together. The parent owns the value; the component defers every change
|
|
35
|
+
* back through the callback.
|
|
36
|
+
*
|
|
37
|
+
* **ARIA.** `role="radiogroup"` and `aria-orientation` are set
|
|
38
|
+
* automatically. Provide an accessible name via `aria-label` or
|
|
39
|
+
* `aria-labelledby`.
|
|
40
|
+
*
|
|
41
|
+
* **Orientation.** `"horizontal"` (default) binds the Arrow Left/Right pair;
|
|
42
|
+
* `"vertical"` binds Arrow Up/Down. Reflected as `data-orientation` on the
|
|
43
|
+
* root for styling.
|
|
44
|
+
*
|
|
45
|
+
* **Disabled.** Pass `disabled` to disable the whole control — every segment
|
|
46
|
+
* becomes non-interactive and drops out of arrow-key navigation, and
|
|
47
|
+
* `data-disabled=""` is set on the root.
|
|
48
|
+
*
|
|
49
|
+
* **Reading direction.** `dir` (`"ltr"` / `"rtl"`) swaps the horizontal
|
|
50
|
+
* arrow pair so Arrow Left moves forward in RTL. When omitted, it is
|
|
51
|
+
* inherited from the nearest {@link DirectionProvider}, falling back to
|
|
52
|
+
* `"ltr"`.
|
|
53
|
+
*
|
|
54
|
+
* **`asChild` prop.** Pass `asChild` to render any consumer-supplied element
|
|
55
|
+
* in place of the native `<div>`, with the control's `role`,
|
|
56
|
+
* `aria-orientation`, `data-orientation`, `data-disabled`, `dir`, and
|
|
57
|
+
* remaining props merged in.
|
|
58
|
+
*
|
|
59
|
+
* @extends HTMLDivElement
|
|
60
|
+
*
|
|
61
|
+
* @example Uncontrolled
|
|
62
|
+
* ```tsx
|
|
63
|
+
* <SegmentedControl.Root defaultValue="headless" aria-label="Consumption mode">
|
|
64
|
+
* <SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
|
|
65
|
+
* <SegmentedControl.Item value="styled">Styled</SegmentedControl.Item>
|
|
66
|
+
* <SegmentedControl.Item value="figma">Figma</SegmentedControl.Item>
|
|
67
|
+
* </SegmentedControl.Root>
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* @example Controlled
|
|
71
|
+
* ```tsx
|
|
72
|
+
* const [value, setValue] = useState("headless");
|
|
73
|
+
*
|
|
74
|
+
* <SegmentedControl.Root value={value} onValueChange={setValue} aria-label="…">
|
|
75
|
+
* <SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
|
|
76
|
+
* <SegmentedControl.Item value="styled">Styled</SegmentedControl.Item>
|
|
77
|
+
* </SegmentedControl.Root>
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export function SegmentedControlRoot({
|
|
81
|
+
defaultValue,
|
|
82
|
+
value: controlledValue,
|
|
83
|
+
onValueChange,
|
|
84
|
+
orientation = "horizontal",
|
|
85
|
+
dir,
|
|
86
|
+
disabled = false,
|
|
87
|
+
asChild = false,
|
|
88
|
+
children,
|
|
89
|
+
...rest
|
|
90
|
+
}: SegmentedControlRootProps): ReactElement {
|
|
91
|
+
const resolvedDir = dir ?? useDirection();
|
|
92
|
+
const { value, select, registerItem, itemValues, disabledValues, focusItem } =
|
|
93
|
+
useSegmentedControlRoot({
|
|
94
|
+
defaultValue,
|
|
95
|
+
value: controlledValue,
|
|
96
|
+
onValueChange,
|
|
97
|
+
});
|
|
98
|
+
const contextValue = useMemo(
|
|
99
|
+
() => ({
|
|
100
|
+
value,
|
|
101
|
+
select,
|
|
102
|
+
registerItem,
|
|
103
|
+
itemValues,
|
|
104
|
+
disabledValues,
|
|
105
|
+
focusItem,
|
|
106
|
+
orientation,
|
|
107
|
+
dir: resolvedDir,
|
|
108
|
+
disabled,
|
|
109
|
+
}),
|
|
110
|
+
[
|
|
111
|
+
value,
|
|
112
|
+
select,
|
|
113
|
+
registerItem,
|
|
114
|
+
itemValues,
|
|
115
|
+
disabledValues,
|
|
116
|
+
focusItem,
|
|
117
|
+
orientation,
|
|
118
|
+
resolvedDir,
|
|
119
|
+
disabled,
|
|
120
|
+
],
|
|
121
|
+
);
|
|
122
|
+
const rootProps = {
|
|
123
|
+
role: "radiogroup" as const,
|
|
124
|
+
"aria-orientation": orientation,
|
|
125
|
+
"data-orientation": orientation,
|
|
126
|
+
"data-disabled": disabled ? "" : undefined,
|
|
127
|
+
dir: resolvedDir,
|
|
128
|
+
...rest,
|
|
129
|
+
};
|
|
130
|
+
return (
|
|
131
|
+
<SegmentedControlContext.Provider value={contextValue}>
|
|
132
|
+
{asChild ? (
|
|
133
|
+
<Slot {...rootProps}>{children}</Slot>
|
|
134
|
+
) : (
|
|
135
|
+
<div {...rootProps}>{children}</div>
|
|
136
|
+
)}
|
|
137
|
+
</SegmentedControlContext.Provider>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** @internal */
|
|
142
|
+
// Runtime-dead: the compound alias below (same object via Object.assign)
|
|
143
|
+
// overwrites this to "SegmentedControl" at load, so the value is never
|
|
144
|
+
// observable. The assignment stays because it declares `displayName` on
|
|
145
|
+
// `typeof SegmentedControlRoot`, which TSegmentedControlCompound extends.
|
|
146
|
+
// Stryker disable next-line StringLiteral: overwritten by the compound alias — an equivalent mutant.
|
|
147
|
+
SegmentedControlRoot.displayName = "SegmentedControlRoot";
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* An individual segment inside a SegmentedControl — a native
|
|
151
|
+
* `<button role="radio">` that reports its state, participates in the
|
|
152
|
+
* roving tabindex, and handles arrow-key navigation within the control.
|
|
153
|
+
*
|
|
154
|
+
* **Selection.** Clicking a segment (or pressing Space / Enter on the
|
|
155
|
+
* focused segment via native `<button>` behaviour) selects it. The arrow
|
|
156
|
+
* keys enabled by the control's `orientation` move focus and selection to
|
|
157
|
+
* the next or previous non-disabled segment, wrapping at the ends.
|
|
158
|
+
*
|
|
159
|
+
* **Roving tabindex.** Only one segment per control is in the document tab
|
|
160
|
+
* sequence at a time: the selected one if any, otherwise the first
|
|
161
|
+
* non-disabled segment. All others have `tabIndex=-1` so `Tab` escapes the
|
|
162
|
+
* control in a single keystroke.
|
|
163
|
+
*
|
|
164
|
+
* **Disabled.** Passing `disabled` forwards the native attribute and
|
|
165
|
+
* excludes the segment from arrow-key navigation and the roving-tabindex
|
|
166
|
+
* home base. The whole control can also be disabled via Root's `disabled`
|
|
167
|
+
* prop, which disables every segment.
|
|
168
|
+
*
|
|
169
|
+
* **Styling hooks.** `data-state="checked" | "unchecked"` mirrors the
|
|
170
|
+
* selection state; `data-disabled=""` is set when the segment (or the whole
|
|
171
|
+
* control) is disabled.
|
|
172
|
+
*
|
|
173
|
+
* **`asChild` prop.** Pass `asChild` to render any consumer element in place
|
|
174
|
+
* of the native `<button>`, with the segment's ARIA, `data-state`,
|
|
175
|
+
* `data-disabled`, `tabIndex`, `onClick`, `onKeyDown`, `disabled`, and `ref`
|
|
176
|
+
* merged onto it. A non-focusable child must be made focusable by the
|
|
177
|
+
* consumer.
|
|
178
|
+
*
|
|
179
|
+
* @extends HTMLButtonElement
|
|
180
|
+
*
|
|
181
|
+
* @throws if rendered outside a `SegmentedControl.Root`.
|
|
182
|
+
*/
|
|
183
|
+
export function SegmentedControlItem({
|
|
184
|
+
value,
|
|
185
|
+
children,
|
|
186
|
+
onClick,
|
|
187
|
+
onKeyDown,
|
|
188
|
+
disabled: itemDisabled,
|
|
189
|
+
asChild = false,
|
|
190
|
+
ref,
|
|
191
|
+
...rest
|
|
192
|
+
}: SegmentedControlItemProps): ReactElement {
|
|
193
|
+
const {
|
|
194
|
+
value: selectedValue,
|
|
195
|
+
select,
|
|
196
|
+
registerItem,
|
|
197
|
+
itemValues,
|
|
198
|
+
disabledValues,
|
|
199
|
+
focusItem,
|
|
200
|
+
orientation,
|
|
201
|
+
dir,
|
|
202
|
+
disabled: groupDisabled,
|
|
203
|
+
} = useSegmentedControlContext();
|
|
204
|
+
const isChecked = selectedValue === value;
|
|
205
|
+
const isDisabled = itemDisabled || groupDisabled;
|
|
206
|
+
// Arrow-key navigation walks only enabled items: the per-item disabled
|
|
207
|
+
// set is excluded, and when the whole control is disabled the predicate
|
|
208
|
+
// fails for every item so the navigable list is empty.
|
|
209
|
+
const enabledValues = useMemo(
|
|
210
|
+
() => itemValues.filter((v) => !groupDisabled && !disabledValues.has(v)),
|
|
211
|
+
[groupDisabled, itemValues, disabledValues],
|
|
212
|
+
);
|
|
213
|
+
const isTabStop =
|
|
214
|
+
selectedValue !== undefined ? isChecked : enabledValues[0] === value;
|
|
215
|
+
|
|
216
|
+
const localRef = useRef<HTMLButtonElement | null>(null);
|
|
217
|
+
const setRef = useMemo(() => composeRefs(localRef, ref), [ref]);
|
|
218
|
+
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
registerItem(value, localRef.current, itemDisabled);
|
|
221
|
+
return () => registerItem(value, null);
|
|
222
|
+
}, [value, itemDisabled, registerItem]);
|
|
223
|
+
|
|
224
|
+
const { handleKeyDown } = useRovingTabindex<string>({
|
|
225
|
+
orientation,
|
|
226
|
+
dir,
|
|
227
|
+
navigable: enabledValues,
|
|
228
|
+
currentKey: value,
|
|
229
|
+
onNavigate: (target) => {
|
|
230
|
+
select(target);
|
|
231
|
+
focusItem(target);
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const itemProps = {
|
|
236
|
+
...rest,
|
|
237
|
+
ref: setRef,
|
|
238
|
+
role: "radio" as const,
|
|
239
|
+
"aria-checked": isChecked,
|
|
240
|
+
"data-state": isChecked ? ("checked" as const) : ("unchecked" as const),
|
|
241
|
+
"data-disabled": isDisabled ? "" : undefined,
|
|
242
|
+
tabIndex: isTabStop ? 0 : -1,
|
|
243
|
+
disabled: isDisabled,
|
|
244
|
+
onClick: composeEventHandlers(onClick, () => select(value)),
|
|
245
|
+
onKeyDown: composeEventHandlers(onKeyDown, handleKeyDown),
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
return asChild ? (
|
|
249
|
+
<Slot {...itemProps}>{children}</Slot>
|
|
250
|
+
) : (
|
|
251
|
+
<button type="button" {...itemProps}>
|
|
252
|
+
{children}
|
|
253
|
+
</button>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** @internal */
|
|
258
|
+
SegmentedControlItem.displayName = "SegmentedControlItem";
|
|
259
|
+
|
|
260
|
+
/** Type of the {@link SegmentedControl} compound: the root callable plus its attached sub-components. */
|
|
261
|
+
export type TSegmentedControlCompound = typeof SegmentedControlRoot & {
|
|
262
|
+
Root: typeof SegmentedControlRoot;
|
|
263
|
+
Item: typeof SegmentedControlItem;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Headless, accessible **SegmentedControl** — a compound component for
|
|
268
|
+
* picking one option from a small linear set. It implements the
|
|
269
|
+
* [WAI-ARIA Radio Group pattern](https://www.w3.org/WAI/ARIA/apg/patterns/radio/)
|
|
270
|
+
* on native `<button role="radio">` elements: exactly one segment is
|
|
271
|
+
* selected at a time and the selection can never be cleared to nothing
|
|
272
|
+
* (unlike a toggle group). Zero styles ship.
|
|
273
|
+
*
|
|
274
|
+
* `SegmentedControl` is both callable (an alias of
|
|
275
|
+
* {@link SegmentedControlRoot | `SegmentedControl.Root`}) and carries its
|
|
276
|
+
* sub-components as static properties. Prefer the namespaced form in
|
|
277
|
+
* application code for readability and grep-ability.
|
|
278
|
+
*
|
|
279
|
+
* - {@link SegmentedControlRoot | `SegmentedControl.Root`} — state owner,
|
|
280
|
+
* context provider, `<div role="radiogroup">` wrapper.
|
|
281
|
+
* - {@link SegmentedControlItem | `SegmentedControl.Item`} — a selectable
|
|
282
|
+
* segment participating in the roving tabindex.
|
|
283
|
+
*
|
|
284
|
+
* @example Minimal usage
|
|
285
|
+
* ```tsx
|
|
286
|
+
* import { SegmentedControl } from "@primitiv-ui/react";
|
|
287
|
+
*
|
|
288
|
+
* <SegmentedControl.Root defaultValue="headless" aria-label="Consumption mode">
|
|
289
|
+
* <SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
|
|
290
|
+
* <SegmentedControl.Item value="styled">Styled</SegmentedControl.Item>
|
|
291
|
+
* <SegmentedControl.Item value="figma">Figma</SegmentedControl.Item>
|
|
292
|
+
* </SegmentedControl.Root>;
|
|
293
|
+
* ```
|
|
294
|
+
*
|
|
295
|
+
* @see {@link SegmentedControlRoot} for state modes, orientation, and ARIA.
|
|
296
|
+
* @see {@link SegmentedControlItem} for selection, roving tabindex, and keyboard navigation.
|
|
297
|
+
*/
|
|
298
|
+
const SegmentedControlCompound: TSegmentedControlCompound = Object.assign(
|
|
299
|
+
SegmentedControlRoot,
|
|
300
|
+
{
|
|
301
|
+
Root: SegmentedControlRoot,
|
|
302
|
+
Item: SegmentedControlItem,
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
/** @internal */
|
|
307
|
+
SegmentedControlCompound.displayName = "SegmentedControl";
|
|
308
|
+
|
|
309
|
+
export { SegmentedControlCompound as SegmentedControl };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createStrictContext } from "../utils/index.ts";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
SegmentedControlOrientation,
|
|
5
|
+
SegmentedControlReadingDirection,
|
|
6
|
+
} from "./types";
|
|
7
|
+
|
|
8
|
+
export type SegmentedControlContextValue = {
|
|
9
|
+
value: string | undefined;
|
|
10
|
+
select: (value: string) => void;
|
|
11
|
+
registerItem: (
|
|
12
|
+
value: string,
|
|
13
|
+
element: HTMLButtonElement | null,
|
|
14
|
+
disabled?: boolean,
|
|
15
|
+
) => void;
|
|
16
|
+
itemValues: string[];
|
|
17
|
+
disabledValues: Set<string>;
|
|
18
|
+
focusItem: (value: string) => void;
|
|
19
|
+
orientation: SegmentedControlOrientation;
|
|
20
|
+
dir: SegmentedControlReadingDirection;
|
|
21
|
+
/** Whether the whole control is disabled (Root's `disabled` prop). */
|
|
22
|
+
disabled: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const [SegmentedControlContext, useSegmentedControlContext] =
|
|
26
|
+
createStrictContext<SegmentedControlContextValue>(
|
|
27
|
+
"SegmentedControl sub-components must be rendered inside a <SegmentedControl.Root>.",
|
|
28
|
+
);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
|
|
4
|
+
import { SegmentedControl } from "../SegmentedControl";
|
|
5
|
+
|
|
6
|
+
describe("SegmentedControl asChild composition", () => {
|
|
7
|
+
it("Root asChild renders the consumer element with radiogroup role preserved", () => {
|
|
8
|
+
// Arrange & Act
|
|
9
|
+
render(
|
|
10
|
+
<SegmentedControl.Root asChild aria-label="Mode">
|
|
11
|
+
<section data-testid="wrap">
|
|
12
|
+
<SegmentedControl.Item value="a">A</SegmentedControl.Item>
|
|
13
|
+
</section>
|
|
14
|
+
</SegmentedControl.Root>,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
// Assert
|
|
18
|
+
const root = screen.getByTestId("wrap");
|
|
19
|
+
expect(root.tagName).toBe("SECTION");
|
|
20
|
+
expect(root).toHaveAttribute("role", "radiogroup");
|
|
21
|
+
expect(root).toHaveAttribute("aria-label", "Mode");
|
|
22
|
+
expect(root).toHaveAttribute("data-orientation", "horizontal");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("Item asChild delegates to the child element while keeping ARIA + selection wiring", async () => {
|
|
26
|
+
// Arrange
|
|
27
|
+
const user = userEvent.setup();
|
|
28
|
+
const onValueChange = vi.fn();
|
|
29
|
+
render(
|
|
30
|
+
<SegmentedControl.Root aria-label="Mode" onValueChange={onValueChange}>
|
|
31
|
+
<SegmentedControl.Item value="a" asChild>
|
|
32
|
+
<li>A</li>
|
|
33
|
+
</SegmentedControl.Item>
|
|
34
|
+
</SegmentedControl.Root>,
|
|
35
|
+
);
|
|
36
|
+
const item = screen.getByRole("radio", { name: "A" });
|
|
37
|
+
|
|
38
|
+
// Assert element is the consumer's <li>
|
|
39
|
+
expect(item.tagName).toBe("LI");
|
|
40
|
+
expect(item).toHaveAttribute("aria-checked", "false");
|
|
41
|
+
expect(item).toHaveAttribute("data-state", "unchecked");
|
|
42
|
+
|
|
43
|
+
// Act
|
|
44
|
+
await user.click(item);
|
|
45
|
+
|
|
46
|
+
// Assert selection wiring still fires via composed onClick
|
|
47
|
+
expect(onValueChange).toHaveBeenCalledWith("a");
|
|
48
|
+
expect(item).toHaveAttribute("aria-checked", "true");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
|
|
3
|
+
import { SegmentedControl } from "../SegmentedControl";
|
|
4
|
+
|
|
5
|
+
describe("SegmentedControl basic rendering", () => {
|
|
6
|
+
it('renders a container with role="radiogroup"', () => {
|
|
7
|
+
// Arrange & Act
|
|
8
|
+
render(
|
|
9
|
+
<SegmentedControl.Root aria-label="Mode">
|
|
10
|
+
<SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
|
|
11
|
+
</SegmentedControl.Root>,
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
// Assert
|
|
15
|
+
expect(screen.getByRole("radiogroup", { name: "Mode" })).toBeInTheDocument();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('renders each item as a <button role="radio"> with aria-checked="false" by default', () => {
|
|
19
|
+
// Arrange & Act
|
|
20
|
+
render(
|
|
21
|
+
<SegmentedControl.Root aria-label="Mode">
|
|
22
|
+
<SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
|
|
23
|
+
<SegmentedControl.Item value="styled">Styled</SegmentedControl.Item>
|
|
24
|
+
</SegmentedControl.Root>,
|
|
25
|
+
);
|
|
26
|
+
const headless = screen.getByRole("radio", { name: "Headless" });
|
|
27
|
+
const styled = screen.getByRole("radio", { name: "Styled" });
|
|
28
|
+
|
|
29
|
+
// Assert
|
|
30
|
+
expect(headless.tagName).toBe("BUTTON");
|
|
31
|
+
expect(headless).toHaveAttribute("aria-checked", "false");
|
|
32
|
+
expect(headless).toHaveAttribute("data-state", "unchecked");
|
|
33
|
+
expect(styled.tagName).toBe("BUTTON");
|
|
34
|
+
expect(styled).toHaveAttribute("aria-checked", "false");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('defaults type="button" on items so they never submit an enclosing form', () => {
|
|
38
|
+
// Arrange & Act
|
|
39
|
+
render(
|
|
40
|
+
<SegmentedControl.Root aria-label="Mode">
|
|
41
|
+
<SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
|
|
42
|
+
</SegmentedControl.Root>,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// Assert
|
|
46
|
+
expect(screen.getByRole("radio", { name: "Headless" })).toHaveAttribute(
|
|
47
|
+
"type",
|
|
48
|
+
"button",
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('defaults to horizontal orientation on the root (aria + data)', () => {
|
|
53
|
+
// Arrange & Act
|
|
54
|
+
render(
|
|
55
|
+
<SegmentedControl.Root aria-label="Mode">
|
|
56
|
+
<SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
|
|
57
|
+
</SegmentedControl.Root>,
|
|
58
|
+
);
|
|
59
|
+
const root = screen.getByRole("radiogroup", { name: "Mode" });
|
|
60
|
+
|
|
61
|
+
// Assert
|
|
62
|
+
expect(root).toHaveAttribute("aria-orientation", "horizontal");
|
|
63
|
+
expect(root).toHaveAttribute("data-orientation", "horizontal");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("reflects vertical orientation on the root when requested", () => {
|
|
67
|
+
// Arrange & Act
|
|
68
|
+
render(
|
|
69
|
+
<SegmentedControl.Root aria-label="Mode" orientation="vertical">
|
|
70
|
+
<SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
|
|
71
|
+
</SegmentedControl.Root>,
|
|
72
|
+
);
|
|
73
|
+
const root = screen.getByRole("radiogroup", { name: "Mode" });
|
|
74
|
+
|
|
75
|
+
// Assert
|
|
76
|
+
expect(root).toHaveAttribute("aria-orientation", "vertical");
|
|
77
|
+
expect(root).toHaveAttribute("data-orientation", "vertical");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("does not set data-disabled on the root by default", () => {
|
|
81
|
+
// Arrange & Act
|
|
82
|
+
render(
|
|
83
|
+
<SegmentedControl.Root aria-label="Mode">
|
|
84
|
+
<SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
|
|
85
|
+
</SegmentedControl.Root>,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Assert
|
|
89
|
+
expect(
|
|
90
|
+
screen.getByRole("radiogroup", { name: "Mode" }),
|
|
91
|
+
).not.toHaveAttribute("data-disabled");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("sets a displayName on the compound and Item", () => {
|
|
95
|
+
// Assert — empty displayNames would render each as anonymous in DevTools.
|
|
96
|
+
// Root aliases the compound (Object.assign), so its name is "SegmentedControl".
|
|
97
|
+
expect(SegmentedControl.displayName).toBe("SegmentedControl");
|
|
98
|
+
expect(SegmentedControl.Item.displayName).toBe("SegmentedControlItem");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { SegmentedControl } from "../SegmentedControl";
|
|
6
|
+
|
|
7
|
+
describe("SegmentedControl controlled state", () => {
|
|
8
|
+
it("reflects the controlled `value` prop", () => {
|
|
9
|
+
// Arrange & Act
|
|
10
|
+
const { rerender } = render(
|
|
11
|
+
<SegmentedControl.Root
|
|
12
|
+
aria-label="Mode"
|
|
13
|
+
value="headless"
|
|
14
|
+
onValueChange={() => {}}
|
|
15
|
+
>
|
|
16
|
+
<SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
|
|
17
|
+
<SegmentedControl.Item value="styled">Styled</SegmentedControl.Item>
|
|
18
|
+
</SegmentedControl.Root>,
|
|
19
|
+
);
|
|
20
|
+
expect(screen.getByRole("radio", { name: "Headless" })).toHaveAttribute(
|
|
21
|
+
"aria-checked",
|
|
22
|
+
"true",
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
rerender(
|
|
26
|
+
<SegmentedControl.Root
|
|
27
|
+
aria-label="Mode"
|
|
28
|
+
value="styled"
|
|
29
|
+
onValueChange={() => {}}
|
|
30
|
+
>
|
|
31
|
+
<SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
|
|
32
|
+
<SegmentedControl.Item value="styled">Styled</SegmentedControl.Item>
|
|
33
|
+
</SegmentedControl.Root>,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// Assert
|
|
37
|
+
expect(screen.getByRole("radio", { name: "Headless" })).toHaveAttribute(
|
|
38
|
+
"aria-checked",
|
|
39
|
+
"false",
|
|
40
|
+
);
|
|
41
|
+
expect(screen.getByRole("radio", { name: "Styled" })).toHaveAttribute(
|
|
42
|
+
"aria-checked",
|
|
43
|
+
"true",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("does not update rendered state when the parent refuses to update `value`", async () => {
|
|
48
|
+
// Arrange
|
|
49
|
+
const user = userEvent.setup();
|
|
50
|
+
const onValueChange = vi.fn();
|
|
51
|
+
render(
|
|
52
|
+
<SegmentedControl.Root
|
|
53
|
+
aria-label="Mode"
|
|
54
|
+
value="headless"
|
|
55
|
+
onValueChange={onValueChange}
|
|
56
|
+
>
|
|
57
|
+
<SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
|
|
58
|
+
<SegmentedControl.Item value="styled">Styled</SegmentedControl.Item>
|
|
59
|
+
</SegmentedControl.Root>,
|
|
60
|
+
);
|
|
61
|
+
const styled = screen.getByRole("radio", { name: "Styled" });
|
|
62
|
+
|
|
63
|
+
// Act
|
|
64
|
+
await user.click(styled);
|
|
65
|
+
|
|
66
|
+
// Assert: callback fires but the pinned `value` keeps state on "headless".
|
|
67
|
+
expect(onValueChange).toHaveBeenCalledWith("styled");
|
|
68
|
+
expect(screen.getByRole("radio", { name: "Headless" })).toHaveAttribute(
|
|
69
|
+
"aria-checked",
|
|
70
|
+
"true",
|
|
71
|
+
);
|
|
72
|
+
expect(styled).toHaveAttribute("aria-checked", "false");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("lets a parent drive the value end to end", async () => {
|
|
76
|
+
// Arrange
|
|
77
|
+
const user = userEvent.setup();
|
|
78
|
+
function Harness() {
|
|
79
|
+
const [value, setValue] = useState("styled");
|
|
80
|
+
return (
|
|
81
|
+
<SegmentedControl.Root
|
|
82
|
+
aria-label="Mode"
|
|
83
|
+
value={value}
|
|
84
|
+
onValueChange={setValue}
|
|
85
|
+
>
|
|
86
|
+
<SegmentedControl.Item value="headless">
|
|
87
|
+
Headless
|
|
88
|
+
</SegmentedControl.Item>
|
|
89
|
+
<SegmentedControl.Item value="styled">Styled</SegmentedControl.Item>
|
|
90
|
+
</SegmentedControl.Root>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
render(<Harness />);
|
|
94
|
+
const headless = screen.getByRole("radio", { name: "Headless" });
|
|
95
|
+
const styled = screen.getByRole("radio", { name: "Styled" });
|
|
96
|
+
expect(styled).toHaveAttribute("aria-checked", "true");
|
|
97
|
+
|
|
98
|
+
// Act & Assert
|
|
99
|
+
await user.click(headless);
|
|
100
|
+
expect(headless).toHaveAttribute("aria-checked", "true");
|
|
101
|
+
expect(styled).toHaveAttribute("aria-checked", "false");
|
|
102
|
+
|
|
103
|
+
await user.click(styled);
|
|
104
|
+
expect(styled).toHaveAttribute("aria-checked", "true");
|
|
105
|
+
expect(headless).toHaveAttribute("aria-checked", "false");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
|
|
3
|
+
import { SegmentedControl } from "../SegmentedControl";
|
|
4
|
+
|
|
5
|
+
describe("SegmentedControl arrow-key guards for disabled focus", () => {
|
|
6
|
+
it("does nothing when the focused segment is disabled but others are enabled", () => {
|
|
7
|
+
// Arrange: `asChild` onto `<li>` so `disabled` doesn't stop the
|
|
8
|
+
// element receiving the keydown the way a native `<button disabled>` would.
|
|
9
|
+
const onValueChange = vi.fn();
|
|
10
|
+
render(
|
|
11
|
+
<SegmentedControl.Root aria-label="Mode" onValueChange={onValueChange}>
|
|
12
|
+
<SegmentedControl.Item value="a" disabled asChild>
|
|
13
|
+
<li>A</li>
|
|
14
|
+
</SegmentedControl.Item>
|
|
15
|
+
<SegmentedControl.Item value="b">B</SegmentedControl.Item>
|
|
16
|
+
<SegmentedControl.Item value="c">C</SegmentedControl.Item>
|
|
17
|
+
</SegmentedControl.Root>,
|
|
18
|
+
);
|
|
19
|
+
const a = screen.getByRole("radio", { name: "A" });
|
|
20
|
+
|
|
21
|
+
// Act: ArrowRight while focus is on a disabled segment
|
|
22
|
+
fireEvent.keyDown(a, { key: "ArrowRight" });
|
|
23
|
+
|
|
24
|
+
// Assert: nothing selected — the handler bailed on the
|
|
25
|
+
// "focused item not in enabledValues" guard.
|
|
26
|
+
expect(onValueChange).not.toHaveBeenCalled();
|
|
27
|
+
expect(screen.getByRole("radio", { name: "B" })).toHaveAttribute(
|
|
28
|
+
"aria-checked",
|
|
29
|
+
"false",
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("does nothing when the whole control is disabled (empty navigable set)", () => {
|
|
34
|
+
// Arrange: group-disabled means no segment is navigable; without the
|
|
35
|
+
// empty-navigable guard, arrowing would compute an undefined target.
|
|
36
|
+
const onValueChange = vi.fn();
|
|
37
|
+
render(
|
|
38
|
+
<SegmentedControl.Root
|
|
39
|
+
aria-label="Mode"
|
|
40
|
+
disabled
|
|
41
|
+
defaultValue="a"
|
|
42
|
+
onValueChange={onValueChange}
|
|
43
|
+
>
|
|
44
|
+
<SegmentedControl.Item value="a" asChild>
|
|
45
|
+
<li>A</li>
|
|
46
|
+
</SegmentedControl.Item>
|
|
47
|
+
<SegmentedControl.Item value="b" asChild>
|
|
48
|
+
<li>B</li>
|
|
49
|
+
</SegmentedControl.Item>
|
|
50
|
+
</SegmentedControl.Root>,
|
|
51
|
+
);
|
|
52
|
+
const a = screen.getByRole("radio", { name: "A" });
|
|
53
|
+
expect(a).toHaveAttribute("aria-checked", "true");
|
|
54
|
+
|
|
55
|
+
// Act
|
|
56
|
+
fireEvent.keyDown(a, { key: "ArrowRight" });
|
|
57
|
+
|
|
58
|
+
// Assert: the pre-existing selection survives and no callback fires.
|
|
59
|
+
expect(onValueChange).not.toHaveBeenCalled();
|
|
60
|
+
expect(a).toHaveAttribute("aria-checked", "true");
|
|
61
|
+
});
|
|
62
|
+
});
|