@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
package/src/RadioCard/types.ts
CHANGED
|
@@ -10,63 +10,136 @@ export type RadioCardOrientation = "horizontal" | "vertical" | "both";
|
|
|
10
10
|
/** Reading direction — swaps the horizontal arrow pair when `"rtl"`. */
|
|
11
11
|
export type RadioCardReadingDirection = "ltr" | "rtl";
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
*
|
|
15
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Shared base for both {@link RadioCardRootProps} variants — the native
|
|
15
|
+
* `<div>` attributes (minus the component-owned `role` and the narrowed
|
|
16
|
+
* `dir`) plus the `asChild` escape hatch, orientation, reading direction,
|
|
17
|
+
* and a typed `ref`.
|
|
18
|
+
*/
|
|
19
|
+
export type RadioCardRootBaseProps = Omit<
|
|
20
|
+
ComponentProps<"div">,
|
|
21
|
+
"role" | "dir" | "defaultValue"
|
|
22
|
+
> & {
|
|
23
|
+
/** The group's cards — typically {@link RadioCardItemProps | `RadioCard.Item`}
|
|
24
|
+
* elements, each optionally wrapping a {@link RadioCardIndicatorProps | `RadioCard.Indicator`}. */
|
|
16
25
|
children?: ReactNode;
|
|
17
|
-
/**
|
|
26
|
+
/** Forwarded to the underlying `HTMLDivElement`. */
|
|
18
27
|
ref?: Ref<HTMLDivElement>;
|
|
19
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* When `true`, Root delegates rendering to a single consumer-supplied
|
|
30
|
+
* element via the {@link Slot} pattern instead of the default `<div>`;
|
|
31
|
+
* `role="radiogroup"`, `aria-orientation`, `dir`, and remaining props
|
|
32
|
+
* are merged onto it.
|
|
33
|
+
* @default false
|
|
34
|
+
*/
|
|
20
35
|
asChild?: boolean;
|
|
21
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Which arrow keys move focus and selection through the group.
|
|
38
|
+
* `"both"` enables all four arrows; `"horizontal"` only Arrow
|
|
39
|
+
* Left/Right; `"vertical"` only Arrow Up/Down. When not `"both"`, the
|
|
40
|
+
* value is also reflected as `aria-orientation` on the root.
|
|
41
|
+
* @default "both"
|
|
42
|
+
*/
|
|
22
43
|
orientation?: RadioCardOrientation;
|
|
23
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Reading direction. In `"rtl"` the horizontal arrow pair is swapped so
|
|
46
|
+
* Arrow Left moves forward. When omitted, it is inherited from the
|
|
47
|
+
* nearest {@link DirectionProvider}, falling back to `"ltr"`.
|
|
48
|
+
*/
|
|
24
49
|
dir?: RadioCardReadingDirection;
|
|
25
50
|
};
|
|
26
51
|
|
|
27
|
-
/**
|
|
28
|
-
*
|
|
52
|
+
/**
|
|
53
|
+
* Uncontrolled variant of {@link RadioCardRootProps}: the component owns
|
|
54
|
+
* the selected value. Pass `defaultValue` (or omit it); `onValueChange` is
|
|
55
|
+
* optional and `value` is forbidden.
|
|
56
|
+
*/
|
|
29
57
|
export type RadioCardRootUncontrolledProps = RadioCardRootBaseProps & {
|
|
30
|
-
/** Value selected on first render.
|
|
58
|
+
/** Value of the card selected on first render. Omit for nothing selected
|
|
59
|
+
* on mount. */
|
|
31
60
|
defaultValue?: string;
|
|
61
|
+
/** Forbidden in uncontrolled mode — use `defaultValue` instead. */
|
|
32
62
|
value?: never;
|
|
33
|
-
/** Called with the value
|
|
63
|
+
/** Called with the new value whenever the selection changes. Optional in
|
|
64
|
+
* uncontrolled mode. */
|
|
34
65
|
onValueChange?: (value: string) => void;
|
|
35
66
|
};
|
|
36
67
|
|
|
37
|
-
/**
|
|
38
|
-
*
|
|
68
|
+
/**
|
|
69
|
+
* Controlled variant of {@link RadioCardRootProps}: the parent owns the
|
|
70
|
+
* selected value. Pass `value` and `onValueChange` together; `defaultValue`
|
|
71
|
+
* is forbidden.
|
|
72
|
+
*/
|
|
39
73
|
export type RadioCardRootControlledProps = RadioCardRootBaseProps & {
|
|
74
|
+
/** Forbidden in controlled mode — use `value` instead. */
|
|
40
75
|
defaultValue?: never;
|
|
41
|
-
/**
|
|
76
|
+
/** The currently selected value. Must be kept in sync by the parent via
|
|
77
|
+
* `onValueChange`. */
|
|
42
78
|
value: string;
|
|
43
|
-
/** Called with the value
|
|
79
|
+
/** Called with the new value whenever the user selects a card. Required
|
|
80
|
+
* in controlled mode. */
|
|
44
81
|
onValueChange: (value: string) => void;
|
|
45
82
|
};
|
|
46
83
|
|
|
47
|
-
/**
|
|
48
|
-
*
|
|
84
|
+
/**
|
|
85
|
+
* Props for {@link RadioCard.Root}. A discriminated union of
|
|
86
|
+
* {@link RadioCardRootUncontrolledProps} and
|
|
87
|
+
* {@link RadioCardRootControlledProps}, so TypeScript accepts exactly one
|
|
88
|
+
* state mode.
|
|
89
|
+
*/
|
|
49
90
|
export type RadioCardRootProps =
|
|
50
91
|
| RadioCardRootUncontrolledProps
|
|
51
92
|
| RadioCardRootControlledProps;
|
|
52
93
|
|
|
53
|
-
/**
|
|
54
|
-
*
|
|
94
|
+
/**
|
|
95
|
+
* Props for {@link RadioCardItem | `RadioCard.Item`} — a selectable card
|
|
96
|
+
* rendered as a `role="radio"` button. `value` identifies the option; all
|
|
97
|
+
* native `<button>` attributes (minus the component-owned `type`, `role`,
|
|
98
|
+
* `aria-checked`, and `value`) plus the `asChild` escape hatch and a typed
|
|
99
|
+
* `ref` are passed through.
|
|
100
|
+
*/
|
|
55
101
|
export type RadioCardItemProps = Omit<
|
|
56
102
|
ComponentProps<"button">,
|
|
57
103
|
"type" | "role" | "aria-checked" | "value"
|
|
58
104
|
> & {
|
|
105
|
+
/** Uniquely identifies this card within the group and is reported to
|
|
106
|
+
* `onValueChange` / matched against Root's `value` when selected. Required. */
|
|
59
107
|
value: string;
|
|
108
|
+
/** The card's content — the whole surface is the interactive control,
|
|
109
|
+
* and typically includes a {@link RadioCardIndicatorProps | `RadioCard.Indicator`}. */
|
|
60
110
|
children?: ReactNode;
|
|
111
|
+
/** Forwarded to the underlying `HTMLButtonElement`. */
|
|
61
112
|
ref?: Ref<HTMLButtonElement>;
|
|
113
|
+
/**
|
|
114
|
+
* When `true`, renders the single child element via the {@link Slot}
|
|
115
|
+
* pattern instead of the default `<button>`; ARIA, `data-state`,
|
|
116
|
+
* `tabIndex`, `onClick`, `onKeyDown`, `disabled`, and `ref` are merged
|
|
117
|
+
* onto it. A non-focusable child must be made focusable by the consumer.
|
|
118
|
+
* @default false
|
|
119
|
+
*/
|
|
62
120
|
asChild?: boolean;
|
|
63
121
|
};
|
|
64
122
|
|
|
65
|
-
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
123
|
+
/**
|
|
124
|
+
* Props for {@link RadioCardIndicator | `RadioCard.Indicator`} — the visual
|
|
125
|
+
* marker rendered inside the selected card. All native `<span>` attributes
|
|
126
|
+
* plus `forceMount` and the `asChild` escape hatch.
|
|
127
|
+
*/
|
|
68
128
|
export type RadioCardIndicatorProps = ComponentProps<"span"> & {
|
|
129
|
+
/** The visual mark shown while the parent Item is selected. */
|
|
69
130
|
children?: ReactNode;
|
|
131
|
+
/**
|
|
132
|
+
* When `true`, keeps the indicator mounted while unchecked so a CSS exit
|
|
133
|
+
* animation can play against `data-state="unchecked"`. Consumers who set
|
|
134
|
+
* it own the exit lifecycle.
|
|
135
|
+
* @default false
|
|
136
|
+
*/
|
|
70
137
|
forceMount?: boolean;
|
|
138
|
+
/**
|
|
139
|
+
* When `true`, renders the single child element via the {@link Slot}
|
|
140
|
+
* pattern instead of the default `<span>`; `aria-hidden` and `data-state`
|
|
141
|
+
* are merged onto it.
|
|
142
|
+
* @default false
|
|
143
|
+
*/
|
|
71
144
|
asChild?: boolean;
|
|
72
145
|
};
|
|
@@ -50,6 +50,8 @@ import {
|
|
|
50
50
|
* element (e.g. `<menu role="menu">` for dropdown composition) with
|
|
51
51
|
* the RadioGroup's props merged in. The native `<div>` is dropped.
|
|
52
52
|
*
|
|
53
|
+
* @extends HTMLDivElement
|
|
54
|
+
*
|
|
53
55
|
* @example Uncontrolled
|
|
54
56
|
* ```tsx
|
|
55
57
|
* <RadioGroup.Root defaultValue="comfortable" aria-label="Density">
|
|
@@ -131,6 +133,11 @@ export function RadioGroupRoot({
|
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
/** @internal */
|
|
136
|
+
// Runtime-dead: the compound alias below (same object via Object.assign)
|
|
137
|
+
// overwrites this to "RadioGroup" at load, so the value is never observable.
|
|
138
|
+
// The assignment stays because it declares `displayName` on
|
|
139
|
+
// `typeof RadioGroupRoot`, which TRadioGroupCompound extends.
|
|
140
|
+
// Stryker disable next-line StringLiteral: overwritten by the compound alias — an equivalent mutant.
|
|
134
141
|
RadioGroupRoot.displayName = "RadioGroupRoot";
|
|
135
142
|
|
|
136
143
|
/**
|
|
@@ -163,6 +170,8 @@ RadioGroupRoot.displayName = "RadioGroupRoot";
|
|
|
163
170
|
* consumers who render a non-focusable element are responsible for
|
|
164
171
|
* making it focusable.
|
|
165
172
|
*
|
|
173
|
+
* @extends HTMLButtonElement
|
|
174
|
+
*
|
|
166
175
|
* @throws if rendered outside a `RadioGroup.Root`.
|
|
167
176
|
*/
|
|
168
177
|
export function RadioGroupItem({
|
|
@@ -263,6 +272,8 @@ RadioGroupItem.displayName = "RadioGroupItem";
|
|
|
263
272
|
* `data-state="unchecked"`. Consumers who use `forceMount` own the
|
|
264
273
|
* exit lifecycle themselves.
|
|
265
274
|
*
|
|
275
|
+
* @extends HTMLSpanElement
|
|
276
|
+
*
|
|
266
277
|
* @example Default span wrapper
|
|
267
278
|
* ```tsx
|
|
268
279
|
* <RadioGroup.Item value="red">
|
|
@@ -69,4 +69,12 @@ describe("RadioGroup basic rendering", () => {
|
|
|
69
69
|
"unchecked",
|
|
70
70
|
);
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
it("sets a displayName on the compound, Item, and Indicator", () => {
|
|
74
|
+
// Assert — empty displayNames would render each as anonymous in DevTools.
|
|
75
|
+
// Root aliases the compound (Object.assign), so its name is "RadioGroup".
|
|
76
|
+
expect(RadioGroup.displayName).toBe("RadioGroup");
|
|
77
|
+
expect(RadioGroup.Item.displayName).toBe("RadioGroupItem");
|
|
78
|
+
expect(RadioGroup.Indicator.displayName).toBe("RadioGroupIndicator");
|
|
79
|
+
});
|
|
72
80
|
});
|
|
@@ -54,6 +54,33 @@ describe("RadioGroup disabled", () => {
|
|
|
54
54
|
expect(red).toHaveAttribute("aria-checked", "true");
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
it("re-registers an item's disabled flag when it changes after mount", async () => {
|
|
58
|
+
// Arrange — all enabled at mount; green becomes disabled afterwards.
|
|
59
|
+
const user = userEvent.setup();
|
|
60
|
+
function Fixture({ greenDisabled }: { greenDisabled: boolean }) {
|
|
61
|
+
return (
|
|
62
|
+
<RadioGroup.Root aria-label="Colour" defaultValue="red">
|
|
63
|
+
<RadioGroup.Item value="red">Red</RadioGroup.Item>
|
|
64
|
+
<RadioGroup.Item value="green" disabled={greenDisabled}>
|
|
65
|
+
Green
|
|
66
|
+
</RadioGroup.Item>
|
|
67
|
+
<RadioGroup.Item value="blue">Blue</RadioGroup.Item>
|
|
68
|
+
</RadioGroup.Root>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const { rerender } = render(<Fixture greenDisabled={false} />);
|
|
72
|
+
|
|
73
|
+
// Act — disable green after mount, then arrow forwards from red.
|
|
74
|
+
rerender(<Fixture greenDisabled />);
|
|
75
|
+
screen.getByRole("radio", { name: "Red" }).focus();
|
|
76
|
+
await user.keyboard("{ArrowDown}");
|
|
77
|
+
|
|
78
|
+
// Assert — the registrar effect re-ran, so green is skipped and focus lands
|
|
79
|
+
// on blue. A stale registration would keep green navigable and strand focus
|
|
80
|
+
// (a disabled button cannot receive it).
|
|
81
|
+
expect(screen.getByRole("radio", { name: "Blue" })).toHaveFocus();
|
|
82
|
+
});
|
|
83
|
+
|
|
57
84
|
it("skips disabled items when computing the home-base tab stop", () => {
|
|
58
85
|
// Arrange & Act
|
|
59
86
|
render(
|
|
@@ -42,4 +42,33 @@ describe("RadioGroup.Item ref forwarding", () => {
|
|
|
42
42
|
const button = screen.getByRole("radio", { name: "Red" });
|
|
43
43
|
expect(objectRef.current).toBe(button);
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
it("re-attaches to a replacement ref when the ref prop changes", () => {
|
|
47
|
+
// Arrange — the item's ref memo must depend on `ref`, or a changed ref
|
|
48
|
+
// callback is never invoked with the element.
|
|
49
|
+
const secondReceived: (HTMLButtonElement | null)[] = [];
|
|
50
|
+
const firstRef = () => {};
|
|
51
|
+
const secondRef = (node: HTMLButtonElement | null) => {
|
|
52
|
+
secondReceived.push(node);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Act — mount with the first ref, then re-render with a different one.
|
|
56
|
+
const { rerender } = render(
|
|
57
|
+
<RadioGroup.Root aria-label="Colour">
|
|
58
|
+
<RadioGroup.Item value="red" ref={firstRef}>
|
|
59
|
+
Red
|
|
60
|
+
</RadioGroup.Item>
|
|
61
|
+
</RadioGroup.Root>,
|
|
62
|
+
);
|
|
63
|
+
rerender(
|
|
64
|
+
<RadioGroup.Root aria-label="Colour">
|
|
65
|
+
<RadioGroup.Item value="red" ref={secondRef}>
|
|
66
|
+
Red
|
|
67
|
+
</RadioGroup.Item>
|
|
68
|
+
</RadioGroup.Root>,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Assert — the replacement ref received the button.
|
|
72
|
+
expect(secondReceived).toContain(screen.getByRole("radio", { name: "Red" }));
|
|
73
|
+
});
|
|
45
74
|
});
|
|
@@ -75,6 +75,37 @@ describe("RadioGroup roving tabindex", () => {
|
|
|
75
75
|
expect(green).toHaveAttribute("tabindex", "0");
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
it("unregisters an unmounted item so the home-base tab stop moves", () => {
|
|
79
|
+
// Arrange — nothing selected, so the first item is the home base.
|
|
80
|
+
function Fixture({ items }: { items: string[] }) {
|
|
81
|
+
return (
|
|
82
|
+
<RadioGroup.Root aria-label="Colour">
|
|
83
|
+
{items.map((v) => (
|
|
84
|
+
<RadioGroup.Item key={v} value={v}>
|
|
85
|
+
{v}
|
|
86
|
+
</RadioGroup.Item>
|
|
87
|
+
))}
|
|
88
|
+
</RadioGroup.Root>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const { rerender } = render(<Fixture items={["red", "green"]} />);
|
|
92
|
+
expect(screen.getByRole("radio", { name: "red" })).toHaveAttribute(
|
|
93
|
+
"tabindex",
|
|
94
|
+
"0",
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Act — remove "red"; its effect cleanup must unregister it.
|
|
98
|
+
rerender(<Fixture items={["green"]} />);
|
|
99
|
+
|
|
100
|
+
// Assert — with "red" gone from the registry, "green" is the home base. A
|
|
101
|
+
// no-op cleanup would leave "red" registered ahead of it, stranding the tab
|
|
102
|
+
// stop and leaving "green" at tabindex -1.
|
|
103
|
+
expect(screen.getByRole("radio", { name: "green" })).toHaveAttribute(
|
|
104
|
+
"tabindex",
|
|
105
|
+
"0",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
78
109
|
it("tabs into the single home-base item and not the others", async () => {
|
|
79
110
|
// Arrange
|
|
80
111
|
const user = userEvent.setup();
|
|
@@ -52,6 +52,9 @@ export function useRadioGroupRoot({
|
|
|
52
52
|
) => {
|
|
53
53
|
registerItemBase(itemValue, element ? { element, disabled } : null);
|
|
54
54
|
},
|
|
55
|
+
// `registerItemBase` is a stable useCollection callback (its own deps never
|
|
56
|
+
// change), so emptying this array yields the identical memoised function.
|
|
57
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — stable dependency.
|
|
55
58
|
[registerItemBase],
|
|
56
59
|
);
|
|
57
60
|
|
|
@@ -71,8 +74,16 @@ export function useRadioGroupRoot({
|
|
|
71
74
|
|
|
72
75
|
const focusItem = useCallback(
|
|
73
76
|
(itemValue: string) => {
|
|
77
|
+
// `itemValue` is always sourced from `navigable` (the enabled subset of
|
|
78
|
+
// `itemValues`), and `useCollection`'s register() writes `itemsRef` and
|
|
79
|
+
// the `itemValues` state together in the same call — so a value that
|
|
80
|
+
// appears here is guaranteed to already have a live entry in the map.
|
|
81
|
+
// Stryker disable next-line OptionalChaining: unreachable given that invariant.
|
|
74
82
|
itemsRef.current.get(itemValue)?.element.focus();
|
|
75
83
|
},
|
|
84
|
+
// `itemsRef` is a stable RefObject, so emptying this array yields the
|
|
85
|
+
// identical memoised function.
|
|
86
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — stable dependency.
|
|
76
87
|
[itemsRef],
|
|
77
88
|
);
|
|
78
89
|
|
package/src/RadioGroup/types.ts
CHANGED
|
@@ -12,14 +12,40 @@ export type RadioGroupReadingDirection = "ltr" | "rtl";
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Shared base for both {@link RadioGroupRootProps} variants — the native
|
|
15
|
-
* `<div>` attributes (minus `role`
|
|
16
|
-
* orientation, reading direction,
|
|
15
|
+
* `<div>` attributes (minus the component-owned `role` and the narrowed
|
|
16
|
+
* `dir`) plus the `asChild` escape hatch, orientation, reading direction,
|
|
17
|
+
* and a typed `ref`.
|
|
17
18
|
*/
|
|
18
|
-
export type RadioGroupRootBaseProps = Omit<
|
|
19
|
+
export type RadioGroupRootBaseProps = Omit<
|
|
20
|
+
ComponentProps<"div">,
|
|
21
|
+
"role" | "dir" | "defaultValue"
|
|
22
|
+
> & {
|
|
23
|
+
/** The group's radios — typically {@link RadioGroupItemProps | `RadioGroup.Item`}
|
|
24
|
+
* elements, each optionally wrapping a {@link RadioGroupIndicatorProps | `RadioGroup.Indicator`}. */
|
|
19
25
|
children?: ReactNode;
|
|
26
|
+
/** Forwarded to the underlying `HTMLDivElement`. */
|
|
20
27
|
ref?: Ref<HTMLDivElement>;
|
|
28
|
+
/**
|
|
29
|
+
* When `true`, Root delegates rendering to a single consumer-supplied
|
|
30
|
+
* element (e.g. `<menu role="menu">` for dropdown composition) via the
|
|
31
|
+
* {@link Slot} pattern instead of the default `<div>`; `role="radiogroup"`,
|
|
32
|
+
* `aria-orientation`, `dir`, and remaining props are merged onto it.
|
|
33
|
+
* @default false
|
|
34
|
+
*/
|
|
21
35
|
asChild?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Which arrow keys move focus and selection through the group.
|
|
38
|
+
* `"both"` enables all four arrows; `"horizontal"` only Arrow
|
|
39
|
+
* Left/Right; `"vertical"` only Arrow Up/Down. When not `"both"`, the
|
|
40
|
+
* value is also reflected as `aria-orientation` on the root.
|
|
41
|
+
* @default "both"
|
|
42
|
+
*/
|
|
22
43
|
orientation?: RadioGroupOrientation;
|
|
44
|
+
/**
|
|
45
|
+
* Reading direction. In `"rtl"` the horizontal arrow pair is swapped so
|
|
46
|
+
* Arrow Left moves forward. When omitted, it is inherited from the
|
|
47
|
+
* nearest {@link DirectionProvider}, falling back to `"ltr"`.
|
|
48
|
+
*/
|
|
23
49
|
dir?: RadioGroupReadingDirection;
|
|
24
50
|
};
|
|
25
51
|
|
|
@@ -29,8 +55,13 @@ export type RadioGroupRootBaseProps = Omit<ComponentProps<"div">, "role"> & {
|
|
|
29
55
|
* optional and `value` is forbidden.
|
|
30
56
|
*/
|
|
31
57
|
export type RadioGroupRootUncontrolledProps = RadioGroupRootBaseProps & {
|
|
58
|
+
/** Value of the radio selected on first render. Omit for nothing
|
|
59
|
+
* selected on mount. */
|
|
32
60
|
defaultValue?: string;
|
|
61
|
+
/** Forbidden in uncontrolled mode — use `defaultValue` instead. */
|
|
33
62
|
value?: never;
|
|
63
|
+
/** Called with the new value whenever the selection changes. Optional
|
|
64
|
+
* in uncontrolled mode. */
|
|
34
65
|
onValueChange?: (value: string) => void;
|
|
35
66
|
};
|
|
36
67
|
|
|
@@ -40,8 +71,13 @@ export type RadioGroupRootUncontrolledProps = RadioGroupRootBaseProps & {
|
|
|
40
71
|
* is forbidden.
|
|
41
72
|
*/
|
|
42
73
|
export type RadioGroupRootControlledProps = RadioGroupRootBaseProps & {
|
|
74
|
+
/** Forbidden in controlled mode — use `value` instead. */
|
|
43
75
|
defaultValue?: never;
|
|
76
|
+
/** The currently selected value. Must be kept in sync by the parent
|
|
77
|
+
* via `onValueChange`. */
|
|
44
78
|
value: string;
|
|
79
|
+
/** Called with the new value whenever the selection changes. Required
|
|
80
|
+
* in controlled mode. */
|
|
45
81
|
onValueChange: (value: string) => void;
|
|
46
82
|
};
|
|
47
83
|
|
|
@@ -56,27 +92,55 @@ export type RadioGroupRootProps =
|
|
|
56
92
|
| RadioGroupRootControlledProps;
|
|
57
93
|
|
|
58
94
|
/**
|
|
59
|
-
* Props for {@link RadioGroup.Item} — the radio button.
|
|
60
|
-
* the option; all native `<button>` attributes (minus
|
|
61
|
-
*
|
|
95
|
+
* Props for {@link RadioGroupItem | `RadioGroup.Item`} — the radio button.
|
|
96
|
+
* `value` identifies the option; all native `<button>` attributes (minus
|
|
97
|
+
* the component-owned `type`, `role`, `aria-checked`, and `value`) plus the
|
|
98
|
+
* `asChild` escape hatch and a typed `ref` are passed through.
|
|
62
99
|
*/
|
|
63
100
|
export type RadioGroupItemProps = Omit<
|
|
64
101
|
ComponentProps<"button">,
|
|
65
102
|
"type" | "role" | "aria-checked" | "value"
|
|
66
103
|
> & {
|
|
104
|
+
/** Uniquely identifies this radio within the group and is reported to
|
|
105
|
+
* `onValueChange` / matched against Root's `value` when selected. Required. */
|
|
67
106
|
value: string;
|
|
107
|
+
/** The radio's visible content — its label, and typically a leading
|
|
108
|
+
* {@link RadioGroupIndicatorProps | `RadioGroup.Indicator`}. */
|
|
68
109
|
children?: ReactNode;
|
|
110
|
+
/** Forwarded to the underlying `HTMLButtonElement`. */
|
|
69
111
|
ref?: Ref<HTMLButtonElement>;
|
|
112
|
+
/**
|
|
113
|
+
* When `true`, renders the single child element (e.g.
|
|
114
|
+
* `<li role="menuitemradio">`) via the {@link Slot} pattern instead of
|
|
115
|
+
* the default `<button>`; ARIA, `data-state`, `tabIndex`, `onClick`,
|
|
116
|
+
* `onKeyDown`, `disabled`, and `ref` are merged onto it. A non-focusable
|
|
117
|
+
* child must be made focusable by the consumer.
|
|
118
|
+
* @default false
|
|
119
|
+
*/
|
|
70
120
|
asChild?: boolean;
|
|
71
121
|
};
|
|
72
122
|
|
|
73
123
|
/**
|
|
74
|
-
* Props for {@link RadioGroup.Indicator} — all
|
|
75
|
-
*
|
|
76
|
-
*
|
|
124
|
+
* Props for {@link RadioGroupIndicator | `RadioGroup.Indicator`} — all
|
|
125
|
+
* native `<span>` attributes plus `forceMount` and the `asChild` escape
|
|
126
|
+
* hatch.
|
|
77
127
|
*/
|
|
78
128
|
export type RadioGroupIndicatorProps = ComponentProps<"span"> & {
|
|
129
|
+
/** The visual mark (e.g. a filled dot) shown while the parent Item is
|
|
130
|
+
* selected. */
|
|
79
131
|
children?: ReactNode;
|
|
132
|
+
/**
|
|
133
|
+
* When `true`, keeps the indicator mounted while unchecked so a CSS exit
|
|
134
|
+
* animation can play against `data-state="unchecked"`. Consumers who set
|
|
135
|
+
* it own the exit lifecycle.
|
|
136
|
+
* @default false
|
|
137
|
+
*/
|
|
80
138
|
forceMount?: boolean;
|
|
139
|
+
/**
|
|
140
|
+
* When `true`, renders the single child element (typically an `<svg>`
|
|
141
|
+
* dot) via the {@link Slot} pattern instead of the default `<span>`;
|
|
142
|
+
* `aria-hidden` and `data-state` are merged onto it.
|
|
143
|
+
* @default false
|
|
144
|
+
*/
|
|
81
145
|
asChild?: boolean;
|
|
82
146
|
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# SegmentedControl
|
|
2
|
+
|
|
3
|
+
A headless, accessible compound component for picking **one** option from a
|
|
4
|
+
small linear set — the segmented-control pattern (iOS-style pill strip,
|
|
5
|
+
"consumption mode" switch, view toggles). It implements the
|
|
6
|
+
[WAI-ARIA Radio Group pattern](https://www.w3.org/WAI/ARIA/apg/patterns/radio/),
|
|
7
|
+
so exactly one segment is always selected and the selection can never be
|
|
8
|
+
cleared to nothing.
|
|
9
|
+
|
|
10
|
+
SegmentedControl renders native `<button role="radio">` elements wrapped in a
|
|
11
|
+
`<div role="radiogroup">` so it gets keyboard activation (`Space` / `Enter`),
|
|
12
|
+
focus ring, and disabled semantics for free from the browser. The React layer
|
|
13
|
+
adds single-selection state, roving tabindex, arrow-key navigation, a
|
|
14
|
+
whole-control `disabled`, and the `asChild` composition every primitive in
|
|
15
|
+
this package supports.
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import { SegmentedControl } from "@primitiv-ui/react";
|
|
19
|
+
|
|
20
|
+
<SegmentedControl.Root defaultValue="headless" aria-label="Consumption mode">
|
|
21
|
+
<SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
|
|
22
|
+
<SegmentedControl.Item value="styled">Styled</SegmentedControl.Item>
|
|
23
|
+
<SegmentedControl.Item value="figma">Figma</SegmentedControl.Item>
|
|
24
|
+
</SegmentedControl.Root>;
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## SegmentedControl vs ToggleGroup
|
|
28
|
+
|
|
29
|
+
Reach for `SegmentedControl` when the choice is a **value** — one option is
|
|
30
|
+
always active, like a radio group (view mode, density, a tab-like switch).
|
|
31
|
+
Reach for [`ToggleGroup`](../ToggleGroup/README.md) when the buttons are
|
|
32
|
+
**commands or toggles** that can each be on or off (text formatting, a
|
|
33
|
+
toolbar), including single-select-with-clear. The two look similar but carry
|
|
34
|
+
different semantics: `SegmentedControl` is `role="radiogroup"` /
|
|
35
|
+
`role="radio"` + `aria-checked`; `ToggleGroup` is `role="group"` +
|
|
36
|
+
`aria-pressed` and allows deselecting to nothing.
|
|
37
|
+
|
|
38
|
+
## Sub-components
|
|
39
|
+
|
|
40
|
+
| Export | Element | Notes |
|
|
41
|
+
| -------------------------- | ---------- | ---------------------------------------------------------------------------------------------- |
|
|
42
|
+
| `SegmentedControl.Root` | `<div>` | `role="radiogroup"`, `aria-orientation`, `data-orientation`, `data-disabled`. State owner. `asChild` |
|
|
43
|
+
| `SegmentedControl.Item` | `<button>` | `role="radio"`, `aria-checked`, `data-state`, `data-disabled`, participates in roving tabindex. `asChild` |
|
|
44
|
+
|
|
45
|
+
## State modes
|
|
46
|
+
|
|
47
|
+
- **Uncontrolled** — pass `defaultValue` (or omit for no initial selection).
|
|
48
|
+
- **Controlled** — pass `value` **and** `onValueChange` together.
|
|
49
|
+
|
|
50
|
+
The two shapes are statically discriminated at the type level; TypeScript
|
|
51
|
+
rejects mixing them.
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
// Uncontrolled
|
|
55
|
+
<SegmentedControl.Root defaultValue="headless" aria-label="Mode">
|
|
56
|
+
…
|
|
57
|
+
</SegmentedControl.Root>;
|
|
58
|
+
|
|
59
|
+
// Controlled
|
|
60
|
+
const [value, setValue] = useState("headless");
|
|
61
|
+
<SegmentedControl.Root value={value} onValueChange={setValue} aria-label="Mode">
|
|
62
|
+
…
|
|
63
|
+
</SegmentedControl.Root>;
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Keyboard interaction
|
|
67
|
+
|
|
68
|
+
| Key | Behaviour |
|
|
69
|
+
| -------------------------- | -------------------------------------------------------------------------------- |
|
|
70
|
+
| `Tab` | Moves focus to the control's single tab stop (selected segment, or first if none). |
|
|
71
|
+
| `Space` / `Enter` | Selects the focused segment (native `<button>` activation). |
|
|
72
|
+
| `ArrowRight` / `ArrowLeft` | (horizontal) Moves focus **and** selection to the next / previous segment, wrapping. |
|
|
73
|
+
| `ArrowDown` / `ArrowUp` | (vertical) Moves focus **and** selection to the next / previous segment, wrapping. |
|
|
74
|
+
|
|
75
|
+
The control has exactly one tab stop — the selected segment, or the first
|
|
76
|
+
non-disabled segment if nothing is selected. `Tab` takes the user into and out
|
|
77
|
+
of the control in one keystroke; arrow keys navigate inside it.
|
|
78
|
+
|
|
79
|
+
## Orientation
|
|
80
|
+
|
|
81
|
+
`orientation` defaults to `"horizontal"` (the usual segmented-control layout),
|
|
82
|
+
which binds `ArrowLeft` / `ArrowRight`. Pass `orientation="vertical"` to bind
|
|
83
|
+
`ArrowUp` / `ArrowDown` instead. Either way the axis is reflected to assistive
|
|
84
|
+
technology via `aria-orientation`, and to CSS via `data-orientation`, on the
|
|
85
|
+
`radiogroup` element.
|
|
86
|
+
|
|
87
|
+
## Reading direction
|
|
88
|
+
|
|
89
|
+
Pass `dir` (`"ltr"` / `"rtl"`) to swap the horizontal arrow pair, so
|
|
90
|
+
`ArrowLeft` moves forward and `ArrowRight` moves backward in RTL. The vertical
|
|
91
|
+
pair is never swapped. When `dir` is omitted, it is inherited from the nearest
|
|
92
|
+
[`DirectionProvider`](../DirectionProvider/README.md), falling back to `"ltr"`.
|
|
93
|
+
An explicit `dir` prop always wins over the inherited value.
|
|
94
|
+
|
|
95
|
+
## Disabled
|
|
96
|
+
|
|
97
|
+
Disable a **single segment** by passing `disabled` to a
|
|
98
|
+
`SegmentedControl.Item`. It forwards the native `disabled` attribute (removing
|
|
99
|
+
it from the focus ring and suppressing clicks), sets `data-disabled=""`, and
|
|
100
|
+
excludes the segment from arrow-key navigation and the roving-tabindex home
|
|
101
|
+
base.
|
|
102
|
+
|
|
103
|
+
Disable the **whole control** by passing `disabled` to `SegmentedControl.Root`.
|
|
104
|
+
Every segment becomes disabled, `data-disabled=""` is set on the root, and no
|
|
105
|
+
segment is navigable or takes a tab stop. The current selection is still
|
|
106
|
+
reflected (`aria-checked` / `data-state`) so the control reads correctly while
|
|
107
|
+
inert.
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
<SegmentedControl.Root aria-label="Plan" disabled defaultValue="pro">
|
|
111
|
+
<SegmentedControl.Item value="free">Free</SegmentedControl.Item>
|
|
112
|
+
<SegmentedControl.Item value="pro">Pro</SegmentedControl.Item>
|
|
113
|
+
</SegmentedControl.Root>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## `asChild` composition
|
|
117
|
+
|
|
118
|
+
`SegmentedControl.Root` and `SegmentedControl.Item` each accept an `asChild`
|
|
119
|
+
boolean. When set, the component delegates rendering to its single child
|
|
120
|
+
element and merges its own ARIA attributes, `data-state`, `data-disabled`,
|
|
121
|
+
composed event handlers, and ref onto the child (the asChild contract: the
|
|
122
|
+
child's handler runs first, the library's runs second unless the child calls
|
|
123
|
+
`preventDefault`).
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
<SegmentedControl.Root asChild aria-label="View">
|
|
127
|
+
<nav>
|
|
128
|
+
<SegmentedControl.Item value="grid" asChild>
|
|
129
|
+
<a href="#grid">Grid</a>
|
|
130
|
+
</SegmentedControl.Item>
|
|
131
|
+
<SegmentedControl.Item value="list" asChild>
|
|
132
|
+
<a href="#list">List</a>
|
|
133
|
+
</SegmentedControl.Item>
|
|
134
|
+
</nav>
|
|
135
|
+
</SegmentedControl.Root>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Styling hooks
|
|
139
|
+
|
|
140
|
+
`data-state="checked" | "unchecked"` is set on each `SegmentedControl.Item`,
|
|
141
|
+
and `data-orientation` / `data-disabled` on the root, letting any CSS system
|
|
142
|
+
style the selected segment and the disabled state from plain attribute
|
|
143
|
+
selectors:
|
|
144
|
+
|
|
145
|
+
```css
|
|
146
|
+
button[role="radio"][data-state="checked"] {
|
|
147
|
+
background: var(--primitiv-action-primary-default);
|
|
148
|
+
color: var(--primitiv-action-primary-foreground-default);
|
|
149
|
+
}
|
|
150
|
+
[role="radiogroup"][data-disabled] {
|
|
151
|
+
opacity: 0.5;
|
|
152
|
+
pointer-events: none;
|
|
153
|
+
}
|
|
154
|
+
```
|