@primitiv-ui/react 0.1.27 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +6 -1
- package/src/AccessibleIcon/AccessibleIcon.tsx +32 -9
- package/src/AccessibleIcon/__tests__/AccessibleIcon.test.tsx +5 -0
- package/src/AccessibleIcon/types.ts +15 -2
- package/src/Accordion/Accordion.tsx +25 -4
- package/src/Accordion/__tests__/Accordion.asChild.test.tsx +46 -0
- package/src/Accordion/__tests__/Accordion.basic-rendering.test.tsx +15 -0
- package/src/Accordion/__tests__/Accordion.disabled-items.test.tsx +42 -0
- package/src/Accordion/__tests__/Accordion.error-handling.test.tsx +33 -0
- package/src/Accordion/hooks/useAccordionRoot.ts +44 -7
- package/src/Accordion/hooks/useAccordionTrigger.ts +9 -1
- package/src/Accordion/types.ts +72 -21
- package/src/Alert/Alert.tsx +36 -7
- package/src/Alert/__tests__/Alert.test.tsx +5 -0
- package/src/Alert/types.ts +18 -2
- package/src/Avatar/Avatar.tsx +99 -15
- package/src/Avatar/__tests__/Avatar.basic-rendering.test.tsx +10 -0
- package/src/Avatar/__tests__/Avatar.fallback.test.tsx +30 -0
- package/src/Avatar/__tests__/Avatar.image-loading.test.tsx +35 -0
- package/src/Avatar/hooks/useAvatarImage.ts +16 -0
- package/src/Avatar/types.ts +15 -3
- package/src/Breadcrumb/Breadcrumb.tsx +136 -18
- package/src/Breadcrumb/README.md +47 -0
- package/src/Breadcrumb/__tests__/Breadcrumb.basic-rendering.test.tsx +11 -0
- package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +110 -0
- package/src/Breadcrumb/types.ts +73 -7
- package/src/Button/Button.tsx +2 -0
- package/src/Button/__tests__/Button.basic-rendering.test.tsx +6 -0
- package/src/Button/types.ts +21 -0
- package/src/Carousel/Carousel.tsx +263 -15
- package/src/Carousel/README.md +749 -72
- package/src/Carousel/__tests__/Carousel.autoplay-status.test.tsx +239 -0
- package/src/Carousel/__tests__/Carousel.drag-status.test.tsx +292 -0
- package/src/Carousel/__tests__/Carousel.dynamic-slides.test.tsx +38 -0
- package/src/Carousel/__tests__/Carousel.indicators.test.tsx +101 -0
- package/src/Carousel/__tests__/Carousel.infinite.test.tsx +1453 -0
- package/src/Carousel/__tests__/Carousel.instant-scroll.test.tsx +124 -0
- package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +88 -0
- package/src/Carousel/__tests__/Carousel.loop.test.tsx +163 -0
- package/src/Carousel/__tests__/Carousel.loopEngine.test.ts +77 -0
- package/src/Carousel/__tests__/Carousel.mouse-drag.test.tsx +504 -0
- package/src/Carousel/__tests__/Carousel.multi-slide-bounds.test.tsx +91 -0
- package/src/Carousel/__tests__/Carousel.orientation.test.tsx +207 -0
- package/src/Carousel/__tests__/Carousel.overscroll.test.tsx +532 -0
- package/src/Carousel/__tests__/Carousel.page-snap-points.test.tsx +50 -0
- package/src/Carousel/__tests__/Carousel.progress-text.test.tsx +76 -0
- package/src/Carousel/__tests__/Carousel.reduced-motion.test.tsx +3 -6
- package/src/Carousel/__tests__/Carousel.refresh-progress.test.tsx +2 -5
- package/src/Carousel/__tests__/Carousel.scroll-margin.test.tsx +167 -0
- package/src/Carousel/__tests__/Carousel.scroll-progress.test.tsx +518 -0
- package/src/Carousel/__tests__/Carousel.scroll-snap-change.test.tsx +32 -11
- package/src/Carousel/__tests__/Carousel.scroll-sync.test.tsx +237 -24
- package/src/Carousel/__tests__/Carousel.scroll-to-index.test.tsx +115 -0
- package/src/Carousel/__tests__/Carousel.slide-snap.test.tsx +172 -0
- package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +179 -1
- package/src/Carousel/__tests__/Carousel.slides-per-page.test.tsx +91 -7
- package/src/Carousel/__tests__/Carousel.snap-type.test.tsx +35 -0
- package/src/Carousel/__tests__/Carousel.transition-modes.test.tsx +30 -5
- package/src/Carousel/__tests__/Carousel.translations.test.tsx +23 -0
- package/src/Carousel/__tests__/Carousel.wheel.test.tsx +160 -0
- package/src/Carousel/hooks/index.ts +1 -0
- package/src/Carousel/hooks/useCarouselLoop.ts +869 -0
- package/src/Carousel/hooks/useCarouselRoot.ts +338 -42
- package/src/Carousel/hooks/useCarouselSlide.ts +44 -9
- package/src/Carousel/hooks/useCarouselViewport.ts +649 -46
- package/src/Carousel/loopEngine.ts +54 -0
- package/src/Carousel/types.ts +457 -35
- package/src/Checkbox/Checkbox.tsx +9 -1
- package/src/Checkbox/__tests__/Checkbox.basic-rendering.test.tsx +14 -0
- package/src/Checkbox/hooks/useCheckboxInput.ts +4 -0
- package/src/Checkbox/types.ts +19 -0
- package/src/CheckboxCard/CheckboxCard.tsx +13 -4
- package/src/CheckboxCard/__tests__/CheckboxCard.basic-rendering.test.tsx +7 -0
- package/src/CheckboxCard/types.ts +65 -17
- package/src/Collapsible/Collapsible.tsx +49 -3
- package/src/Collapsible/README.md +40 -0
- package/src/Collapsible/__tests__/Collapsible.asChild.test.tsx +89 -0
- package/src/Collapsible/__tests__/Collapsible.basic-rendering.test.tsx +27 -0
- package/src/Collapsible/__tests__/Collapsible.collapsedHeight.test.tsx +140 -0
- package/src/Collapsible/hooks/useCollapsibleTrigger.ts +3 -1
- package/src/Collapsible/types.ts +89 -22
- package/src/ContextMenu/ContextMenu.tsx +270 -4
- package/src/ContextMenu/types.ts +148 -6
- package/src/DirectionProvider/DirectionProvider.tsx +41 -9
- package/src/DirectionProvider/__tests__/DirectionProvider.test.tsx +5 -0
- package/src/DirectionProvider/types.ts +28 -3
- package/src/Divider/Divider.tsx +24 -10
- package/src/Divider/__tests__/Divider.test.tsx +5 -0
- package/src/Divider/types.ts +10 -3
- package/src/Drawer/Drawer.tsx +315 -0
- package/src/Drawer/README.md +124 -0
- package/src/Drawer/__tests__/Drawer.behavior.test.tsx +100 -0
- package/src/Drawer/__tests__/Drawer.focus-trap.test.tsx +55 -0
- package/src/Drawer/__tests__/Drawer.rendering.test.tsx +111 -0
- package/src/Drawer/__tests__/Drawer.side.test.tsx +69 -0
- package/src/Drawer/__tests__/dialog-polyfill.ts +39 -0
- package/src/Drawer/index.ts +2 -0
- package/src/Drawer/types.ts +66 -0
- package/src/Dropdown/Dropdown.tsx +117 -0
- package/src/Dropdown/types.ts +241 -11
- package/src/EmptyState/EmptyState.tsx +74 -4
- package/src/EmptyState/__tests__/EmptyState.Actions.test.tsx +7 -2
- package/src/EmptyState/__tests__/EmptyState.Description.test.tsx +3 -1
- package/src/EmptyState/__tests__/EmptyState.Root.test.tsx +10 -0
- package/src/EmptyState/__tests__/EmptyState.Title.test.tsx +3 -1
- package/src/EmptyState/types.ts +49 -6
- package/src/Field/Field.tsx +23 -0
- package/src/Field/__tests__/Field.basic-rendering.test.tsx +11 -0
- package/src/Field/__tests__/Field.error-handling.test.tsx +23 -0
- package/src/Field/__tests__/Field.state-cascade.test.tsx +21 -0
- package/src/Field/hooks/__tests__/useFieldProps.test.tsx +104 -9
- package/src/Field/types.ts +60 -12
- package/src/Fieldset/Fieldset.tsx +54 -18
- package/src/Fieldset/__tests__/Fieldset.basic-rendering.test.tsx +9 -0
- package/src/Fieldset/types.ts +20 -2
- package/src/Input/Input.tsx +14 -9
- package/src/Input/__tests__/Input.basic-rendering.test.tsx +5 -0
- package/src/Input/types.ts +19 -2
- package/src/InputGroup/InputGroup.tsx +11 -3
- package/src/InputGroup/__tests__/InputGroup.basic-rendering.test.tsx +14 -0
- package/src/InputGroup/types.ts +26 -5
- package/src/MillerColumns/MillerColumns.tsx +86 -19
- package/src/MillerColumns/types.ts +83 -27
- package/src/Modal/Modal.tsx +31 -4
- package/src/Modal/README.md +19 -10
- package/src/Modal/__tests__/Modal.focus-trap.test.tsx +162 -0
- package/src/Modal/hooks/useModalContent.ts +32 -0
- package/src/Modal/types.ts +110 -29
- package/src/NavigationMenu/NavigationMenu.tsx +890 -0
- package/src/NavigationMenu/NavigationMenuContext.ts +67 -0
- package/src/NavigationMenu/README.md +355 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.anchor-positioning.test.tsx +132 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.asChild.test.tsx +73 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.basic-rendering.test.tsx +92 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.contract.test.tsx +45 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.controlled-state.test.tsx +134 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.error-handling.test.tsx +62 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.fixtures.tsx +79 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.forceMount.test.tsx +52 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.hover-interaction.test.tsx +254 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.indicator.test.tsx +252 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-interaction.test.tsx +294 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-navigation.test.tsx +126 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.link.test.tsx +139 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.motion.test.tsx +568 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.mouse-interaction.test.tsx +76 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.uncontrolled-state.test.tsx +30 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.viewport.test.tsx +113 -0
- package/src/NavigationMenu/constants.ts +5 -0
- package/src/NavigationMenu/hooks/index.ts +7 -0
- package/src/NavigationMenu/hooks/useNavigationMenuEntry.ts +33 -0
- package/src/NavigationMenu/hooks/useNavigationMenuIndicator.ts +70 -0
- package/src/NavigationMenu/hooks/useNavigationMenuLink.ts +72 -0
- package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +301 -0
- package/src/NavigationMenu/hooks/useNavigationMenuTopLevelEntry.ts +65 -0
- package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +158 -0
- package/src/NavigationMenu/hooks/useNavigationMenuViewport.ts +101 -0
- package/src/NavigationMenu/index.ts +3 -0
- package/src/NavigationMenu/types.ts +259 -0
- package/src/NavigationMenu/utils.ts +89 -0
- package/src/Popover/Popover.tsx +427 -0
- package/src/Popover/PopoverContext.ts +13 -0
- package/src/Popover/README.md +106 -0
- package/src/Popover/__tests__/Popover.anchor.test.tsx +38 -0
- package/src/Popover/__tests__/Popover.asChild.test.tsx +160 -0
- package/src/Popover/__tests__/Popover.basic-rendering.test.tsx +106 -0
- package/src/Popover/__tests__/Popover.click-outside.test.tsx +149 -0
- package/src/Popover/__tests__/Popover.close.test.tsx +70 -0
- package/src/Popover/__tests__/Popover.content.test.tsx +58 -0
- package/src/Popover/__tests__/Popover.controlled-state.test.tsx +45 -0
- package/src/Popover/__tests__/Popover.error-handling.test.tsx +51 -0
- package/src/Popover/__tests__/Popover.focus.test.tsx +43 -0
- package/src/Popover/__tests__/Popover.keyboard-interaction.test.tsx +71 -0
- package/src/Popover/__tests__/Popover.mouse-interaction.test.tsx +69 -0
- package/src/Popover/__tests__/Popover.uncontrolled-state.test.tsx +44 -0
- package/src/Popover/hooks/index.ts +3 -0
- package/src/Popover/hooks/usePopoverContent.ts +98 -0
- package/src/Popover/hooks/usePopoverRoot.ts +84 -0
- package/src/Popover/hooks/usePopoverTrigger.ts +33 -0
- package/src/Popover/index.ts +2 -0
- package/src/Popover/types.ts +197 -0
- package/src/Portal/Portal.tsx +50 -10
- package/src/Portal/__tests__/Portal.basic-rendering.test.tsx +5 -0
- package/src/Portal/types.ts +5 -0
- package/src/Progress/Progress.tsx +33 -1
- package/src/Progress/__tests__/Progress.basic-rendering.test.tsx +10 -0
- package/src/Progress/__tests__/Progress.value.test.tsx +21 -0
- package/src/Progress/types.ts +20 -4
- package/src/Radio/Radio.tsx +9 -1
- package/src/Radio/__tests__/Radio.basic-rendering.test.tsx +14 -0
- package/src/Radio/hooks/useRadioRoot.ts +3 -0
- package/src/Radio/types.ts +20 -0
- package/src/RadioCard/RadioCard.tsx +11 -0
- package/src/RadioCard/__tests__/RadioCard.basic-rendering.test.tsx +8 -0
- package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +27 -0
- package/src/RadioCard/__tests__/RadioCard.keyboard-interaction.test.tsx +53 -0
- package/src/RadioCard/__tests__/RadioCard.ref-forwarding.test.tsx +54 -0
- package/src/RadioCard/__tests__/RadioCard.uncontrolled-state.test.tsx +21 -0
- package/src/RadioCard/hooks/useRadioCardRoot.ts +11 -0
- package/src/RadioCard/types.ts +95 -22
- package/src/RadioGroup/RadioGroup.tsx +11 -0
- package/src/RadioGroup/__tests__/RadioGroup.basic-rendering.test.tsx +8 -0
- package/src/RadioGroup/__tests__/RadioGroup.disabled.test.tsx +27 -0
- package/src/RadioGroup/__tests__/RadioGroup.ref-forwarding.test.tsx +29 -0
- package/src/RadioGroup/__tests__/RadioGroup.roving-tabindex.test.tsx +31 -0
- package/src/RadioGroup/hooks/useRadioGroupRoot.ts +11 -0
- package/src/RadioGroup/types.ts +73 -9
- package/src/SegmentedControl/README.md +154 -0
- package/src/SegmentedControl/SegmentedControl.tsx +309 -0
- package/src/SegmentedControl/SegmentedControlContext.ts +28 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.asChild.test.tsx +50 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.basic-rendering.test.tsx +100 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.controlled-state.test.tsx +107 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.disabled-keydown-guards.test.tsx +62 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.disabled.test.tsx +151 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.error-handling.test.tsx +17 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.keyboard-interaction.test.tsx +117 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.orientation.test.tsx +54 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.reading-direction.test.tsx +56 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.ref-forwarding.test.tsx +45 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.roving-tabindex.test.tsx +104 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.uncontrolled-state.test.tsx +94 -0
- package/src/SegmentedControl/hooks/index.ts +2 -0
- package/src/SegmentedControl/hooks/useSegmentedControlContext.ts +1 -0
- package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +86 -0
- package/src/SegmentedControl/index.ts +2 -0
- package/src/SegmentedControl/types.ts +138 -0
- package/src/Select/README.md +122 -157
- package/src/Select/Select.tsx +595 -68
- package/src/Select/SelectContext.ts +38 -0
- package/src/Select/SelectItemIndicatorContext.ts +29 -0
- package/src/Select/__tests__/Select.asChild.test.tsx +2 -2
- package/src/Select/__tests__/Select.basic-rendering.test.tsx +22 -3
- package/src/Select/__tests__/Select.controlled-state.test.tsx +6 -6
- package/src/Select/__tests__/Select.data-attributes.test.tsx +4 -4
- package/src/Select/__tests__/Select.field-integration.test.tsx +11 -11
- package/src/Select/__tests__/Select.form.test.tsx +38 -0
- package/src/Select/__tests__/Select.group.test.tsx +6 -6
- package/src/Select/__tests__/Select.keyboard-edge.test.tsx +95 -0
- package/src/Select/__tests__/Select.keyboard.test.tsx +124 -0
- package/src/Select/__tests__/Select.mutation-hardening.test.tsx +317 -0
- package/src/Select/__tests__/Select.native-item.test.tsx +30 -0
- package/src/Select/__tests__/Select.open-close.test.tsx +57 -0
- package/src/Select/__tests__/Select.placeholder.test.tsx +4 -4
- package/src/Select/__tests__/Select.rich-asChild.test.tsx +60 -0
- package/src/Select/__tests__/Select.rich-mode.test.tsx +16 -0
- package/src/Select/__tests__/Select.selection.test.tsx +104 -0
- package/src/Select/__tests__/Select.separator.test.tsx +71 -0
- package/src/Select/__tests__/Select.strict-context.test.tsx +36 -0
- package/src/Select/__tests__/Select.typeahead.test.tsx +156 -0
- package/src/Select/__tests__/Select.value-mirror.test.tsx +240 -0
- package/src/Select/constants.ts +8 -0
- package/src/Select/hooks/index.ts +2 -0
- package/src/Select/hooks/useSelectContent.ts +149 -0
- package/src/Select/hooks/useSelectRoot.ts +149 -0
- package/src/Select/types.ts +199 -31
- package/src/SkipNav/SkipNav.tsx +69 -29
- package/src/SkipNav/__tests__/SkipNav.basic-rendering.test.tsx +6 -0
- package/src/SkipNav/types.ts +17 -9
- package/src/Slider/Slider.tsx +33 -1
- package/src/Slider/SliderContext.ts +28 -1
- package/src/Slider/types.ts +157 -9
- package/src/Slot/Slot.tsx +94 -19
- package/src/Slot/__tests__/Slot.test.tsx +110 -0
- package/src/Slot/composeEventHandlers.ts +26 -2
- package/src/Slot/types.ts +16 -0
- package/src/Status/Status.tsx +36 -15
- package/src/Status/__tests__/Status.test.tsx +5 -0
- package/src/Status/types.ts +12 -1
- package/src/Switch/Switch.tsx +15 -2
- package/src/Switch/__tests__/Switch.basic-rendering.test.tsx +7 -0
- package/src/Switch/hooks/useSwitchRoot.ts +3 -0
- package/src/Switch/types.ts +49 -22
- package/src/Table/Table.tsx +44 -10
- package/src/Table/types.ts +66 -17
- package/src/Tabs/Tabs.tsx +8 -0
- package/src/Tabs/types.ts +29 -4
- package/src/Textarea/Textarea.tsx +9 -1
- package/src/Textarea/__tests__/Textarea.basic-rendering.test.tsx +5 -0
- package/src/Textarea/types.ts +20 -3
- package/src/Toggle/Toggle.tsx +51 -13
- package/src/Toggle/__tests__/Toggle.basic-rendering.test.tsx +5 -0
- package/src/Toggle/types.ts +54 -4
- package/src/ToggleGroup/ToggleGroup.tsx +14 -1
- package/src/ToggleGroup/__tests__/ToggleGroup.asChild.test.tsx +20 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.basic-rendering.test.tsx +9 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.dynamic-items.test.tsx +70 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.error-handling.test.tsx +23 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.keyboard-interaction.test.tsx +23 -1
- package/src/ToggleGroup/__tests__/ToggleGroup.ref-forwarding.test.tsx +58 -0
- package/src/ToggleGroup/hooks/useToggleGroupRoot.ts +21 -1
- package/src/ToggleGroup/types.ts +55 -2
- package/src/Tooltip/Tooltip.tsx +98 -20
- package/src/Tooltip/hooks/__tests__/useTooltipProvider.test.tsx +40 -0
- package/src/Tooltip/hooks/useTooltipProvider.ts +13 -1
- package/src/Tooltip/types.ts +89 -8
- package/src/Tree/Tree.tsx +187 -33
- package/src/Tree/types.ts +54 -12
- package/src/VisuallyHidden/VisuallyHidden.tsx +33 -8
- package/src/VisuallyHidden/__tests__/VisuallyHidden.test.tsx +15 -1
- package/src/VisuallyHidden/types.ts +11 -1
- package/src/index.ts +4 -0
- package/src/test/intersectionObserverPolyfill.ts +7 -0
- package/src/test/resizeObserverPolyfill.ts +80 -0
- package/src/utils/focusable.ts +15 -0
- package/src/utils/index.ts +1 -0
|
@@ -14,4 +14,9 @@ describe("Portal basic rendering", () => {
|
|
|
14
14
|
// Assert
|
|
15
15
|
expect(screen.getByText("Portal content")).toBeInTheDocument();
|
|
16
16
|
});
|
|
17
|
+
|
|
18
|
+
it('sets displayName to "Portal" for React DevTools and stack traces', () => {
|
|
19
|
+
// Assert — an empty displayName would render it as an anonymous component.
|
|
20
|
+
expect(Portal.displayName).toBe("Portal");
|
|
21
|
+
});
|
|
17
22
|
});
|
package/src/Portal/types.ts
CHANGED
|
@@ -5,6 +5,11 @@ import { ReactNode } from "react";
|
|
|
5
5
|
* `container` to render into (defaults to `document.body`).
|
|
6
6
|
*/
|
|
7
7
|
export type PortalProps = {
|
|
8
|
+
/** Content to render inside the portal. Appears directly inside `container`
|
|
9
|
+
* with no wrapping host element added by `Portal` itself. */
|
|
8
10
|
children?: ReactNode;
|
|
11
|
+
/** DOM node to mount the portal children into. When omitted, children are
|
|
12
|
+
* mounted into `document.body`.
|
|
13
|
+
* @default document.body */
|
|
9
14
|
container?: HTMLElement;
|
|
10
15
|
};
|
|
@@ -33,6 +33,16 @@ function defaultGetValueLabel(value: number, max: number): string {
|
|
|
33
33
|
* **Styling hooks.** `data-state="indeterminate" | "loading" | "complete"`,
|
|
34
34
|
* plus `data-value` (determinate only) and `data-max` on the root.
|
|
35
35
|
*
|
|
36
|
+
* **Validation.** `max` must be a positive, finite number and `value` (when
|
|
37
|
+
* not `null`) must be finite and within `0..max`; either violation throws a
|
|
38
|
+
* descriptive error during render so bad inputs surface early in development.
|
|
39
|
+
*
|
|
40
|
+
* **`asChild` composition.** Pass `asChild` to render the consumer's own
|
|
41
|
+
* element as the root instead of the native `<div>`, merging the ARIA and
|
|
42
|
+
* `data-*` hooks onto it via the {@link Slot} pattern.
|
|
43
|
+
*
|
|
44
|
+
* @extends HTMLDivElement
|
|
45
|
+
*
|
|
36
46
|
* @example Determinate
|
|
37
47
|
* ```tsx
|
|
38
48
|
* <Progress.Root value={60} aria-label="Upload progress">
|
|
@@ -46,6 +56,18 @@ function defaultGetValueLabel(value: number, max: number): string {
|
|
|
46
56
|
* <Progress.Indicator />
|
|
47
57
|
* </Progress.Root>
|
|
48
58
|
* ```
|
|
59
|
+
*
|
|
60
|
+
* @example Custom value label
|
|
61
|
+
* ```tsx
|
|
62
|
+
* <Progress.Root
|
|
63
|
+
* value={3}
|
|
64
|
+
* max={5}
|
|
65
|
+
* getValueLabel={(v, m) => `Step ${v} of ${m}`}
|
|
66
|
+
* aria-label="Setup progress"
|
|
67
|
+
* >
|
|
68
|
+
* <Progress.Indicator />
|
|
69
|
+
* </Progress.Root>
|
|
70
|
+
* ```
|
|
49
71
|
*/
|
|
50
72
|
export function ProgressRoot({
|
|
51
73
|
value,
|
|
@@ -93,7 +115,11 @@ export function ProgressRoot({
|
|
|
93
115
|
);
|
|
94
116
|
}
|
|
95
117
|
|
|
96
|
-
|
|
118
|
+
// Runtime-dead: the compound alias below (same object via Object.assign)
|
|
119
|
+
// overwrites this to "Progress" at load, so the value is never observable. The
|
|
120
|
+
// assignment stays because it declares `displayName` on `typeof ProgressRoot`,
|
|
121
|
+
// which TProgressCompound extends.
|
|
122
|
+
// Stryker disable next-line StringLiteral: overwritten by the compound alias — an equivalent mutant.
|
|
97
123
|
ProgressRoot.displayName = "ProgressRoot";
|
|
98
124
|
|
|
99
125
|
/**
|
|
@@ -109,6 +135,8 @@ ProgressRoot.displayName = "ProgressRoot";
|
|
|
109
135
|
* **`asChild` prop.** Pass `asChild` to render the consumer's own element as
|
|
110
136
|
* the indicator, with the `data-*` hooks merged in.
|
|
111
137
|
*
|
|
138
|
+
* @extends HTMLDivElement
|
|
139
|
+
*
|
|
112
140
|
* @example
|
|
113
141
|
* ```tsx
|
|
114
142
|
* <Progress.Root value={75} aria-label="Upload progress">
|
|
@@ -127,6 +155,10 @@ export function ProgressIndicator({
|
|
|
127
155
|
const indicatorProps = {
|
|
128
156
|
...rest,
|
|
129
157
|
"data-state": state,
|
|
158
|
+
// Stryker disable next-line ConditionalExpression: `value` is null only
|
|
159
|
+
// while indeterminate, and React renders null and undefined attributes
|
|
160
|
+
// identically (both omitted), so the guarded (`undefined`) and unguarded
|
|
161
|
+
// (`null`) forms produce the same DOM — a provably-equivalent mutant.
|
|
130
162
|
"data-value": value === null ? undefined : value,
|
|
131
163
|
"data-max": max,
|
|
132
164
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { render, screen } from "@testing-library/react";
|
|
2
2
|
|
|
3
3
|
import { Progress } from "../Progress";
|
|
4
|
+
import { ProgressContext } from "../ProgressContext";
|
|
4
5
|
|
|
5
6
|
describe("Progress basic rendering", () => {
|
|
6
7
|
it('renders a <div role="progressbar">', () => {
|
|
@@ -30,6 +31,7 @@ describe("Progress basic rendering", () => {
|
|
|
30
31
|
// Assert
|
|
31
32
|
expect(bar).toHaveAttribute("data-state", "indeterminate");
|
|
32
33
|
expect(bar).not.toHaveAttribute("aria-valuenow");
|
|
34
|
+
expect(bar).not.toHaveAttribute("aria-valuetext");
|
|
33
35
|
expect(bar).not.toHaveAttribute("data-value");
|
|
34
36
|
});
|
|
35
37
|
|
|
@@ -62,4 +64,12 @@ describe("Progress basic rendering", () => {
|
|
|
62
64
|
);
|
|
63
65
|
expect(screen.getByTestId("indicator")).toHaveAttribute("data-max", "100");
|
|
64
66
|
});
|
|
67
|
+
|
|
68
|
+
it("sets a displayName on the compound, indicator, and context", () => {
|
|
69
|
+
// Assert — empty displayNames would render each as anonymous in DevTools.
|
|
70
|
+
// Root aliases the compound (Object.assign), so its name is "Progress".
|
|
71
|
+
expect(Progress.displayName).toBe("Progress");
|
|
72
|
+
expect(Progress.Indicator.displayName).toBe("ProgressIndicator");
|
|
73
|
+
expect(ProgressContext.displayName).toBe("ProgressContext");
|
|
74
|
+
});
|
|
65
75
|
});
|
|
@@ -66,6 +66,27 @@ describe("Progress value", () => {
|
|
|
66
66
|
expect(screen.getByTestId("indicator")).toHaveAttribute("data-value", "70");
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
it("propagates a changed value to the Indicator through context", () => {
|
|
70
|
+
// Arrange
|
|
71
|
+
const { rerender } = render(
|
|
72
|
+
<Progress.Root value={40} aria-label="Upload">
|
|
73
|
+
<Progress.Indicator data-testid="indicator" />
|
|
74
|
+
</Progress.Root>,
|
|
75
|
+
);
|
|
76
|
+
expect(screen.getByTestId("indicator")).toHaveAttribute("data-value", "40");
|
|
77
|
+
|
|
78
|
+
// Act — a new value must recompute the memoised context value, not a stale
|
|
79
|
+
// one frozen by empty dependencies.
|
|
80
|
+
rerender(
|
|
81
|
+
<Progress.Root value={70} aria-label="Upload">
|
|
82
|
+
<Progress.Indicator data-testid="indicator" />
|
|
83
|
+
</Progress.Root>,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Assert
|
|
87
|
+
expect(screen.getByTestId("indicator")).toHaveAttribute("data-value", "70");
|
|
88
|
+
});
|
|
89
|
+
|
|
69
90
|
describe.each(determinateStateCases)(
|
|
70
91
|
"value $value of $max",
|
|
71
92
|
({ value, max, expectedState }) => {
|
package/src/Progress/types.ts
CHANGED
|
@@ -21,23 +21,39 @@ export type ProgressRootProps = Omit<
|
|
|
21
21
|
> & {
|
|
22
22
|
/**
|
|
23
23
|
* Current progress, between `0` and `max`. Pass `null` (or omit) for an
|
|
24
|
-
* indeterminate progress bar whose completion ratio is unknown.
|
|
24
|
+
* indeterminate progress bar whose completion ratio is unknown. Drives the
|
|
25
|
+
* resolved {@link ProgressState} and the `aria-valuenow` / `data-value`
|
|
26
|
+
* hooks. A non-`null` value outside `0..max` throws.
|
|
27
|
+
* @default null
|
|
25
28
|
*/
|
|
26
29
|
value?: number | null;
|
|
27
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Upper bound of `value`. Must be a positive, finite number, or Root throws
|
|
32
|
+
* during render.
|
|
33
|
+
* @default 100
|
|
34
|
+
*/
|
|
28
35
|
max?: number;
|
|
29
36
|
/**
|
|
30
37
|
* Produces the human-readable `aria-valuetext`. Receives the resolved
|
|
31
38
|
* `value` and `max`. Defaults to a rounded percentage (e.g. `"42%"`).
|
|
32
39
|
* Not called while indeterminate.
|
|
40
|
+
* @default (value, max) => `${Math.round((value / max) * 100)}%`
|
|
33
41
|
*/
|
|
34
42
|
getValueLabel?: (value: number, max: number) => string;
|
|
35
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Render the consumer's own element instead of the native `<div>`, merging
|
|
45
|
+
* the ARIA and `data-*` hooks onto it via {@link Slot}.
|
|
46
|
+
* @default false
|
|
47
|
+
*/
|
|
36
48
|
asChild?: boolean;
|
|
37
49
|
};
|
|
38
50
|
|
|
39
51
|
/** Props for {@link Progress.Indicator} — all `<div>` attributes plus `asChild`. */
|
|
40
52
|
export type ProgressIndicatorProps = ComponentProps<"div"> & {
|
|
41
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Render the consumer's own element instead of the native `<div>`, merging
|
|
55
|
+
* the `data-*` hooks onto it via {@link Slot}.
|
|
56
|
+
* @default false
|
|
57
|
+
*/
|
|
42
58
|
asChild?: boolean;
|
|
43
59
|
};
|
package/src/Radio/Radio.tsx
CHANGED
|
@@ -46,6 +46,8 @@ function dataStateOf(checked: boolean) {
|
|
|
46
46
|
* visibility off the input's native `:checked` / `:has()` selectors;
|
|
47
47
|
* `data-state` is a convenience mirror.
|
|
48
48
|
*
|
|
49
|
+
* @extends HTMLInputElement
|
|
50
|
+
*
|
|
49
51
|
* @example Native group (the browser owns selection)
|
|
50
52
|
* ```tsx
|
|
51
53
|
* <Radio.Root name="density" value="compact" defaultChecked aria-label="Compact">
|
|
@@ -112,7 +114,11 @@ export function RadioRoot(props: RadioRootProps): ReactElement {
|
|
|
112
114
|
);
|
|
113
115
|
}
|
|
114
116
|
|
|
115
|
-
|
|
117
|
+
// Runtime-dead: the compound alias below (same object via Object.assign)
|
|
118
|
+
// overwrites this to "Radio" at load, so the value is never observable. The
|
|
119
|
+
// assignment stays because it declares `displayName` on `typeof RadioRoot`,
|
|
120
|
+
// which TRadioCompound extends.
|
|
121
|
+
// Stryker disable next-line StringLiteral: overwritten by the compound alias — an equivalent mutant.
|
|
116
122
|
RadioRoot.displayName = "RadioRoot";
|
|
117
123
|
|
|
118
124
|
/**
|
|
@@ -129,6 +135,8 @@ RadioRoot.displayName = "RadioRoot";
|
|
|
129
135
|
* (typically an `<svg>` dot) as the indicator itself, with `data-state` merged
|
|
130
136
|
* onto that element rather than a wrapper.
|
|
131
137
|
*
|
|
138
|
+
* @extends HTMLSpanElement
|
|
139
|
+
*
|
|
132
140
|
* @example Dot as the indicator via `asChild`
|
|
133
141
|
* ```tsx
|
|
134
142
|
* <Radio.Indicator asChild>
|
|
@@ -63,4 +63,18 @@ describe("Radio basic rendering", () => {
|
|
|
63
63
|
"unchecked",
|
|
64
64
|
);
|
|
65
65
|
});
|
|
66
|
+
|
|
67
|
+
it("sets a displayName on the compound and the Indicator", () => {
|
|
68
|
+
// Assert — empty displayNames would render each as anonymous in DevTools.
|
|
69
|
+
// Root aliases the compound (Object.assign), so its name is "Radio".
|
|
70
|
+
expect(Radio.displayName).toBe("Radio");
|
|
71
|
+
expect(Radio.Indicator.displayName).toBe("RadioIndicator");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("throws a helpful error when a sub-component renders outside Radio.Root", () => {
|
|
75
|
+
// Assert — the strict-context guard names the required parent.
|
|
76
|
+
expect(() => render(<Radio.Indicator />)).toThrow(
|
|
77
|
+
"Radio sub-components must be rendered inside a <Radio.Root>.",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
66
80
|
});
|
|
@@ -36,6 +36,9 @@ export function useRadioRoot({
|
|
|
36
36
|
onChange?.(event);
|
|
37
37
|
if (event.defaultPrevented) return;
|
|
38
38
|
const next = event.target.checked;
|
|
39
|
+
// Stryker disable next-line ConditionalExpression: in controlled mode the
|
|
40
|
+
// input reads the controlled value, never `mirror`, so setting mirror here
|
|
41
|
+
// (the mutant's always-run form) has no observable effect — equivalent.
|
|
39
42
|
if (!isControlled) setMirror(next);
|
|
40
43
|
onCheckedChange?.(next);
|
|
41
44
|
};
|
package/src/Radio/types.ts
CHANGED
|
@@ -8,7 +8,16 @@ import { ChangeEventHandler, ComponentProps, ReactNode, Ref } from "react";
|
|
|
8
8
|
* grouped sibling (which fires no React event).
|
|
9
9
|
*/
|
|
10
10
|
export type RadioIndicatorProps = ComponentProps<"span"> & {
|
|
11
|
+
/** Custom dot content. Omit to let the shipped CSS draw the dot off the
|
|
12
|
+
* input's native `:checked` state; provide your own (an icon, glyph, or
|
|
13
|
+
* nested element) to override it. */
|
|
11
14
|
children?: ReactNode;
|
|
15
|
+
/**
|
|
16
|
+
* When `true`, render `children` as the indicator element itself (via the
|
|
17
|
+
* {@link Slot} pattern) instead of wrapping them in a `<span>`. `data-state`
|
|
18
|
+
* and `aria-hidden` are merged onto that element.
|
|
19
|
+
* @default false
|
|
20
|
+
*/
|
|
12
21
|
asChild?: boolean;
|
|
13
22
|
};
|
|
14
23
|
|
|
@@ -42,7 +51,11 @@ export type RadioRootBaseProps = Omit<
|
|
|
42
51
|
* forbidden.
|
|
43
52
|
*/
|
|
44
53
|
export type RadioRootUncontrolledProps = RadioRootBaseProps & {
|
|
54
|
+
/** Whether this radio is selected on first render; the **browser** owns it
|
|
55
|
+
* thereafter, so native `name`-grouping (including silent deselection of
|
|
56
|
+
* siblings) works for free. Omit for an initially unselected radio. */
|
|
45
57
|
defaultChecked?: boolean;
|
|
58
|
+
/** Forbidden in uncontrolled mode — use `defaultChecked` instead. */
|
|
46
59
|
checked?: never;
|
|
47
60
|
};
|
|
48
61
|
|
|
@@ -52,8 +65,15 @@ export type RadioRootUncontrolledProps = RadioRootBaseProps & {
|
|
|
52
65
|
* forbidden. The consumer owns grouping in this mode.
|
|
53
66
|
*/
|
|
54
67
|
export type RadioRootControlledProps = RadioRootBaseProps & {
|
|
68
|
+
/** Forbidden in controlled mode — use `checked` instead. */
|
|
55
69
|
defaultChecked?: never;
|
|
70
|
+
/** Whether this radio is currently selected, owned by the parent. The
|
|
71
|
+
* consumer owns grouping in this mode (typically deriving each radio's
|
|
72
|
+
* `checked` from a single shared value). Keep it in sync via
|
|
73
|
+
* `onCheckedChange`. */
|
|
56
74
|
checked: boolean;
|
|
75
|
+
/** Called (always with `true`) whenever this radio becomes selected —
|
|
76
|
+
* required in controlled mode so the parent can update its shared value. */
|
|
57
77
|
onCheckedChange: (checked: boolean) => void;
|
|
58
78
|
};
|
|
59
79
|
|
|
@@ -49,6 +49,8 @@ import {
|
|
|
49
49
|
* **`asChild` prop.** Pass `asChild` to render any consumer-supplied
|
|
50
50
|
* element with the RadioCard's props merged in. The native `<div>` is dropped.
|
|
51
51
|
*
|
|
52
|
+
* @extends HTMLDivElement
|
|
53
|
+
*
|
|
52
54
|
* @example Uncontrolled
|
|
53
55
|
* ```tsx
|
|
54
56
|
* <RadioCard.Root defaultValue="pro" aria-label="Plan">
|
|
@@ -130,6 +132,11 @@ export function RadioCardRoot({
|
|
|
130
132
|
}
|
|
131
133
|
|
|
132
134
|
/** @internal */
|
|
135
|
+
// Runtime-dead: the compound alias below (same object via Object.assign)
|
|
136
|
+
// overwrites this to "RadioCard" at load, so the value is never observable.
|
|
137
|
+
// The assignment stays because it declares `displayName` on
|
|
138
|
+
// `typeof RadioCardRoot`, which TRadioCardCompound extends.
|
|
139
|
+
// Stryker disable next-line StringLiteral: overwritten by the compound alias — an equivalent mutant.
|
|
133
140
|
RadioCardRoot.displayName = "RadioCardRoot";
|
|
134
141
|
|
|
135
142
|
/**
|
|
@@ -157,6 +164,8 @@ RadioCardRoot.displayName = "RadioCardRoot";
|
|
|
157
164
|
* the Item's ARIA, data-state, tabIndex, onClick, onKeyDown, disabled, and
|
|
158
165
|
* ref merged onto it.
|
|
159
166
|
*
|
|
167
|
+
* @extends HTMLButtonElement
|
|
168
|
+
*
|
|
160
169
|
* @throws if rendered outside a `RadioCard.Root`.
|
|
161
170
|
*/
|
|
162
171
|
export function RadioCardItem({
|
|
@@ -252,6 +261,8 @@ RadioCardItem.displayName = "RadioCardItem";
|
|
|
252
261
|
* while unchecked so a CSS exit animation can play against
|
|
253
262
|
* `data-state="unchecked"`.
|
|
254
263
|
*
|
|
264
|
+
* @extends HTMLSpanElement
|
|
265
|
+
*
|
|
255
266
|
* @example
|
|
256
267
|
* ```tsx
|
|
257
268
|
* <RadioCard.Item value="pro">
|
|
@@ -84,4 +84,12 @@ describe("RadioCard basic rendering", () => {
|
|
|
84
84
|
// Assert
|
|
85
85
|
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
|
86
86
|
});
|
|
87
|
+
|
|
88
|
+
it("sets a displayName on the compound, Item, and Indicator", () => {
|
|
89
|
+
// Assert — empty displayNames would render each as anonymous in DevTools.
|
|
90
|
+
// Root aliases the compound (Object.assign), so its name is "RadioCard".
|
|
91
|
+
expect(RadioCard.displayName).toBe("RadioCard");
|
|
92
|
+
expect(RadioCard.Item.displayName).toBe("RadioCardItem");
|
|
93
|
+
expect(RadioCard.Indicator.displayName).toBe("RadioCardIndicator");
|
|
94
|
+
});
|
|
87
95
|
});
|
|
@@ -37,6 +37,33 @@ describe("RadioCard disabled items", () => {
|
|
|
37
37
|
expect(onValueChange).not.toHaveBeenCalled();
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
it("re-registers an item's disabled flag when it changes after mount", async () => {
|
|
41
|
+
// Arrange — all enabled at mount; Pro becomes disabled afterwards.
|
|
42
|
+
const user = userEvent.setup();
|
|
43
|
+
function Fixture({ proDisabled }: { proDisabled: boolean }) {
|
|
44
|
+
return (
|
|
45
|
+
<RadioCard.Root aria-label="Plan" defaultValue="starter">
|
|
46
|
+
<RadioCard.Item value="starter">Starter</RadioCard.Item>
|
|
47
|
+
<RadioCard.Item value="pro" disabled={proDisabled}>
|
|
48
|
+
Pro
|
|
49
|
+
</RadioCard.Item>
|
|
50
|
+
<RadioCard.Item value="enterprise">Enterprise</RadioCard.Item>
|
|
51
|
+
</RadioCard.Root>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const { rerender } = render(<Fixture proDisabled={false} />);
|
|
55
|
+
|
|
56
|
+
// Act — disable Pro after mount, then arrow forwards from Starter.
|
|
57
|
+
rerender(<Fixture proDisabled />);
|
|
58
|
+
screen.getByRole("radio", { name: "Starter" }).focus();
|
|
59
|
+
await user.keyboard("{ArrowDown}");
|
|
60
|
+
|
|
61
|
+
// Assert — the registrar effect re-ran, so Pro is skipped and focus lands
|
|
62
|
+
// on Enterprise. A stale registration would keep Pro navigable and strand
|
|
63
|
+
// focus (a disabled button cannot receive it).
|
|
64
|
+
expect(screen.getByRole("radio", { name: "Enterprise" })).toHaveFocus();
|
|
65
|
+
});
|
|
66
|
+
|
|
40
67
|
it("excludes disabled items from the roving-tabindex home base", () => {
|
|
41
68
|
// Arrange & Act — no default value, first NON-disabled item is the tab stop
|
|
42
69
|
render(
|
|
@@ -3,6 +3,59 @@ import userEvent from "@testing-library/user-event";
|
|
|
3
3
|
|
|
4
4
|
import { RadioCard } from "../RadioCard";
|
|
5
5
|
|
|
6
|
+
describe("RadioCard roving tab stop", () => {
|
|
7
|
+
it("makes the selected item the tab stop even when it is not first", () => {
|
|
8
|
+
// Arrange & Act — Pro is selected but not the first item.
|
|
9
|
+
render(
|
|
10
|
+
<RadioCard.Root aria-label="Plan" defaultValue="pro">
|
|
11
|
+
<RadioCard.Item value="starter">Starter</RadioCard.Item>
|
|
12
|
+
<RadioCard.Item value="pro">Pro</RadioCard.Item>
|
|
13
|
+
</RadioCard.Root>,
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
// Assert — selection drives the tab stop; falling back to the first item
|
|
17
|
+
// would strand it on Starter.
|
|
18
|
+
expect(screen.getByRole("radio", { name: "Pro" })).toHaveAttribute(
|
|
19
|
+
"tabindex",
|
|
20
|
+
"0",
|
|
21
|
+
);
|
|
22
|
+
expect(screen.getByRole("radio", { name: "Starter" })).toHaveAttribute(
|
|
23
|
+
"tabindex",
|
|
24
|
+
"-1",
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("unregisters an unmounted item so the home-base tab stop moves", () => {
|
|
29
|
+
// Arrange — nothing selected, so the first item is the home base.
|
|
30
|
+
function Fixture({ items }: { items: string[] }) {
|
|
31
|
+
return (
|
|
32
|
+
<RadioCard.Root aria-label="Plan">
|
|
33
|
+
{items.map((v) => (
|
|
34
|
+
<RadioCard.Item key={v} value={v}>
|
|
35
|
+
{v}
|
|
36
|
+
</RadioCard.Item>
|
|
37
|
+
))}
|
|
38
|
+
</RadioCard.Root>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const { rerender } = render(<Fixture items={["starter", "pro"]} />);
|
|
42
|
+
expect(screen.getByRole("radio", { name: "starter" })).toHaveAttribute(
|
|
43
|
+
"tabindex",
|
|
44
|
+
"0",
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Act — remove "starter"; its effect cleanup must unregister it.
|
|
48
|
+
rerender(<Fixture items={["pro"]} />);
|
|
49
|
+
|
|
50
|
+
// Assert — "pro" becomes the home base. A no-op cleanup would leave
|
|
51
|
+
// "starter" registered ahead of it, leaving "pro" at tabindex -1.
|
|
52
|
+
expect(screen.getByRole("radio", { name: "pro" })).toHaveAttribute(
|
|
53
|
+
"tabindex",
|
|
54
|
+
"0",
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
6
59
|
describe("RadioCard keyboard interaction", () => {
|
|
7
60
|
it("selects and focuses the next item on ArrowDown", async () => {
|
|
8
61
|
// Arrange
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
|
|
3
|
+
import { RadioCard } from "../RadioCard";
|
|
4
|
+
|
|
5
|
+
describe("RadioCard.Item ref forwarding", () => {
|
|
6
|
+
it("forwards the underlying button element to a consumer function ref", () => {
|
|
7
|
+
// Arrange
|
|
8
|
+
const received: (HTMLButtonElement | null)[] = [];
|
|
9
|
+
const functionRef = (node: HTMLButtonElement | null) => {
|
|
10
|
+
received.push(node);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Act
|
|
14
|
+
render(
|
|
15
|
+
<RadioCard.Root aria-label="Plan">
|
|
16
|
+
<RadioCard.Item value="pro" ref={functionRef}>
|
|
17
|
+
Pro
|
|
18
|
+
</RadioCard.Item>
|
|
19
|
+
</RadioCard.Root>,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// Assert
|
|
23
|
+
expect(received).toContain(screen.getByRole("radio", { name: "Pro" }));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("re-attaches to a replacement ref when the ref prop changes", () => {
|
|
27
|
+
// Arrange — the item's ref memo must depend on `ref`, or a changed ref
|
|
28
|
+
// callback is never invoked with the element.
|
|
29
|
+
const secondReceived: (HTMLButtonElement | null)[] = [];
|
|
30
|
+
const firstRef = () => {};
|
|
31
|
+
const secondRef = (node: HTMLButtonElement | null) => {
|
|
32
|
+
secondReceived.push(node);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Act — mount with the first ref, then re-render with a different one.
|
|
36
|
+
const { rerender } = render(
|
|
37
|
+
<RadioCard.Root aria-label="Plan">
|
|
38
|
+
<RadioCard.Item value="pro" ref={firstRef}>
|
|
39
|
+
Pro
|
|
40
|
+
</RadioCard.Item>
|
|
41
|
+
</RadioCard.Root>,
|
|
42
|
+
);
|
|
43
|
+
rerender(
|
|
44
|
+
<RadioCard.Root aria-label="Plan">
|
|
45
|
+
<RadioCard.Item value="pro" ref={secondRef}>
|
|
46
|
+
Pro
|
|
47
|
+
</RadioCard.Item>
|
|
48
|
+
</RadioCard.Root>,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Assert — the replacement ref received the button.
|
|
52
|
+
expect(secondReceived).toContain(screen.getByRole("radio", { name: "Pro" }));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -105,4 +105,25 @@ describe("RadioCard uncontrolled state", () => {
|
|
|
105
105
|
// Assert
|
|
106
106
|
expect(onValueChange).not.toHaveBeenCalled();
|
|
107
107
|
});
|
|
108
|
+
|
|
109
|
+
it("does not re-fire onValueChange when a freshly selected item is re-clicked", async () => {
|
|
110
|
+
// Arrange — no defaultValue, so the guard reads a value that changes after
|
|
111
|
+
// the first click; a stale select closure would re-fire on the second.
|
|
112
|
+
const user = userEvent.setup();
|
|
113
|
+
const onValueChange = vi.fn();
|
|
114
|
+
render(
|
|
115
|
+
<RadioCard.Root aria-label="Plan" onValueChange={onValueChange}>
|
|
116
|
+
<RadioCard.Item value="pro">Pro</RadioCard.Item>
|
|
117
|
+
<RadioCard.Item value="starter">Starter</RadioCard.Item>
|
|
118
|
+
</RadioCard.Root>,
|
|
119
|
+
);
|
|
120
|
+
const pro = screen.getByRole("radio", { name: "Pro" });
|
|
121
|
+
|
|
122
|
+
// Act — select Pro, then click it again while it is already selected.
|
|
123
|
+
await user.click(pro);
|
|
124
|
+
await user.click(pro);
|
|
125
|
+
|
|
126
|
+
// Assert — exactly one change: the redundant second click is a no-op.
|
|
127
|
+
expect(onValueChange).toHaveBeenCalledOnce();
|
|
128
|
+
});
|
|
108
129
|
});
|
|
@@ -46,6 +46,9 @@ export function useRadioCardRoot({
|
|
|
46
46
|
) => {
|
|
47
47
|
registerItemBase(itemValue, element ? { element, disabled } : null);
|
|
48
48
|
},
|
|
49
|
+
// `registerItemBase` is a stable useCollection callback (its own deps never
|
|
50
|
+
// change), so emptying this array yields the identical memoised function.
|
|
51
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — stable dependency.
|
|
49
52
|
[registerItemBase],
|
|
50
53
|
);
|
|
51
54
|
|
|
@@ -61,8 +64,16 @@ export function useRadioCardRoot({
|
|
|
61
64
|
|
|
62
65
|
const focusItem = useCallback(
|
|
63
66
|
(itemValue: string) => {
|
|
67
|
+
// `itemValue` is always sourced from `navigable` (the enabled subset of
|
|
68
|
+
// `itemValues`), and `useCollection`'s register() writes `itemsRef` and
|
|
69
|
+
// the `itemValues` state together in the same call — so a value that
|
|
70
|
+
// appears here is guaranteed to already have a live entry in the map.
|
|
71
|
+
// Stryker disable next-line OptionalChaining: unreachable given that invariant.
|
|
64
72
|
itemsRef.current.get(itemValue)?.element.focus();
|
|
65
73
|
},
|
|
74
|
+
// `itemsRef` is a stable RefObject, so emptying this array yields the
|
|
75
|
+
// identical memoised function.
|
|
76
|
+
// Stryker disable next-line ArrayDeclaration: equivalent — stable dependency.
|
|
66
77
|
[itemsRef],
|
|
67
78
|
);
|
|
68
79
|
|