@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
|
@@ -13,7 +13,7 @@ describe("EmptyState.Title component", () => {
|
|
|
13
13
|
|
|
14
14
|
it("should render the consumer element with asChild", () => {
|
|
15
15
|
// Arrange
|
|
16
|
-
render(
|
|
16
|
+
const { container } = render(
|
|
17
17
|
<EmptyState.Title asChild>
|
|
18
18
|
<h2>No results found</h2>
|
|
19
19
|
</EmptyState.Title>,
|
|
@@ -22,5 +22,7 @@ describe("EmptyState.Title component", () => {
|
|
|
22
22
|
// Assert
|
|
23
23
|
const title = screen.getByRole("heading", { name: "No results found" });
|
|
24
24
|
expect(title.tagName).toBe("H2");
|
|
25
|
+
// Slot renders the child in place — no default <p> wrapper around it.
|
|
26
|
+
expect(container.querySelector("p")).toBeNull();
|
|
25
27
|
});
|
|
26
28
|
});
|
package/src/EmptyState/types.ts
CHANGED
|
@@ -2,21 +2,64 @@ import { ComponentProps } from "react";
|
|
|
2
2
|
|
|
3
3
|
/** Mixin adding the `asChild` opt-in to a sub-component's props. */
|
|
4
4
|
export type WithAsChild = {
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* When `true`, renders the consumer's own element instead of the
|
|
7
|
+
* sub-component's default host element, merging all props (aria-*, data-*,
|
|
8
|
+
* event handlers) onto it via the `Slot` utility. The child must be a single
|
|
9
|
+
* React element. For `EmptyState.Root` and `EmptyState.Media`, the implicit
|
|
10
|
+
* `role="status"` / `aria-hidden="true"` are also forwarded through `Slot`.
|
|
11
|
+
* @default false
|
|
12
|
+
*/
|
|
6
13
|
asChild?: boolean;
|
|
7
14
|
};
|
|
8
15
|
|
|
9
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Props for {@link EmptyStateRoot | `EmptyState.Root`} — all native `<div>`
|
|
18
|
+
* attributes plus the `asChild` escape hatch.
|
|
19
|
+
*
|
|
20
|
+
* The root renders `role="status"` by default, creating a polite ARIA live
|
|
21
|
+
* region. Override with `role={undefined}` for a static empty state that has
|
|
22
|
+
* nothing to announce on appearance.
|
|
23
|
+
*/
|
|
10
24
|
export type EmptyStateRootProps = ComponentProps<"div"> & WithAsChild;
|
|
11
25
|
|
|
12
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Props for {@link EmptyStateMedia | `EmptyState.Media`} — all native `<div>`
|
|
28
|
+
* attributes plus the `asChild` escape hatch.
|
|
29
|
+
*
|
|
30
|
+
* The media wrapper renders `aria-hidden="true"` by default, hiding decorative
|
|
31
|
+
* artwork from assistive technology. Pass `aria-hidden={false}` and provide an
|
|
32
|
+
* accessible name when the artwork is genuinely informative.
|
|
33
|
+
*/
|
|
13
34
|
export type EmptyStateMediaProps = ComponentProps<"div"> & WithAsChild;
|
|
14
35
|
|
|
15
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Props for {@link EmptyStateTitle | `EmptyState.Title`} — all native `<p>`
|
|
38
|
+
* attributes plus the `asChild` escape hatch.
|
|
39
|
+
*
|
|
40
|
+
* Renders a `<p>` by default. Use `asChild` to substitute a heading element
|
|
41
|
+
* (`<h2>`, `<h3>`, etc.) when the empty state stands in for a titled section
|
|
42
|
+
* and needs to participate in the document's heading hierarchy.
|
|
43
|
+
*/
|
|
16
44
|
export type EmptyStateTitleProps = ComponentProps<"p"> & WithAsChild;
|
|
17
45
|
|
|
18
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Props for {@link EmptyStateDescription | `EmptyState.Description`} — all
|
|
48
|
+
* native `<p>` attributes plus the `asChild` escape hatch.
|
|
49
|
+
*
|
|
50
|
+
* Renders a `<p>` for secondary supporting copy that explains the situation or
|
|
51
|
+
* suggests a next step. Actionable controls belong in
|
|
52
|
+
* {@link EmptyStateActionsProps | `EmptyState.Actions`} rather than here.
|
|
53
|
+
*/
|
|
19
54
|
export type EmptyStateDescriptionProps = ComponentProps<"p"> & WithAsChild;
|
|
20
55
|
|
|
21
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Props for {@link EmptyStateActions | `EmptyState.Actions`} — all native
|
|
58
|
+
* `<div>` attributes plus the `asChild` escape hatch.
|
|
59
|
+
*
|
|
60
|
+
* Renders a plain `<div>` with no ARIA role of its own, so buttons and links
|
|
61
|
+
* placed inside it retain their native semantics. The element is a child of the
|
|
62
|
+
* `role="status"` live region, so its text content is included in the
|
|
63
|
+
* announcement.
|
|
64
|
+
*/
|
|
22
65
|
export type EmptyStateActionsProps = ComponentProps<"div"> & WithAsChild;
|
package/src/Field/Field.tsx
CHANGED
|
@@ -36,6 +36,12 @@ import {
|
|
|
36
36
|
* `<section>`. The `data-field-*` hooks and context provider stay
|
|
37
37
|
* intact.
|
|
38
38
|
*
|
|
39
|
+
* **Styling hooks.** `data-field=""` always; `data-field-invalid` /
|
|
40
|
+
* `data-field-disabled` / `data-field-required` present (empty string)
|
|
41
|
+
* only when the corresponding flag is truthy.
|
|
42
|
+
*
|
|
43
|
+
* @extends HTMLDivElement
|
|
44
|
+
*
|
|
39
45
|
* @example
|
|
40
46
|
* ```tsx
|
|
41
47
|
* <Field.Root id="email" invalid={!!errors.email}>
|
|
@@ -85,6 +91,7 @@ export function FieldRoot({
|
|
|
85
91
|
}
|
|
86
92
|
|
|
87
93
|
/** @internal */
|
|
94
|
+
// Stryker disable next-line StringLiteral: overwritten by the compound alias — an equivalent mutant.
|
|
88
95
|
FieldRoot.displayName = "FieldRoot";
|
|
89
96
|
|
|
90
97
|
/**
|
|
@@ -95,6 +102,13 @@ FieldRoot.displayName = "FieldRoot";
|
|
|
95
102
|
* element with the `htmlFor` attribute merged on.
|
|
96
103
|
*
|
|
97
104
|
* @throws If rendered outside a `<Field.Root>`.
|
|
105
|
+
*
|
|
106
|
+
* @extends HTMLLabelElement
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```tsx
|
|
110
|
+
* <Field.Label>Email address</Field.Label>
|
|
111
|
+
* ```
|
|
98
112
|
*/
|
|
99
113
|
export function FieldLabel({
|
|
100
114
|
asChild = false,
|
|
@@ -123,6 +137,13 @@ FieldLabel.displayName = "FieldLabel";
|
|
|
123
137
|
* `<span>`, or any other element with the `id` merged on.
|
|
124
138
|
*
|
|
125
139
|
* @throws If rendered outside a `<Field.Root>`.
|
|
140
|
+
*
|
|
141
|
+
* @extends HTMLDivElement
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* ```tsx
|
|
145
|
+
* <Field.Description>We'll only use this to sign you in.</Field.Description>
|
|
146
|
+
* ```
|
|
126
147
|
*/
|
|
127
148
|
export function FieldDescription({
|
|
128
149
|
asChild = false,
|
|
@@ -150,6 +171,8 @@ FieldDescription.displayName = "FieldDescription";
|
|
|
150
171
|
* **`asChild` composition.** Pass `asChild` to render the consumer's
|
|
151
172
|
* element with the `id` and `role="alert"` merged on.
|
|
152
173
|
*
|
|
174
|
+
* @extends HTMLDivElement
|
|
175
|
+
*
|
|
153
176
|
* @example
|
|
154
177
|
* ```tsx
|
|
155
178
|
* <Field.Root invalid={!!errors.email}>
|
|
@@ -101,4 +101,15 @@ describe("Field basic rendering", () => {
|
|
|
101
101
|
// Assert
|
|
102
102
|
expect(screen.getByTestId("field")).toHaveClass("form-field");
|
|
103
103
|
});
|
|
104
|
+
|
|
105
|
+
it("sets a displayName on the compound and each sub-component", () => {
|
|
106
|
+
// Assert — empty displayNames would render each as anonymous in DevTools.
|
|
107
|
+
// `Field`, `Field.Root`, and the underlying function are one object
|
|
108
|
+
// (Object.assign compound), so the compound's "Field" alias is the
|
|
109
|
+
// observable Root displayName; the sub-components are distinct objects.
|
|
110
|
+
expect(Field.displayName).toBe("Field");
|
|
111
|
+
expect(Field.Label.displayName).toBe("FieldLabel");
|
|
112
|
+
expect(Field.Description.displayName).toBe("FieldDescription");
|
|
113
|
+
expect(Field.ErrorText.displayName).toBe("FieldErrorText");
|
|
114
|
+
});
|
|
104
115
|
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { render } from "@testing-library/react";
|
|
2
|
+
|
|
3
|
+
import { Field } from "../Field";
|
|
4
|
+
import { FieldContext } from "../FieldContext";
|
|
5
|
+
|
|
6
|
+
describe("Field error handling", () => {
|
|
7
|
+
it("throws a helpful error when a sub-component is rendered outside Root", () => {
|
|
8
|
+
// Arrange
|
|
9
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
10
|
+
|
|
11
|
+
// Assert
|
|
12
|
+
expect(() => render(<Field.Label>Email</Field.Label>)).toThrow(
|
|
13
|
+
/Field sub-components must be rendered inside a <Field\.Root>/,
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
error.mockRestore();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("names the context for React DevTools", () => {
|
|
20
|
+
// Assert — an empty displayName would render the provider as anonymous.
|
|
21
|
+
expect(FieldContext.displayName).toBe("FieldContext");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -72,4 +72,25 @@ describe("Field state cascade", () => {
|
|
|
72
72
|
// Assert
|
|
73
73
|
expect(screen.getByText("Required.")).toBeInTheDocument();
|
|
74
74
|
});
|
|
75
|
+
|
|
76
|
+
it("re-derives the cascaded context when a flag changes after mount", () => {
|
|
77
|
+
// Arrange — a valid field: the error is gated off.
|
|
78
|
+
const { rerender } = render(
|
|
79
|
+
<Field.Root>
|
|
80
|
+
<Field.ErrorText>Required.</Field.ErrorText>
|
|
81
|
+
</Field.Root>,
|
|
82
|
+
);
|
|
83
|
+
expect(screen.queryByText("Required.")).not.toBeInTheDocument();
|
|
84
|
+
|
|
85
|
+
// Act — flip to invalid; the memoised context value must recompute.
|
|
86
|
+
rerender(
|
|
87
|
+
<Field.Root invalid>
|
|
88
|
+
<Field.ErrorText>Required.</Field.ErrorText>
|
|
89
|
+
</Field.Root>,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Assert — a frozen memo (empty dependency array) would leave the error
|
|
93
|
+
// gated off; the recomputed context reveals it.
|
|
94
|
+
expect(screen.getByText("Required.")).toBeInTheDocument();
|
|
95
|
+
});
|
|
75
96
|
});
|
|
@@ -5,20 +5,26 @@ import { useFieldProps } from "../useFieldProps";
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* `useFieldProps` is a reusable hook that reads {@link FieldContext} directly,
|
|
8
|
-
* so it is exercised here against a hand-built provider value
|
|
9
|
-
* full `Field.Root
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* so it is exercised here against a hand-built provider value (and, for the
|
|
9
|
+
* no-context path, no provider at all) rather than the full `Field.Root` —
|
|
10
|
+
* always through a real component that consumes it. `Field.Root` always
|
|
11
|
+
* supplies a truthy `descriptionId`, so its defensive "nothing to describe"
|
|
12
|
+
* fallback is only reachable through a custom provider like the one below.
|
|
12
13
|
*/
|
|
13
|
-
|
|
14
|
+
type ProbeProps = {
|
|
15
|
+
id?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
required?: boolean;
|
|
18
|
+
"aria-invalid"?: boolean | "true" | "false";
|
|
19
|
+
"aria-describedby"?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function Probe(props: ProbeProps) {
|
|
14
23
|
const merged = useFieldProps(props);
|
|
15
24
|
return <input data-testid="probe" {...merged} />;
|
|
16
25
|
}
|
|
17
26
|
|
|
18
|
-
function renderInField(
|
|
19
|
-
field: Partial<FieldContextValue>,
|
|
20
|
-
props: { "aria-describedby"?: string } = {},
|
|
21
|
-
) {
|
|
27
|
+
function renderInField(field: Partial<FieldContextValue>, props: ProbeProps = {}) {
|
|
22
28
|
const value: FieldContextValue = {
|
|
23
29
|
id: "f",
|
|
24
30
|
descriptionId: "",
|
|
@@ -35,6 +41,80 @@ function renderInField(
|
|
|
35
41
|
);
|
|
36
42
|
}
|
|
37
43
|
|
|
44
|
+
describe("useFieldProps without a FieldContext", () => {
|
|
45
|
+
it("returns the consumer props unchanged when no provider is in scope", () => {
|
|
46
|
+
// Arrange & Act — no <FieldContext.Provider>: the early guard must fire.
|
|
47
|
+
render(<Probe id="consumer-id" aria-invalid="true" />);
|
|
48
|
+
const probe = screen.getByTestId("probe");
|
|
49
|
+
|
|
50
|
+
// Assert — consumer props survive untouched and no field wiring is added.
|
|
51
|
+
expect(probe).toHaveAttribute("id", "consumer-id");
|
|
52
|
+
expect(probe).toHaveAttribute("aria-invalid", "true");
|
|
53
|
+
expect(probe).not.toHaveAttribute("aria-describedby");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("useFieldProps inheriting from the field", () => {
|
|
58
|
+
it("adopts the field's id, disabled, required, and invalid state when the consumer omits them", () => {
|
|
59
|
+
// Arrange & Act
|
|
60
|
+
renderInField(
|
|
61
|
+
{
|
|
62
|
+
id: "field-id",
|
|
63
|
+
descriptionId: "field-desc",
|
|
64
|
+
errorId: "field-err",
|
|
65
|
+
invalid: true,
|
|
66
|
+
disabled: true,
|
|
67
|
+
required: true,
|
|
68
|
+
},
|
|
69
|
+
{},
|
|
70
|
+
);
|
|
71
|
+
const probe = screen.getByTestId("probe");
|
|
72
|
+
|
|
73
|
+
// Assert — every value falls back to the field.
|
|
74
|
+
expect(probe).toHaveAttribute("id", "field-id");
|
|
75
|
+
expect(probe).toBeDisabled();
|
|
76
|
+
expect(probe).toBeRequired();
|
|
77
|
+
expect(probe).toHaveAttribute("aria-invalid", "true");
|
|
78
|
+
expect(probe).toHaveAttribute("aria-describedby", "field-desc field-err");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("omits aria-invalid entirely when the field is valid and the consumer is silent", () => {
|
|
82
|
+
// Arrange & Act
|
|
83
|
+
renderInField({ invalid: false }, {});
|
|
84
|
+
|
|
85
|
+
// Assert — a valid field contributes no aria-invalid, so the attribute is
|
|
86
|
+
// absent rather than rendered as aria-invalid="false".
|
|
87
|
+
expect(screen.getByTestId("probe")).not.toHaveAttribute("aria-invalid");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("useFieldProps with consumer overrides", () => {
|
|
92
|
+
it("lets consumer id, disabled, required, and aria-invalid win over the field", () => {
|
|
93
|
+
// Arrange & Act — field would contribute the opposite of each override.
|
|
94
|
+
renderInField(
|
|
95
|
+
{
|
|
96
|
+
id: "field-id",
|
|
97
|
+
invalid: false,
|
|
98
|
+
disabled: false,
|
|
99
|
+
required: false,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: "consumer-id",
|
|
103
|
+
disabled: true,
|
|
104
|
+
required: true,
|
|
105
|
+
"aria-invalid": "true",
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
const probe = screen.getByTestId("probe");
|
|
109
|
+
|
|
110
|
+
// Assert — the consumer's explicit values are preserved.
|
|
111
|
+
expect(probe).toHaveAttribute("id", "consumer-id");
|
|
112
|
+
expect(probe).toBeDisabled();
|
|
113
|
+
expect(probe).toBeRequired();
|
|
114
|
+
expect(probe).toHaveAttribute("aria-invalid", "true");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
38
118
|
describe("useFieldProps aria-describedby composition", () => {
|
|
39
119
|
it("omits aria-describedby entirely when nothing contributes an id", () => {
|
|
40
120
|
// Arrange & Act — empty description/error ids, not invalid, no consumer id.
|
|
@@ -44,4 +124,19 @@ describe("useFieldProps aria-describedby composition", () => {
|
|
|
44
124
|
// rather than rendered as aria-describedby="".
|
|
45
125
|
expect(screen.getByTestId("probe")).not.toHaveAttribute("aria-describedby");
|
|
46
126
|
});
|
|
127
|
+
|
|
128
|
+
it("prepends the consumer's ids ahead of the field's, space-separated", () => {
|
|
129
|
+
// Arrange & Act — a valid field so the errorId does not join in.
|
|
130
|
+
renderInField(
|
|
131
|
+
{ descriptionId: "field-desc", errorId: "field-err", invalid: false },
|
|
132
|
+
{ "aria-describedby": "consumer-desc" },
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// Assert — consumer id first, then the field's descriptionId; the errorId is
|
|
136
|
+
// excluded while valid.
|
|
137
|
+
expect(screen.getByTestId("probe")).toHaveAttribute(
|
|
138
|
+
"aria-describedby",
|
|
139
|
+
"consumer-desc field-desc",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
47
142
|
});
|
package/src/Field/types.ts
CHANGED
|
@@ -1,37 +1,85 @@
|
|
|
1
1
|
import { ComponentProps, ReactNode } from "react";
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Props for {@link FieldRoot | `Field.Root`} — the wrapper that owns the
|
|
5
|
+
* shared field id, derived `descriptionId` / `errorId`, and the
|
|
6
|
+
* `invalid` / `disabled` / `required` cascade. Extends the native
|
|
7
|
+
* `<div>` props; `id` is redeclared purely to document its role as the
|
|
8
|
+
* field-wide id (its type is unchanged from the native attribute).
|
|
9
|
+
*/
|
|
4
10
|
export type FieldRootProps = ComponentProps<"div"> & {
|
|
5
|
-
/** Stable id
|
|
11
|
+
/** Stable id for the field. Wired to the control via
|
|
12
|
+
* {@link FieldLabel | `Field.Label`}'s `htmlFor`, and used to derive
|
|
13
|
+
* the `descriptionId` (`<id>-description`) and `errorId`
|
|
14
|
+
* (`<id>-error`) exposed through {@link FieldContext}. Auto-generated
|
|
15
|
+
* via React's `useId` when omitted. */
|
|
6
16
|
id?: string;
|
|
7
|
-
/** Marks the field
|
|
17
|
+
/** Marks the field invalid. Cascades to a context-aware control (e.g.
|
|
18
|
+
* `Input`) as `aria-invalid`, sets `data-field-invalid` on the
|
|
19
|
+
* wrapper, and gates {@link FieldErrorText | `Field.ErrorText`}
|
|
20
|
+
* rendering.
|
|
21
|
+
* @default false */
|
|
8
22
|
invalid?: boolean;
|
|
9
|
-
/** Disables the field
|
|
23
|
+
/** Disables the field. Cascades to the control's `disabled` prop and
|
|
24
|
+
* sets `data-field-disabled` on the wrapper.
|
|
25
|
+
* @default false */
|
|
10
26
|
disabled?: boolean;
|
|
11
|
-
/** Marks the field
|
|
27
|
+
/** Marks the field required. Cascades to the control's `required` prop
|
|
28
|
+
* and sets `data-field-required` on the wrapper.
|
|
29
|
+
* @default false */
|
|
12
30
|
required?: boolean;
|
|
13
|
-
/**
|
|
31
|
+
/** Render the consumer's element instead of `<div>` via the
|
|
32
|
+
* {@link Slot} pattern — e.g. a semantic `<fieldset>`. The
|
|
33
|
+
* `data-field-*` hooks and context provider are preserved.
|
|
34
|
+
* @default false */
|
|
14
35
|
asChild?: boolean;
|
|
36
|
+
/** The label, control, description, and error sub-components composed
|
|
37
|
+
* inside the field. */
|
|
15
38
|
children?: ReactNode;
|
|
16
39
|
};
|
|
17
40
|
|
|
18
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Props for {@link FieldLabel | `Field.Label`} — the field's `<label>`,
|
|
43
|
+
* wired to the field id via `htmlFor`. Extends the native `<label>`
|
|
44
|
+
* props.
|
|
45
|
+
*/
|
|
19
46
|
export type FieldLabelProps = ComponentProps<"label"> & {
|
|
20
|
-
/**
|
|
47
|
+
/** Render the consumer's element instead of `<label>` via the
|
|
48
|
+
* {@link Slot} pattern, with `htmlFor` merged on.
|
|
49
|
+
* @default false */
|
|
21
50
|
asChild?: boolean;
|
|
51
|
+
/** The label text. */
|
|
22
52
|
children?: ReactNode;
|
|
23
53
|
};
|
|
24
54
|
|
|
25
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Props for {@link FieldDescription | `Field.Description`} — the field's
|
|
57
|
+
* supporting helper text, carrying the field's `descriptionId` so a
|
|
58
|
+
* context-aware control can reference it via `aria-describedby`. Extends
|
|
59
|
+
* the native `<div>` props.
|
|
60
|
+
*/
|
|
26
61
|
export type FieldDescriptionProps = ComponentProps<"div"> & {
|
|
27
|
-
/**
|
|
62
|
+
/** Render the consumer's element instead of `<div>` via the
|
|
63
|
+
* {@link Slot} pattern (e.g. a `<p>` or `<span>`), with the derived
|
|
64
|
+
* `id` merged on.
|
|
65
|
+
* @default false */
|
|
28
66
|
asChild?: boolean;
|
|
67
|
+
/** The helper text. */
|
|
29
68
|
children?: ReactNode;
|
|
30
69
|
};
|
|
31
70
|
|
|
32
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Props for {@link FieldErrorText | `Field.ErrorText`} — the error
|
|
73
|
+
* message shown only when the field is
|
|
74
|
+
* {@link FieldRootProps.invalid | invalid}. Renders `role="alert"` and
|
|
75
|
+
* carries the field's `errorId`. Extends the native `<div>` props.
|
|
76
|
+
*/
|
|
33
77
|
export type FieldErrorTextProps = ComponentProps<"div"> & {
|
|
34
|
-
/**
|
|
78
|
+
/** Render the consumer's element instead of `<div>` via the
|
|
79
|
+
* {@link Slot} pattern, with the derived `id` and `role="alert"`
|
|
80
|
+
* merged on.
|
|
81
|
+
* @default false */
|
|
35
82
|
asChild?: boolean;
|
|
83
|
+
/** The error message. */
|
|
36
84
|
children?: ReactNode;
|
|
37
85
|
};
|
|
@@ -3,24 +3,37 @@ import type { ReactElement } from "react";
|
|
|
3
3
|
import { FieldsetLegendProps, FieldsetProps } from "./types";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* The root of a Fieldset — renders a native `<fieldset>` element
|
|
6
|
+
* The root of a Fieldset — renders a native `<fieldset>` element with an
|
|
7
|
+
* implicit `role="group"`.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Groups a set of related form controls (radios, checkboxes, inputs). Pair
|
|
10
|
+
* it with a {@link FieldsetLegend} as the first child so the group is
|
|
11
|
+
* announced with an accessible name when the user moves focus into any
|
|
12
|
+
* control inside it.
|
|
12
13
|
*
|
|
13
|
-
* **
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `data-disabled=""` so CSS can target the state without the `:disabled`
|
|
17
|
-
* pseudo-class:
|
|
14
|
+
* **Styling hooks.** `data-disabled=""` is set when the `disabled` prop is
|
|
15
|
+
* truthy (omitted otherwise), so CSS can target the state without relying
|
|
16
|
+
* on the `:disabled` pseudo-class:
|
|
18
17
|
*
|
|
19
18
|
* ```css
|
|
20
|
-
* fieldset[data-disabled] { opacity: 0.5; }
|
|
19
|
+
* fieldset[data-disabled] { opacity: 0.5; cursor: not-allowed; }
|
|
21
20
|
* ```
|
|
22
21
|
*
|
|
23
|
-
*
|
|
22
|
+
* **Disabled.** Forwarding the native `disabled` attribute disables *every*
|
|
23
|
+
* form control nested inside the fieldset at once — the standard way to
|
|
24
|
+
* disable a whole section of a form.
|
|
25
|
+
*
|
|
26
|
+
* **Ref forwarding.** Pass a `ref` prop to access the underlying
|
|
27
|
+
* `HTMLFieldSetElement` directly:
|
|
28
|
+
*
|
|
29
|
+
* ```tsx
|
|
30
|
+
* const ref = useRef<HTMLFieldSetElement>(null);
|
|
31
|
+
* <Fieldset.Root ref={ref}>…</Fieldset.Root>
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* @extends HTMLFieldSetElement
|
|
35
|
+
*
|
|
36
|
+
* @example Basic grouping
|
|
24
37
|
* ```tsx
|
|
25
38
|
* <Fieldset.Root>
|
|
26
39
|
* <Fieldset.Legend>Notifications</Fieldset.Legend>
|
|
@@ -28,6 +41,14 @@ import { FieldsetLegendProps, FieldsetProps } from "./types";
|
|
|
28
41
|
* <label><input type="checkbox" name="sms" /> SMS</label>
|
|
29
42
|
* </Fieldset.Root>
|
|
30
43
|
* ```
|
|
44
|
+
*
|
|
45
|
+
* @example Disabled section
|
|
46
|
+
* ```tsx
|
|
47
|
+
* <Fieldset.Root disabled>
|
|
48
|
+
* <Fieldset.Legend>Billing</Fieldset.Legend>
|
|
49
|
+
* …every nested control is disabled…
|
|
50
|
+
* </Fieldset.Root>
|
|
51
|
+
* ```
|
|
31
52
|
*/
|
|
32
53
|
export function FieldsetRoot({
|
|
33
54
|
disabled,
|
|
@@ -46,14 +67,26 @@ export function FieldsetRoot({
|
|
|
46
67
|
}
|
|
47
68
|
|
|
48
69
|
/** @internal */
|
|
70
|
+
// Stryker disable next-line StringLiteral: overwritten by the compound alias — an equivalent mutant.
|
|
49
71
|
FieldsetRoot.displayName = "Fieldset";
|
|
50
72
|
|
|
51
73
|
/**
|
|
52
|
-
* The caption for a {@link Fieldset} — renders a
|
|
74
|
+
* The caption for a {@link FieldsetRoot | `Fieldset.Root`} — renders a
|
|
75
|
+
* native `<legend>`.
|
|
76
|
+
*
|
|
77
|
+
* Must be the **first child** of the `<fieldset>`. It supplies the group's
|
|
78
|
+
* accessible name; assistive technology announces it when the user moves
|
|
79
|
+
* focus into any control within the group.
|
|
53
80
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
81
|
+
* **Ref forwarding.** Pass a `ref` prop to access the underlying
|
|
82
|
+
* `HTMLLegendElement` directly:
|
|
83
|
+
*
|
|
84
|
+
* ```tsx
|
|
85
|
+
* const ref = useRef<HTMLLegendElement>(null);
|
|
86
|
+
* <Fieldset.Legend ref={ref}>Address</Fieldset.Legend>
|
|
87
|
+
* ```
|
|
88
|
+
*
|
|
89
|
+
* @extends HTMLLegendElement
|
|
57
90
|
*
|
|
58
91
|
* @example
|
|
59
92
|
* ```tsx
|
|
@@ -83,11 +116,11 @@ export type FieldsetCompound = typeof FieldsetRoot & {
|
|
|
83
116
|
* Headless, accessible **Fieldset** — a stateless compound component that
|
|
84
117
|
* groups related form controls, with zero styles.
|
|
85
118
|
*
|
|
86
|
-
* `Fieldset` is both callable (an alias of {@link
|
|
119
|
+
* `Fieldset` is both callable (an alias of {@link FieldsetRoot | `Fieldset.Root`})
|
|
87
120
|
* and carries its sub-component as a static property. Prefer the
|
|
88
121
|
* namespaced form in application code:
|
|
89
122
|
*
|
|
90
|
-
* - {@link
|
|
123
|
+
* - {@link FieldsetRoot | `Fieldset.Root`} — `<fieldset>`, implicit `role="group"`.
|
|
91
124
|
* - {@link FieldsetLegend | `Fieldset.Legend`} — `<legend>`, the group's accessible name.
|
|
92
125
|
*
|
|
93
126
|
* @example Grouping a set of radios
|
|
@@ -108,6 +141,9 @@ export type FieldsetCompound = typeof FieldsetRoot & {
|
|
|
108
141
|
* …every nested control is disabled…
|
|
109
142
|
* </Fieldset.Root>
|
|
110
143
|
* ```
|
|
144
|
+
*
|
|
145
|
+
* @see {@link FieldsetRoot} for the `disabled` prop and `data-disabled` styling hook.
|
|
146
|
+
* @see {@link FieldsetLegend} for accessible name requirements.
|
|
111
147
|
*/
|
|
112
148
|
const FieldsetCompound: FieldsetCompound = Object.assign(FieldsetRoot, {
|
|
113
149
|
Root: FieldsetRoot,
|
|
@@ -78,4 +78,13 @@ describe("Fieldset basic rendering", () => {
|
|
|
78
78
|
// Assert
|
|
79
79
|
expect(screen.getByText("Billing")).toHaveClass("group__legend");
|
|
80
80
|
});
|
|
81
|
+
|
|
82
|
+
it("sets a displayName on the compound and Legend", () => {
|
|
83
|
+
// Assert — empty displayNames would render each as anonymous in DevTools.
|
|
84
|
+
// `Fieldset`, `Fieldset.Root`, and the underlying function are one object
|
|
85
|
+
// (Object.assign compound), so the compound's "Fieldset" alias is the
|
|
86
|
+
// observable Root displayName; Legend is a distinct object.
|
|
87
|
+
expect(Fieldset.displayName).toBe("Fieldset");
|
|
88
|
+
expect(Fieldset.Legend.displayName).toBe("FieldsetLegend");
|
|
89
|
+
});
|
|
81
90
|
});
|
package/src/Fieldset/types.ts
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
import { ComponentProps } from "react";
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Props for {@link FieldsetRoot | `Fieldset.Root`} — all native `<fieldset>`
|
|
5
|
+
* attributes plus the `data-disabled` styling hook.
|
|
6
|
+
*
|
|
7
|
+
* No custom props narrow any native attribute, so the full `ComponentProps<"fieldset">`
|
|
8
|
+
* surface is passed through unchanged, including `ref` (forwarded to the
|
|
9
|
+
* underlying `HTMLFieldSetElement`).
|
|
10
|
+
*
|
|
11
|
+
* The one behaviour addition over a bare `<fieldset>` is `data-disabled=""`:
|
|
12
|
+
* when `disabled` is truthy the component sets this attribute alongside the
|
|
13
|
+
* native `disabled`, so CSS can target `[data-disabled]` without relying on
|
|
14
|
+
* the `:disabled` pseudo-class.
|
|
15
|
+
*/
|
|
4
16
|
export type FieldsetProps = ComponentProps<"fieldset">;
|
|
5
17
|
|
|
6
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Props for {@link FieldsetLegend | `Fieldset.Legend`} — all native `<legend>`
|
|
20
|
+
* attributes, including `ref` (forwarded to the underlying `HTMLLegendElement`).
|
|
21
|
+
*
|
|
22
|
+
* `<legend>` has no interactive state, so no additional `data-*` attributes
|
|
23
|
+
* are emitted beyond what is passed explicitly by the consumer.
|
|
24
|
+
*/
|
|
7
25
|
export type FieldsetLegendProps = ComponentProps<"legend">;
|
package/src/Input/Input.tsx
CHANGED
|
@@ -5,12 +5,9 @@ import { Slot } from "../Slot/index.ts";
|
|
|
5
5
|
import { InputProps } from "./types";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* A headless, accessible single-line text input.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* straight through to the DOM — `value` / `defaultValue`, `placeholder`,
|
|
12
|
-
* `type`, `name`, `required`, `pattern`, `min`, `max`, `step`,
|
|
13
|
-
* `maxLength`, and so on. No styles ship with the component.
|
|
8
|
+
* A headless, accessible single-line text input. Renders a native
|
|
9
|
+
* `<input>` element and passes every standard attribute straight through
|
|
10
|
+
* to the DOM. No styles ship with the component.
|
|
14
11
|
*
|
|
15
12
|
* **Default type.** `type="text"` is set by default. Override with the
|
|
16
13
|
* `type` prop for any native variant (`"email"`, `"password"`,
|
|
@@ -45,6 +42,11 @@ import { InputProps } from "./types";
|
|
|
45
42
|
* ids first, then field-supplied description / error ids). Outside a
|
|
46
43
|
* `<Field.Root>`, behaviour is unchanged.
|
|
47
44
|
*
|
|
45
|
+
* **Styling hooks.**
|
|
46
|
+
* - `data-disabled=""` — present when `disabled` is `true`; absent
|
|
47
|
+
* otherwise. Lets CSS target `[data-disabled]` without depending on
|
|
48
|
+
* the `:disabled` pseudo-class.
|
|
49
|
+
*
|
|
48
50
|
* **Ref forwarding.** Pass a `ref` prop to access the underlying
|
|
49
51
|
* `HTMLInputElement`:
|
|
50
52
|
*
|
|
@@ -62,14 +64,17 @@ import { InputProps } from "./types";
|
|
|
62
64
|
* ```
|
|
63
65
|
*
|
|
64
66
|
* **`asChild` composition.** Renders the consumer's element instead of
|
|
65
|
-
* `<input>`, merging all props (aria
|
|
66
|
-
* via the {@link Slot} utility.
|
|
67
|
-
*
|
|
67
|
+
* `<input>`, merging all props (`aria-*`, `data-*`, event handlers,
|
|
68
|
+
* `ref`) via the {@link Slot} utility. Event handlers compose with the
|
|
69
|
+
* child's own handlers (child runs first). `type` is **not** forwarded
|
|
70
|
+
* in this mode — the child element owns its own type semantics.
|
|
68
71
|
*
|
|
69
72
|
* **Adornments** live in the separate `InputGroup` primitive — leading
|
|
70
73
|
* / trailing icons, currency symbols, clear buttons, password-reveal
|
|
71
74
|
* toggles.
|
|
72
75
|
*
|
|
76
|
+
* @extends HTMLInputElement
|
|
77
|
+
*
|
|
73
78
|
* @example Basic usage
|
|
74
79
|
* ```tsx
|
|
75
80
|
* <label htmlFor="email">Email</label>
|