@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
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import "./dialog-polyfill";
|
|
2
|
+
|
|
3
|
+
import { createEvent, fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
import userEvent from "@testing-library/user-event";
|
|
5
|
+
|
|
6
|
+
import { Modal } from "../Modal";
|
|
7
|
+
|
|
8
|
+
function getDialog() {
|
|
9
|
+
return document.querySelector("dialog") as HTMLDialogElement;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// WAI-ARIA APG Modal Dialog pattern: Tab must cycle *within* the dialog and
|
|
13
|
+
// never reach the browser chrome. A native <dialog> inerts the background but
|
|
14
|
+
// does not wrap focus, so Modal.Content adds a boundary Tab-wrap trap. These
|
|
15
|
+
// tests render a Trigger (outside the dialog) in the wrap cases so that,
|
|
16
|
+
// without the trap, a native wrap would land on the Trigger — proving the
|
|
17
|
+
// assertion is driven by the trap, not by jsdom's own tab order.
|
|
18
|
+
describe("Modal focus trap — Tab cycles within the dialog", () => {
|
|
19
|
+
it("wraps focus from the last element back to the first on Tab", async () => {
|
|
20
|
+
// Arrange
|
|
21
|
+
const user = userEvent.setup();
|
|
22
|
+
render(
|
|
23
|
+
<Modal.Root defaultOpen>
|
|
24
|
+
<Modal.Trigger>Open</Modal.Trigger>
|
|
25
|
+
<Modal.Content>
|
|
26
|
+
<button type="button">First</button>
|
|
27
|
+
<button type="button">Second</button>
|
|
28
|
+
<button type="button">Third</button>
|
|
29
|
+
</Modal.Content>
|
|
30
|
+
</Modal.Root>,
|
|
31
|
+
);
|
|
32
|
+
screen.getByRole("button", { name: "Third" }).focus();
|
|
33
|
+
|
|
34
|
+
// Act
|
|
35
|
+
await user.tab();
|
|
36
|
+
|
|
37
|
+
// Assert
|
|
38
|
+
expect(screen.getByRole("button", { name: "First" })).toHaveFocus();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("leaves an interior forward Tab to native handling (no wrap)", async () => {
|
|
42
|
+
// Arrange
|
|
43
|
+
const user = userEvent.setup();
|
|
44
|
+
render(
|
|
45
|
+
<Modal.Root defaultOpen>
|
|
46
|
+
<Modal.Content>
|
|
47
|
+
<button type="button">First</button>
|
|
48
|
+
<button type="button">Second</button>
|
|
49
|
+
<button type="button">Third</button>
|
|
50
|
+
</Modal.Content>
|
|
51
|
+
</Modal.Root>,
|
|
52
|
+
);
|
|
53
|
+
screen.getByRole("button", { name: "First" }).focus();
|
|
54
|
+
|
|
55
|
+
// Act
|
|
56
|
+
await user.tab();
|
|
57
|
+
|
|
58
|
+
// Assert
|
|
59
|
+
expect(screen.getByRole("button", { name: "Second" })).toHaveFocus();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("wraps focus from the first element to the last on Shift+Tab", async () => {
|
|
63
|
+
// Arrange
|
|
64
|
+
const user = userEvent.setup();
|
|
65
|
+
render(
|
|
66
|
+
<Modal.Root defaultOpen>
|
|
67
|
+
<Modal.Trigger>Open</Modal.Trigger>
|
|
68
|
+
<Modal.Content>
|
|
69
|
+
<button type="button">First</button>
|
|
70
|
+
<button type="button">Second</button>
|
|
71
|
+
<button type="button">Third</button>
|
|
72
|
+
</Modal.Content>
|
|
73
|
+
</Modal.Root>,
|
|
74
|
+
);
|
|
75
|
+
screen.getByRole("button", { name: "First" }).focus();
|
|
76
|
+
|
|
77
|
+
// Act
|
|
78
|
+
await user.tab({ shift: true });
|
|
79
|
+
|
|
80
|
+
// Assert
|
|
81
|
+
expect(screen.getByRole("button", { name: "Third" })).toHaveFocus();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("leaves an interior Shift+Tab to native handling (no wrap)", async () => {
|
|
85
|
+
// Arrange
|
|
86
|
+
const user = userEvent.setup();
|
|
87
|
+
render(
|
|
88
|
+
<Modal.Root defaultOpen>
|
|
89
|
+
<Modal.Content>
|
|
90
|
+
<button type="button">First</button>
|
|
91
|
+
<button type="button">Second</button>
|
|
92
|
+
<button type="button">Third</button>
|
|
93
|
+
</Modal.Content>
|
|
94
|
+
</Modal.Root>,
|
|
95
|
+
);
|
|
96
|
+
screen.getByRole("button", { name: "Second" }).focus();
|
|
97
|
+
|
|
98
|
+
// Act
|
|
99
|
+
await user.tab({ shift: true });
|
|
100
|
+
|
|
101
|
+
// Assert
|
|
102
|
+
expect(screen.getByRole("button", { name: "First" })).toHaveFocus();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("ignores non-Tab keys (typing is never intercepted)", () => {
|
|
106
|
+
// Arrange
|
|
107
|
+
render(
|
|
108
|
+
<Modal.Root defaultOpen>
|
|
109
|
+
<Modal.Content>
|
|
110
|
+
<button type="button">First</button>
|
|
111
|
+
<button type="button">Second</button>
|
|
112
|
+
</Modal.Content>
|
|
113
|
+
</Modal.Root>,
|
|
114
|
+
);
|
|
115
|
+
const first = screen.getByRole("button", { name: "First" });
|
|
116
|
+
first.focus();
|
|
117
|
+
|
|
118
|
+
// Act
|
|
119
|
+
const event = createEvent.keyDown(first, { key: "a" });
|
|
120
|
+
fireEvent(first, event);
|
|
121
|
+
|
|
122
|
+
// Assert
|
|
123
|
+
expect(first).toHaveFocus();
|
|
124
|
+
expect(event.defaultPrevented).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("blocks Tab when the dialog has no focusable content", () => {
|
|
128
|
+
// Arrange
|
|
129
|
+
render(
|
|
130
|
+
<Modal.Root defaultOpen>
|
|
131
|
+
<Modal.Content>Just text, nothing to focus.</Modal.Content>
|
|
132
|
+
</Modal.Root>,
|
|
133
|
+
);
|
|
134
|
+
const dialog = getDialog();
|
|
135
|
+
|
|
136
|
+
// Act
|
|
137
|
+
const event = createEvent.keyDown(dialog, { key: "Tab" });
|
|
138
|
+
fireEvent(dialog, event);
|
|
139
|
+
|
|
140
|
+
// Assert — Tab is swallowed so focus cannot escape to the browser chrome.
|
|
141
|
+
expect(event.defaultPrevented).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("stays inactive while the dialog is closed", () => {
|
|
145
|
+
// Arrange — Content always renders its <dialog>; without defaultOpen it is
|
|
146
|
+
// mounted but closed, so the trap must not fire.
|
|
147
|
+
render(
|
|
148
|
+
<Modal.Root>
|
|
149
|
+
<Modal.Content>Just text, nothing to focus.</Modal.Content>
|
|
150
|
+
</Modal.Root>,
|
|
151
|
+
);
|
|
152
|
+
const dialog = getDialog();
|
|
153
|
+
expect(dialog.open).toBe(false);
|
|
154
|
+
|
|
155
|
+
// Act
|
|
156
|
+
const event = createEvent.keyDown(dialog, { key: "Tab" });
|
|
157
|
+
fireEvent(dialog, event);
|
|
158
|
+
|
|
159
|
+
// Assert — the guard returns before the empty-content block would preventDefault.
|
|
160
|
+
expect(event.defaultPrevented).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react";
|
|
2
2
|
|
|
3
|
+
import { FOCUSABLE_SELECTOR } from "../../utils/index.ts";
|
|
3
4
|
import { useModalContext } from "./useModalContext";
|
|
4
5
|
|
|
5
6
|
export function useModalContent() {
|
|
@@ -48,13 +49,44 @@ export function useModalContent() {
|
|
|
48
49
|
if (event.defaultPrevented) return;
|
|
49
50
|
setOpen(false);
|
|
50
51
|
};
|
|
52
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
53
|
+
// WAI-ARIA APG Modal Dialog: Tab must cycle within the dialog. A native
|
|
54
|
+
// modal <dialog> inerts the background (Tab can't reach the page) but
|
|
55
|
+
// does NOT wrap — Tab past the last element escapes to the browser
|
|
56
|
+
// chrome, then returns. Close only the two boundary gaps here; interior
|
|
57
|
+
// Tabs stay native so the browser's inert-aware order (radio groups,
|
|
58
|
+
// etc.) keeps working.
|
|
59
|
+
if (event.key !== "Tab") return;
|
|
60
|
+
if (!dialog.open) return;
|
|
61
|
+
const focusables = Array.from(
|
|
62
|
+
dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
|
63
|
+
);
|
|
64
|
+
if (focusables.length === 0) {
|
|
65
|
+
// Nothing tabbable inside — block Tab so focus can't leak to the
|
|
66
|
+
// browser chrome (native <dialog> would otherwise let it out).
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const first = focusables[0];
|
|
71
|
+
const last = focusables[focusables.length - 1];
|
|
72
|
+
const active = document.activeElement;
|
|
73
|
+
if (!event.shiftKey && active === last) {
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
first.focus();
|
|
76
|
+
} else if (event.shiftKey && active === first) {
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
last.focus();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
51
81
|
dialog.addEventListener("close", handleClose);
|
|
52
82
|
dialog.addEventListener("cancel", handleCancel);
|
|
53
83
|
dialog.addEventListener("pointerdown", handlePointerDown);
|
|
84
|
+
dialog.addEventListener("keydown", handleKeyDown);
|
|
54
85
|
return () => {
|
|
55
86
|
dialog.removeEventListener("close", handleClose);
|
|
56
87
|
dialog.removeEventListener("cancel", handleCancel);
|
|
57
88
|
dialog.removeEventListener("pointerdown", handlePointerDown);
|
|
89
|
+
dialog.removeEventListener("keydown", handleKeyDown);
|
|
58
90
|
};
|
|
59
91
|
}, [setOpen, contentCallbacksRef]);
|
|
60
92
|
|
package/src/Modal/types.ts
CHANGED
|
@@ -8,23 +8,36 @@ import {
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Imperative handle exposed on `Modal.Root`'s `ref`, letting callers open
|
|
11
|
-
* and close the modal programmatically.
|
|
11
|
+
* and close the modal programmatically from outside the React subtree.
|
|
12
|
+
*
|
|
13
|
+
* In controlled mode these methods defer to `onOpenChange` rather than
|
|
14
|
+
* flipping any internal state — the parent stays the source of truth.
|
|
12
15
|
*/
|
|
13
16
|
export type ModalImperativeApi = {
|
|
14
|
-
/** Opens the modal. */
|
|
17
|
+
/** Opens the modal (uncontrolled: sets open; controlled: calls `onOpenChange(true)`). */
|
|
15
18
|
open: () => void;
|
|
16
|
-
/** Closes the modal. */
|
|
19
|
+
/** Closes the modal (uncontrolled: clears open; controlled: calls `onOpenChange(false)`). */
|
|
17
20
|
close: () => void;
|
|
18
21
|
};
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
|
-
* Optional escape-hatch callbacks accepted by
|
|
22
|
-
* intercepting
|
|
24
|
+
* Optional escape-hatch callbacks accepted by
|
|
25
|
+
* {@link ModalContentProps | `Modal.Content`} for intercepting the two
|
|
26
|
+
* dismissal gestures the browser drives on a native `<dialog>`. Both receive
|
|
27
|
+
* the raw native event (not a synthetic React event).
|
|
23
28
|
*/
|
|
24
29
|
export type ModalContentCallbacks = {
|
|
25
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Fires on the dialog's native `cancel` event when the user presses
|
|
32
|
+
* Escape. Call `event.preventDefault()` to veto closing and keep the
|
|
33
|
+
* modal open (e.g. to confirm unsaved changes first).
|
|
34
|
+
*/
|
|
26
35
|
onEscapeKeyDown?: (event: Event) => void;
|
|
27
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Fires on a `pointerdown` whose coordinates land outside the dialog's
|
|
38
|
+
* bounding rect — i.e. on the browser-painted `::backdrop`. Call
|
|
39
|
+
* `event.preventDefault()` to veto closing and keep the modal open.
|
|
40
|
+
*/
|
|
28
41
|
onPointerDownOutside?: (event: PointerEvent) => void;
|
|
29
42
|
};
|
|
30
43
|
|
|
@@ -51,75 +64,143 @@ export type ModalContextValue = {
|
|
|
51
64
|
registerDescription: (id: string | undefined) => void;
|
|
52
65
|
};
|
|
53
66
|
|
|
54
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Props for `Modal.Content` — every native `<dialog>` attribute plus the
|
|
69
|
+
* two {@link ModalContentCallbacks | dismissal callbacks}. `asChild` is
|
|
70
|
+
* intentionally absent: the native `<dialog>` is what supplies the inert
|
|
71
|
+
* background, top-layer stacking, and the Tab-wrap focus trap, so it cannot
|
|
72
|
+
* be swapped for another element.
|
|
73
|
+
*/
|
|
55
74
|
export type ModalContentProps = ComponentProps<"dialog"> &
|
|
56
75
|
ModalContentCallbacks;
|
|
57
76
|
|
|
58
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* `Modal.Root` props for the **uncontrolled** (self-managed) open state.
|
|
79
|
+
* The component owns the open flag internally. Mutually exclusive with
|
|
80
|
+
* {@link ControlledModalRootProps}.
|
|
81
|
+
*/
|
|
59
82
|
export type UncontrolledModalRootProps = {
|
|
60
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Whether the modal is open on first render. The component owns the flag
|
|
85
|
+
* from then on. Omit for closed-on-mount.
|
|
86
|
+
* @default false
|
|
87
|
+
*/
|
|
61
88
|
defaultOpen?: boolean;
|
|
89
|
+
/** Forbidden in uncontrolled mode — use `defaultOpen` instead. */
|
|
62
90
|
open?: never;
|
|
63
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* Called with the new open state whenever it changes (open or close).
|
|
93
|
+
* Optional in uncontrolled mode — useful purely to observe transitions.
|
|
94
|
+
*/
|
|
64
95
|
onOpenChange?: (open: boolean) => void;
|
|
65
96
|
};
|
|
66
97
|
|
|
67
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* `Modal.Root` props for the **controlled** open state. The parent owns the
|
|
100
|
+
* flag and must keep it in sync via `onOpenChange`. Mutually exclusive with
|
|
101
|
+
* {@link UncontrolledModalRootProps}.
|
|
102
|
+
*/
|
|
68
103
|
export type ControlledModalRootProps = {
|
|
69
|
-
/**
|
|
104
|
+
/** The current open state. Must be kept in sync by the parent via `onOpenChange`. */
|
|
70
105
|
open: boolean;
|
|
71
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Called with the requested open state whenever the modal wants to open or
|
|
108
|
+
* close (trigger click, Close button, Esc, click-outside, imperative API).
|
|
109
|
+
* Required in controlled mode.
|
|
110
|
+
*/
|
|
72
111
|
onOpenChange: (open: boolean) => void;
|
|
112
|
+
/** Forbidden in controlled mode — use `open` instead. */
|
|
73
113
|
defaultOpen?: never;
|
|
74
114
|
};
|
|
75
115
|
|
|
76
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* Props for {@link ModalRoot | `Modal.Root`}, in either the controlled or
|
|
118
|
+
* uncontrolled mode — resolves to
|
|
119
|
+
* {@link UncontrolledModalRootProps} | {@link ControlledModalRootProps}, so
|
|
120
|
+
* TypeScript accepts only one shape at a time.
|
|
121
|
+
*/
|
|
77
122
|
export type ModalRootProps = {
|
|
78
|
-
/**
|
|
123
|
+
/** The modal's sub-components (Trigger, Portal, Overlay, Content, …). */
|
|
79
124
|
children?: ReactNode;
|
|
80
125
|
} & (UncontrolledModalRootProps | ControlledModalRootProps) & {
|
|
81
|
-
/** Ref receiving the imperative open/close handle. */
|
|
126
|
+
/** Ref receiving the imperative {@link ModalImperativeApi | open/close handle}. */
|
|
82
127
|
ref?: Ref<ModalImperativeApi>;
|
|
83
128
|
};
|
|
84
129
|
|
|
85
130
|
/** Props for `Modal.Trigger` — a `<button>` that opens the modal. */
|
|
86
131
|
export type ModalTriggerProps = ComponentProps<"button"> & {
|
|
87
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* Render into the consumer's own element (e.g. a router `<Link>`) instead
|
|
134
|
+
* of the default `<button>`, merging the trigger's ARIA wiring, composed
|
|
135
|
+
* event handlers, and ref onto it via the Slot pattern.
|
|
136
|
+
* @default false
|
|
137
|
+
*/
|
|
88
138
|
asChild?: boolean;
|
|
89
139
|
};
|
|
90
140
|
|
|
91
141
|
/** Props for `Modal.Close` — a `<button>` that closes the modal. */
|
|
92
142
|
export type ModalCloseProps = ComponentProps<"button"> & {
|
|
93
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* Render into the consumer's own element (e.g. an icon-only button)
|
|
145
|
+
* instead of the default `<button>`, merging the close behaviour in. The
|
|
146
|
+
* consumer's `onClick` runs first and can `event.preventDefault()` to veto.
|
|
147
|
+
* @default false
|
|
148
|
+
*/
|
|
94
149
|
asChild?: boolean;
|
|
95
150
|
};
|
|
96
151
|
|
|
97
152
|
/** Props for `Modal.Portal` — renders its children into `container`. */
|
|
98
153
|
export type ModalPortalProps = {
|
|
99
|
-
/**
|
|
154
|
+
/** The dialog subtree to portal — typically `Modal.Overlay` + `Modal.Content`. */
|
|
100
155
|
children?: ReactNode;
|
|
101
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* Target element to portal into.
|
|
158
|
+
* @default document.body
|
|
159
|
+
*/
|
|
102
160
|
container?: HTMLElement;
|
|
103
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* Keep the portalled subtree mounted after `open` flips false, so a CSS
|
|
163
|
+
* exit animation keyed off `data-state="closed"` can play. Without it the
|
|
164
|
+
* portal unmounts as soon as the modal closes.
|
|
165
|
+
* @default false
|
|
166
|
+
*/
|
|
104
167
|
forceMount?: boolean;
|
|
105
168
|
};
|
|
106
169
|
|
|
107
|
-
/** Props for `Modal.Overlay` — the
|
|
170
|
+
/** Props for `Modal.Overlay` — the animation-friendly backdrop sibling. */
|
|
108
171
|
export type ModalOverlayProps = ComponentProps<"div"> & {
|
|
109
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* Render into the consumer's own element (e.g. a motion wrapper) instead of
|
|
174
|
+
* the default `<div>`, merging the overlay's `aria-hidden` and `data-state`
|
|
175
|
+
* onto it via the Slot pattern.
|
|
176
|
+
* @default false
|
|
177
|
+
*/
|
|
110
178
|
asChild?: boolean;
|
|
111
|
-
/**
|
|
179
|
+
/**
|
|
180
|
+
* Keep the overlay mounted after `open` flips false so a CSS fade-out can
|
|
181
|
+
* play. Without it the overlay unmounts as soon as the modal closes.
|
|
182
|
+
* @default false
|
|
183
|
+
*/
|
|
112
184
|
forceMount?: boolean;
|
|
113
185
|
};
|
|
114
186
|
|
|
115
|
-
/** Props for `Modal.Title` — supplies the dialog's accessible name. */
|
|
187
|
+
/** Props for `Modal.Title` — supplies the dialog's accessible name (`aria-labelledby`). */
|
|
116
188
|
export type ModalTitleProps = HTMLAttributes<HTMLElement> & {
|
|
117
|
-
/**
|
|
189
|
+
/**
|
|
190
|
+
* Render into the consumer's own heading element (e.g. an `<h3>` for a
|
|
191
|
+
* nested dialog) instead of the default `<h2>`; the generated id is still
|
|
192
|
+
* registered for `aria-labelledby`.
|
|
193
|
+
* @default false
|
|
194
|
+
*/
|
|
118
195
|
asChild?: boolean;
|
|
119
196
|
};
|
|
120
197
|
|
|
121
|
-
/** Props for `Modal.Description` — supplies the dialog's accessible description. */
|
|
198
|
+
/** Props for `Modal.Description` — supplies the dialog's accessible description (`aria-describedby`). */
|
|
122
199
|
export type ModalDescriptionProps = HTMLAttributes<HTMLElement> & {
|
|
123
|
-
/**
|
|
200
|
+
/**
|
|
201
|
+
* Render into the consumer's own element instead of the default `<p>`; the
|
|
202
|
+
* generated id is still registered for `aria-describedby`.
|
|
203
|
+
* @default false
|
|
204
|
+
*/
|
|
124
205
|
asChild?: boolean;
|
|
125
206
|
};
|