@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,71 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
|
|
4
|
+
import { Popover } from "../Popover";
|
|
5
|
+
|
|
6
|
+
describe("Popover keyboard interaction", () => {
|
|
7
|
+
it("closes the popover when Escape is pressed", async () => {
|
|
8
|
+
// Arrange
|
|
9
|
+
const user = userEvent.setup();
|
|
10
|
+
render(
|
|
11
|
+
<Popover.Root defaultOpen>
|
|
12
|
+
<Popover.Trigger>Open</Popover.Trigger>
|
|
13
|
+
<Popover.Content data-testid="content">
|
|
14
|
+
<button type="button">Inside</button>
|
|
15
|
+
</Popover.Content>
|
|
16
|
+
</Popover.Root>,
|
|
17
|
+
);
|
|
18
|
+
const trigger = screen.getByRole("button", { name: "Open" });
|
|
19
|
+
const content = screen.getByTestId("content");
|
|
20
|
+
|
|
21
|
+
// Act
|
|
22
|
+
await user.click(screen.getByRole("button", { name: "Inside", hidden: true }));
|
|
23
|
+
await user.keyboard("{Escape}");
|
|
24
|
+
|
|
25
|
+
// Assert
|
|
26
|
+
expect(content).toHaveAttribute("data-state", "closed");
|
|
27
|
+
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns focus to the trigger after Escape", async () => {
|
|
31
|
+
// Arrange
|
|
32
|
+
const user = userEvent.setup();
|
|
33
|
+
render(
|
|
34
|
+
<Popover.Root defaultOpen>
|
|
35
|
+
<Popover.Trigger>Open</Popover.Trigger>
|
|
36
|
+
<Popover.Content>
|
|
37
|
+
<button type="button">Inside</button>
|
|
38
|
+
</Popover.Content>
|
|
39
|
+
</Popover.Root>,
|
|
40
|
+
);
|
|
41
|
+
const trigger = screen.getByRole("button", { name: "Open" });
|
|
42
|
+
|
|
43
|
+
// Act
|
|
44
|
+
await user.click(screen.getByRole("button", { name: "Inside", hidden: true }));
|
|
45
|
+
await user.keyboard("{Escape}");
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
48
|
+
expect(trigger).toHaveFocus();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("still fires a consumer onKeyDown on the content", async () => {
|
|
52
|
+
// Arrange
|
|
53
|
+
const user = userEvent.setup();
|
|
54
|
+
const onKeyDown = vi.fn();
|
|
55
|
+
render(
|
|
56
|
+
<Popover.Root defaultOpen>
|
|
57
|
+
<Popover.Trigger>Open</Popover.Trigger>
|
|
58
|
+
<Popover.Content onKeyDown={onKeyDown}>
|
|
59
|
+
<button type="button">Inside</button>
|
|
60
|
+
</Popover.Content>
|
|
61
|
+
</Popover.Root>,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Act
|
|
65
|
+
await user.click(screen.getByRole("button", { name: "Inside", hidden: true }));
|
|
66
|
+
await user.keyboard("a");
|
|
67
|
+
|
|
68
|
+
// Assert
|
|
69
|
+
expect(onKeyDown).toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
|
|
4
|
+
import { Popover } from "../Popover";
|
|
5
|
+
|
|
6
|
+
describe("Popover mouse interaction", () => {
|
|
7
|
+
it("opens the popover when the trigger is clicked", async () => {
|
|
8
|
+
// Arrange
|
|
9
|
+
const user = userEvent.setup();
|
|
10
|
+
render(
|
|
11
|
+
<Popover.Root>
|
|
12
|
+
<Popover.Trigger>Open</Popover.Trigger>
|
|
13
|
+
<Popover.Content data-testid="content">Content</Popover.Content>
|
|
14
|
+
</Popover.Root>,
|
|
15
|
+
);
|
|
16
|
+
const trigger = screen.getByRole("button", { name: "Open" });
|
|
17
|
+
const content = screen.getByTestId("content");
|
|
18
|
+
|
|
19
|
+
// Act
|
|
20
|
+
await user.click(trigger);
|
|
21
|
+
|
|
22
|
+
// Assert
|
|
23
|
+
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
|
24
|
+
expect(content).toHaveAttribute("data-state", "open");
|
|
25
|
+
expect(content).toHaveAttribute("data-popover-open");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("closes the popover when the trigger is clicked again", async () => {
|
|
29
|
+
// Arrange
|
|
30
|
+
const user = userEvent.setup();
|
|
31
|
+
render(
|
|
32
|
+
<Popover.Root>
|
|
33
|
+
<Popover.Trigger>Open</Popover.Trigger>
|
|
34
|
+
<Popover.Content data-testid="content">Content</Popover.Content>
|
|
35
|
+
</Popover.Root>,
|
|
36
|
+
);
|
|
37
|
+
const trigger = screen.getByRole("button", { name: "Open" });
|
|
38
|
+
const content = screen.getByTestId("content");
|
|
39
|
+
|
|
40
|
+
// Act
|
|
41
|
+
await user.click(trigger);
|
|
42
|
+
await user.click(trigger);
|
|
43
|
+
|
|
44
|
+
// Assert
|
|
45
|
+
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
|
46
|
+
expect(content).toHaveAttribute("data-state", "closed");
|
|
47
|
+
expect(content).not.toHaveAttribute("data-popover-open");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("composes a consumer onClick on the trigger with the toggle behaviour", async () => {
|
|
51
|
+
// Arrange
|
|
52
|
+
const user = userEvent.setup();
|
|
53
|
+
const onClick = vi.fn();
|
|
54
|
+
render(
|
|
55
|
+
<Popover.Root>
|
|
56
|
+
<Popover.Trigger onClick={onClick}>Open</Popover.Trigger>
|
|
57
|
+
<Popover.Content>Content</Popover.Content>
|
|
58
|
+
</Popover.Root>,
|
|
59
|
+
);
|
|
60
|
+
const trigger = screen.getByRole("button", { name: "Open" });
|
|
61
|
+
|
|
62
|
+
// Act
|
|
63
|
+
await user.click(trigger);
|
|
64
|
+
|
|
65
|
+
// Assert
|
|
66
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
67
|
+
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
|
|
4
|
+
import { Popover } from "../Popover";
|
|
5
|
+
|
|
6
|
+
describe("Popover uncontrolled state", () => {
|
|
7
|
+
it("starts open when defaultOpen is set", () => {
|
|
8
|
+
// Arrange & Act
|
|
9
|
+
render(
|
|
10
|
+
<Popover.Root defaultOpen>
|
|
11
|
+
<Popover.Trigger>Open</Popover.Trigger>
|
|
12
|
+
<Popover.Content data-testid="content">Content</Popover.Content>
|
|
13
|
+
</Popover.Root>,
|
|
14
|
+
);
|
|
15
|
+
const trigger = screen.getByRole("button", { name: "Open" });
|
|
16
|
+
const content = screen.getByTestId("content");
|
|
17
|
+
|
|
18
|
+
// Assert
|
|
19
|
+
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
|
20
|
+
expect(content).toHaveAttribute("data-state", "open");
|
|
21
|
+
expect(content).toHaveAttribute("data-popover-open");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("notifies onOpenChange as the internal state changes", async () => {
|
|
25
|
+
// Arrange
|
|
26
|
+
const user = userEvent.setup();
|
|
27
|
+
const onOpenChange = vi.fn();
|
|
28
|
+
render(
|
|
29
|
+
<Popover.Root onOpenChange={onOpenChange}>
|
|
30
|
+
<Popover.Trigger>Open</Popover.Trigger>
|
|
31
|
+
<Popover.Content>Content</Popover.Content>
|
|
32
|
+
</Popover.Root>,
|
|
33
|
+
);
|
|
34
|
+
const trigger = screen.getByRole("button", { name: "Open" });
|
|
35
|
+
|
|
36
|
+
// Act
|
|
37
|
+
await user.click(trigger);
|
|
38
|
+
await user.click(trigger);
|
|
39
|
+
|
|
40
|
+
// Assert
|
|
41
|
+
expect(onOpenChange).toHaveBeenNthCalledWith(1, true);
|
|
42
|
+
expect(onOpenChange).toHaveBeenNthCalledWith(2, false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {
|
|
2
|
+
KeyboardEvent,
|
|
3
|
+
KeyboardEventHandler,
|
|
4
|
+
Ref,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
} from "react";
|
|
8
|
+
|
|
9
|
+
import { composeEventHandlers, composeRefs } from "../../Slot/index.ts";
|
|
10
|
+
import { FOCUSABLE_SELECTOR } from "../../utils/index.ts";
|
|
11
|
+
import { usePopoverContext } from "../PopoverContext";
|
|
12
|
+
|
|
13
|
+
type UsePopoverContentArgs = {
|
|
14
|
+
onKeyDown?: KeyboardEventHandler<HTMLDivElement>;
|
|
15
|
+
ref?: Ref<HTMLDivElement>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function usePopoverContent({
|
|
19
|
+
onKeyDown,
|
|
20
|
+
ref,
|
|
21
|
+
}: UsePopoverContentArgs = {}) {
|
|
22
|
+
const { open, setOpen, contentId, triggerRef, titleId, descriptionId } =
|
|
23
|
+
usePopoverContext();
|
|
24
|
+
const contentRef = useRef<HTMLDivElement | null>(null);
|
|
25
|
+
const composedRef = ref ? composeRefs(contentRef, ref) : contentRef;
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const content = contentRef.current!;
|
|
29
|
+
if (open) {
|
|
30
|
+
content.showPopover();
|
|
31
|
+
// Move focus into the popover (WAI-ARIA non-modal dialog): the first
|
|
32
|
+
// focusable descendant, or the content itself (it carries tabIndex=-1).
|
|
33
|
+
const focusable = content.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
|
|
34
|
+
(focusable ?? content).focus();
|
|
35
|
+
} else {
|
|
36
|
+
try {
|
|
37
|
+
content.hidePopover();
|
|
38
|
+
} catch {
|
|
39
|
+
// already hidden — no-op (e.g. browser already light-dismissed it)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}, [open]);
|
|
43
|
+
|
|
44
|
+
// Sync React state when the browser closes the popover without our code
|
|
45
|
+
// doing it (real-browser ToggleEvent on light-dismiss or native Escape).
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const content = contentRef.current!;
|
|
48
|
+
const handleToggle = (event: Event) => {
|
|
49
|
+
if ((event as ToggleEvent).newState === "closed") setOpen(false);
|
|
50
|
+
};
|
|
51
|
+
content.addEventListener("toggle", handleToggle);
|
|
52
|
+
return () => content.removeEventListener("toggle", handleToggle);
|
|
53
|
+
}, [setOpen]);
|
|
54
|
+
|
|
55
|
+
// Close on clicks outside the popover. This is only a fallback for
|
|
56
|
+
// environments WITHOUT the native Popover API (e.g. jsdom): a real browser
|
|
57
|
+
// already light-dismisses an `auto` popover on an outside click and fires the
|
|
58
|
+
// `toggle` event we sync above. Running this listener in a real browser is
|
|
59
|
+
// not just redundant — React 19 can flush this effect synchronously inside
|
|
60
|
+
// the very click that opened the popover from an external control, so the
|
|
61
|
+
// listener would catch that click and immediately re-close it. Gate it to
|
|
62
|
+
// non-native environments; `"popover" in HTMLElement.prototype` is true only
|
|
63
|
+
// where the native API (and its own light-dismiss) exists.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!open) return;
|
|
66
|
+
if ("popover" in HTMLElement.prototype) return;
|
|
67
|
+
const handleClick = (event: MouseEvent) => {
|
|
68
|
+
const target = event.target as Element;
|
|
69
|
+
if (triggerRef.current?.contains(target)) return;
|
|
70
|
+
if (target.closest?.("[popover]")) return;
|
|
71
|
+
setOpen(false);
|
|
72
|
+
};
|
|
73
|
+
document.addEventListener("click", handleClick);
|
|
74
|
+
return () => document.removeEventListener("click", handleClick);
|
|
75
|
+
}, [open, setOpen, triggerRef]);
|
|
76
|
+
|
|
77
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
78
|
+
if (event.key === "Escape") {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
setOpen(false);
|
|
81
|
+
triggerRef.current?.focus();
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const contentProps = {
|
|
86
|
+
ref: composedRef,
|
|
87
|
+
id: contentId,
|
|
88
|
+
role: "dialog" as const,
|
|
89
|
+
popover: "auto" as const,
|
|
90
|
+
tabIndex: -1,
|
|
91
|
+
"aria-labelledby": titleId,
|
|
92
|
+
"aria-describedby": descriptionId,
|
|
93
|
+
"data-state": (open ? "open" : "closed") as "open" | "closed",
|
|
94
|
+
onKeyDown: composeEventHandlers(onKeyDown, handleKeyDown),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return { contentProps };
|
|
98
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useId,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from "react";
|
|
9
|
+
|
|
10
|
+
import { useControllableState } from "../../hooks/index.ts";
|
|
11
|
+
import { PopoverContextValue } from "../types";
|
|
12
|
+
|
|
13
|
+
type UsePopoverRootArgs = {
|
|
14
|
+
defaultOpen?: boolean;
|
|
15
|
+
open?: boolean;
|
|
16
|
+
onOpenChange?: (open: boolean) => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function usePopoverRoot({
|
|
20
|
+
defaultOpen = false,
|
|
21
|
+
open: controlledOpen,
|
|
22
|
+
onOpenChange,
|
|
23
|
+
}: UsePopoverRootArgs) {
|
|
24
|
+
const contentId = useId();
|
|
25
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
26
|
+
const [open, setOpenBase] = useControllableState<boolean>(
|
|
27
|
+
controlledOpen,
|
|
28
|
+
defaultOpen,
|
|
29
|
+
onOpenChange,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// Mirror `open` so setOpen can short-circuit repeat transitions within a
|
|
33
|
+
// single event without a re-render in between. Without this, internal
|
|
34
|
+
// paths that converge on the same close (e.g. a light-dismiss firing both
|
|
35
|
+
// a `toggle` event and a document click) would double-notify consumers.
|
|
36
|
+
const openRef = useRef(open);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
openRef.current = open;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const setOpen = useCallback(
|
|
42
|
+
(next: boolean) => {
|
|
43
|
+
if (openRef.current === next) return;
|
|
44
|
+
openRef.current = next;
|
|
45
|
+
setOpenBase(next);
|
|
46
|
+
},
|
|
47
|
+
[setOpenBase],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const [titleId, setTitleId] = useState<string | undefined>(undefined);
|
|
51
|
+
const [descriptionId, setDescriptionId] = useState<string | undefined>(
|
|
52
|
+
undefined,
|
|
53
|
+
);
|
|
54
|
+
const registerTitle = useCallback((id: string | undefined) => {
|
|
55
|
+
setTitleId(id);
|
|
56
|
+
}, []);
|
|
57
|
+
const registerDescription = useCallback((id: string | undefined) => {
|
|
58
|
+
setDescriptionId(id);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
const contextValue = useMemo<PopoverContextValue>(
|
|
62
|
+
() => ({
|
|
63
|
+
open,
|
|
64
|
+
setOpen,
|
|
65
|
+
triggerRef,
|
|
66
|
+
contentId,
|
|
67
|
+
titleId,
|
|
68
|
+
descriptionId,
|
|
69
|
+
registerTitle,
|
|
70
|
+
registerDescription,
|
|
71
|
+
}),
|
|
72
|
+
[
|
|
73
|
+
open,
|
|
74
|
+
setOpen,
|
|
75
|
+
contentId,
|
|
76
|
+
titleId,
|
|
77
|
+
descriptionId,
|
|
78
|
+
registerTitle,
|
|
79
|
+
registerDescription,
|
|
80
|
+
],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return { contextValue };
|
|
84
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { MouseEventHandler, Ref } from "react";
|
|
2
|
+
|
|
3
|
+
import { composeEventHandlers, composeRefs } from "../../Slot/index.ts";
|
|
4
|
+
import { PopoverTriggerProps } from "../types";
|
|
5
|
+
import { usePopoverContext } from "../PopoverContext";
|
|
6
|
+
|
|
7
|
+
export function usePopoverTrigger({
|
|
8
|
+
ref,
|
|
9
|
+
onClick,
|
|
10
|
+
asChild = false,
|
|
11
|
+
...rest
|
|
12
|
+
}: Omit<PopoverTriggerProps, "children">) {
|
|
13
|
+
const { open, setOpen, triggerRef, contentId } = usePopoverContext();
|
|
14
|
+
const composedRef = ref
|
|
15
|
+
? composeRefs(triggerRef, ref as Ref<HTMLButtonElement>)
|
|
16
|
+
: triggerRef;
|
|
17
|
+
|
|
18
|
+
const toggle = () => setOpen(!open);
|
|
19
|
+
|
|
20
|
+
const triggerProps = {
|
|
21
|
+
ref: composedRef,
|
|
22
|
+
"aria-haspopup": "dialog" as const,
|
|
23
|
+
"aria-expanded": open,
|
|
24
|
+
"aria-controls": contentId,
|
|
25
|
+
onClick: composeEventHandlers(
|
|
26
|
+
onClick as MouseEventHandler | undefined,
|
|
27
|
+
toggle,
|
|
28
|
+
),
|
|
29
|
+
...rest,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return { triggerProps };
|
|
33
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { ComponentProps, ReactNode, Ref, RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
/** Props common to both controlled and uncontrolled `Popover.Root` modes. */
|
|
4
|
+
export type PopoverRootBaseProps = {
|
|
5
|
+
/** The Popover sub-components — typically a {@link PopoverTriggerProps | `Popover.Trigger`}
|
|
6
|
+
* and a {@link PopoverContentProps | `Popover.Content`}, optionally a
|
|
7
|
+
* {@link PopoverAnchorProps | `Popover.Anchor`}. */
|
|
8
|
+
children?: ReactNode;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Props for {@link PopoverRoot | `Popover.Root`} in uncontrolled mode — the
|
|
13
|
+
* component owns the open state. Pass `defaultOpen` to set the initial value;
|
|
14
|
+
* `open` is forbidden.
|
|
15
|
+
*/
|
|
16
|
+
export type PopoverRootUncontrolledProps = PopoverRootBaseProps & {
|
|
17
|
+
/**
|
|
18
|
+
* Whether the popover is open on first render. The component owns the flag
|
|
19
|
+
* from then on (trigger toggles, `Popover.Close`, Escape, and light-dismiss
|
|
20
|
+
* all mutate it internally).
|
|
21
|
+
* @default false
|
|
22
|
+
*/
|
|
23
|
+
defaultOpen?: boolean;
|
|
24
|
+
/** Forbidden in uncontrolled mode — use `defaultOpen` instead. */
|
|
25
|
+
open?: never;
|
|
26
|
+
/** Called with the new open state after every transition. Optional in
|
|
27
|
+
* uncontrolled mode; use it to observe (not drive) open/close. */
|
|
28
|
+
onOpenChange?: (open: boolean) => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Props for {@link PopoverRoot | `Popover.Root`} in controlled mode — the
|
|
33
|
+
* parent owns the open value. Pass `open` and `onOpenChange` together;
|
|
34
|
+
* `defaultOpen` is forbidden.
|
|
35
|
+
*/
|
|
36
|
+
export type PopoverRootControlledProps = PopoverRootBaseProps & {
|
|
37
|
+
/** The current open state. Must be kept in sync by the parent via
|
|
38
|
+
* `onOpenChange`. */
|
|
39
|
+
open: boolean;
|
|
40
|
+
/** Called with the requested open state whenever the popover wants to open
|
|
41
|
+
* or close (trigger click, `Popover.Close`, Escape, outside dismiss).
|
|
42
|
+
* Required in controlled mode — the popover will not move unless the parent
|
|
43
|
+
* updates `open`. */
|
|
44
|
+
onOpenChange: (open: boolean) => void;
|
|
45
|
+
/** Forbidden in controlled mode — use `open` instead. */
|
|
46
|
+
defaultOpen?: never;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Props for {@link PopoverRoot | `Popover.Root`} — a discriminated union that
|
|
51
|
+
* resolves to either {@link PopoverRootUncontrolledProps} or
|
|
52
|
+
* {@link PopoverRootControlledProps}, so TypeScript accepts exactly one mode.
|
|
53
|
+
*/
|
|
54
|
+
export type PopoverRootProps =
|
|
55
|
+
| PopoverRootUncontrolledProps
|
|
56
|
+
| PopoverRootControlledProps;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Props for {@link PopoverTrigger | `Popover.Trigger`}, the button that toggles
|
|
60
|
+
* the popover. Extends the native `<button>` attributes (with `ref` re-typed to
|
|
61
|
+
* the generic element `T`); the trigger's `aria-haspopup` / `aria-expanded` /
|
|
62
|
+
* `aria-controls` and toggle `onClick` are added by the component (a consumer
|
|
63
|
+
* `onClick` composes and can `preventDefault()` to veto the toggle).
|
|
64
|
+
*/
|
|
65
|
+
export type PopoverTriggerProps<T extends HTMLElement = HTMLButtonElement> =
|
|
66
|
+
Omit<ComponentProps<"button">, "ref"> & {
|
|
67
|
+
/** Trigger content (label, icon, etc.). */
|
|
68
|
+
children?: ReactNode;
|
|
69
|
+
/**
|
|
70
|
+
* Render the single child element instead of the default `<button>`,
|
|
71
|
+
* merging the trigger's ARIA, composed event handlers, and ref onto it via
|
|
72
|
+
* the {@link Slot} pattern.
|
|
73
|
+
* @default false
|
|
74
|
+
*/
|
|
75
|
+
asChild?: boolean;
|
|
76
|
+
/** Ref to the rendered element, composed with Root's internal trigger ref
|
|
77
|
+
* (used for focus return on close). Defaults to `HTMLButtonElement`; with
|
|
78
|
+
* `asChild`, set the generic to the child's element type
|
|
79
|
+
* (e.g. `HTMLAnchorElement`). */
|
|
80
|
+
ref?: Ref<T>;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Props for {@link PopoverContent | `Popover.Content`}, the floating panel.
|
|
85
|
+
* Extends the native `<div>` attributes; the component adds `role="dialog"`,
|
|
86
|
+
* `popover="auto"`, `tabIndex={-1}`, the `aria-labelledby` / `aria-describedby`
|
|
87
|
+
* wiring (from {@link PopoverTitleProps | `Popover.Title`} /
|
|
88
|
+
* {@link PopoverDescriptionProps | `Popover.Description`}), and the
|
|
89
|
+
* `data-state` hook. Open/close, focus movement into the panel, Escape, and
|
|
90
|
+
* light-dismiss are all handled internally — no props needed.
|
|
91
|
+
*/
|
|
92
|
+
export type PopoverContentProps = ComponentProps<"div"> & {
|
|
93
|
+
/** Panel content — typically {@link PopoverTitleProps | `Popover.Title`},
|
|
94
|
+
* body markup, and a {@link PopoverCloseProps | `Popover.Close`}. */
|
|
95
|
+
children?: ReactNode;
|
|
96
|
+
/**
|
|
97
|
+
* Render the single child element (e.g. a `<section>` or motion wrapper)
|
|
98
|
+
* instead of the default `<div>`, merging the dialog props and internal ref
|
|
99
|
+
* onto it via the {@link Slot} pattern.
|
|
100
|
+
* @default false
|
|
101
|
+
*/
|
|
102
|
+
asChild?: boolean;
|
|
103
|
+
/** Ref to the rendered element, composed with Root's internal content ref so
|
|
104
|
+
* open/close and focus management keep working. */
|
|
105
|
+
ref?: Ref<HTMLDivElement>;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Props for {@link PopoverAnchor | `Popover.Anchor`}, an optional element the
|
|
110
|
+
* popover is positioned against instead of the trigger. Attach a CSS
|
|
111
|
+
* `anchor-name` to it and a matching `position-anchor` to
|
|
112
|
+
* {@link PopoverContentProps | `Popover.Content`}. Renders no behaviour of its
|
|
113
|
+
* own — positioning is purely a CSS concern.
|
|
114
|
+
*/
|
|
115
|
+
export type PopoverAnchorProps = ComponentProps<"div"> & {
|
|
116
|
+
/** Anchor content — the element(s) the popover should be positioned against. */
|
|
117
|
+
children?: ReactNode;
|
|
118
|
+
/**
|
|
119
|
+
* Render the single child element instead of the default `<div>`, projecting
|
|
120
|
+
* the anchor onto an existing element via the {@link Slot} pattern.
|
|
121
|
+
* @default false
|
|
122
|
+
*/
|
|
123
|
+
asChild?: boolean;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Props for {@link PopoverClose | `Popover.Close`}, the button that closes the
|
|
128
|
+
* popover and returns focus to the trigger. Extends the native `<button>`
|
|
129
|
+
* attributes; a consumer `onClick` composes with the close (runs first, can
|
|
130
|
+
* `preventDefault()` to veto closing).
|
|
131
|
+
*/
|
|
132
|
+
export type PopoverCloseProps = ComponentProps<"button"> & {
|
|
133
|
+
/** Close-button content (label, icon, etc.). */
|
|
134
|
+
children?: ReactNode;
|
|
135
|
+
/**
|
|
136
|
+
* Render the single child element (e.g. a text link or icon button) instead
|
|
137
|
+
* of the default `<button>`, merging the close behaviour onto it via the
|
|
138
|
+
* {@link Slot} pattern.
|
|
139
|
+
* @default false
|
|
140
|
+
*/
|
|
141
|
+
asChild?: boolean;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Props for {@link PopoverTitle | `Popover.Title`}, the popover's accessible
|
|
146
|
+
* name. Extends the native `<h2>` attributes; the component supplies a
|
|
147
|
+
* generated `id` and registers it so `Popover.Content` exposes it via
|
|
148
|
+
* `aria-labelledby`.
|
|
149
|
+
*/
|
|
150
|
+
export type PopoverTitleProps = ComponentProps<"h2"> & {
|
|
151
|
+
/** Title content, rendered as the popover's accessible name. */
|
|
152
|
+
children?: ReactNode;
|
|
153
|
+
/**
|
|
154
|
+
* Render the single child element (e.g. an `<h3>`) instead of the default
|
|
155
|
+
* `<h2>` via the {@link Slot} pattern; the generated `id` is still applied
|
|
156
|
+
* and registered.
|
|
157
|
+
* @default false
|
|
158
|
+
*/
|
|
159
|
+
asChild?: boolean;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Props for {@link PopoverDescription | `Popover.Description`}, the popover's
|
|
164
|
+
* accessible description. Extends the native `<p>` attributes; the component
|
|
165
|
+
* supplies a generated `id` and registers it so `Popover.Content` exposes it
|
|
166
|
+
* via `aria-describedby`.
|
|
167
|
+
*/
|
|
168
|
+
export type PopoverDescriptionProps = ComponentProps<"p"> & {
|
|
169
|
+
/** Description content, exposed as the popover's accessible description. */
|
|
170
|
+
children?: ReactNode;
|
|
171
|
+
/**
|
|
172
|
+
* Render the single child element instead of the default `<p>` via the
|
|
173
|
+
* {@link Slot} pattern; the generated `id` is still applied and registered.
|
|
174
|
+
* @default false
|
|
175
|
+
*/
|
|
176
|
+
asChild?: boolean;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** Value shared through Popover context to all sub-components. */
|
|
180
|
+
export type PopoverContextValue = {
|
|
181
|
+
/** Whether the popover is currently open. */
|
|
182
|
+
open: boolean;
|
|
183
|
+
/** Request an open-state change. */
|
|
184
|
+
setOpen: (open: boolean) => void;
|
|
185
|
+
/** Ref to the trigger element, used for focus return. */
|
|
186
|
+
triggerRef: RefObject<HTMLButtonElement | null>;
|
|
187
|
+
/** Generated id of the content panel. */
|
|
188
|
+
contentId: string;
|
|
189
|
+
/** Id of the rendered `Popover.Title`, or `undefined` when absent. */
|
|
190
|
+
titleId: string | undefined;
|
|
191
|
+
/** Id of the rendered `Popover.Description`, or `undefined` when absent. */
|
|
192
|
+
descriptionId: string | undefined;
|
|
193
|
+
/** Register (or clear) the title id for `aria-labelledby`. */
|
|
194
|
+
registerTitle: (id: string | undefined) => void;
|
|
195
|
+
/** Register (or clear) the description id for `aria-describedby`. */
|
|
196
|
+
registerDescription: (id: string | undefined) => void;
|
|
197
|
+
};
|
package/src/Portal/Portal.tsx
CHANGED
|
@@ -4,21 +4,61 @@ import { createPortal } from "react-dom";
|
|
|
4
4
|
import type { PortalProps } from "./types";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Teleports its children into a DOM node outside the current React subtree
|
|
8
|
+
* using React's `createPortal`. Renders no host element of its own — the
|
|
9
|
+
* children appear directly inside the target `container`.
|
|
10
|
+
*
|
|
11
|
+
* Use `Portal` whenever a subtree must escape its ancestor's stacking context,
|
|
12
|
+
* overflow clipping, or z-index hierarchy — tooltips, modals, dropdowns, and
|
|
13
|
+
* similar overlays are the typical cases. `Portal` carries no state, context,
|
|
14
|
+
* or open/close awareness; those concerns belong in the composing component
|
|
15
|
+
* (see `Modal.Portal`, which layers open-state suppression and `forceMount` on
|
|
16
|
+
* top of this primitive).
|
|
17
|
+
*
|
|
18
|
+
* **Conditional rendering.** `Portal` always mounts when rendered. To control
|
|
19
|
+
* visibility, conditionally render the `Portal` itself, or manage show/hide
|
|
20
|
+
* logic in the composing component:
|
|
21
|
+
*
|
|
22
|
+
* ```tsx
|
|
23
|
+
* {isOpen && (
|
|
24
|
+
* <Portal>
|
|
25
|
+
* <div role="dialog">…</div>
|
|
26
|
+
* </Portal>
|
|
27
|
+
* )}
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* For CSS-driven exit animations, keep the portal mounted and drive styles
|
|
31
|
+
* through a `data-state` attribute on its child:
|
|
32
|
+
*
|
|
33
|
+
* ```tsx
|
|
34
|
+
* <Portal>
|
|
35
|
+
* <div data-state={isOpen ? "open" : "closed"}>…</div>
|
|
36
|
+
* </Portal>
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* **Ref forwarding.** `Portal` does not forward a ref (there is no host
|
|
40
|
+
* element to attach one to). Attach `ref` directly to the child element
|
|
41
|
+
* rendered inside the portal.
|
|
42
|
+
*
|
|
43
|
+
* @example Mount into document.body (default)
|
|
44
|
+
* ```tsx
|
|
45
|
+
* import { Portal } from "@primitiv-ui/react";
|
|
10
46
|
*
|
|
11
|
-
* @example
|
|
12
|
-
* // Mount into document.body (default)
|
|
13
47
|
* <Portal>
|
|
14
|
-
* <div role="dialog"
|
|
48
|
+
* <div role="dialog" aria-modal="true">
|
|
49
|
+
* Modal content rendered directly in document.body
|
|
50
|
+
* </div>
|
|
15
51
|
* </Portal>
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* @example Mount into a custom container
|
|
55
|
+
* ```tsx
|
|
56
|
+
* const overlayRoot = document.getElementById("overlay-root");
|
|
16
57
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* <Portal container={document.getElementById("overlays")}>
|
|
20
|
-
* <div role="dialog">…</div>
|
|
58
|
+
* <Portal container={overlayRoot}>
|
|
59
|
+
* <div role="tooltip">Tooltip content</div>
|
|
21
60
|
* </Portal>
|
|
61
|
+
* ```
|
|
22
62
|
*/
|
|
23
63
|
function Portal({ children, container }: PortalProps): ReactPortal {
|
|
24
64
|
return createPortal(children, container ?? document.body);
|