@primitiv-ui/react 0.1.29 → 0.1.30
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 +15 -2
- package/package.json +1 -1
- package/src/AccessibleIcon/AccessibleIcon.tsx +1 -1
- package/src/Accordion/Accordion.tsx +7 -7
- package/src/Accordion/README.md +2 -2
- package/src/Avatar/__tests__/Avatar.fallback.test.tsx +3 -3
- package/src/Breadcrumb/Breadcrumb.tsx +7 -7
- package/src/Breadcrumb/README.md +2 -2
- package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +1 -1
- package/src/Breadcrumb/types.ts +1 -1
- package/src/Button/__tests__/Button.contract.test.tsx +1 -1
- package/src/Carousel/Carousel.tsx +6 -6
- package/src/Carousel/README.md +22 -22
- package/src/Carousel/__tests__/Carousel.infinite.test.tsx +5 -5
- package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +2 -2
- package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +1 -1
- package/src/Carousel/loopEngine.ts +1 -1
- package/src/Carousel/types.ts +1 -1
- package/src/Checkbox/Checkbox.tsx +2 -2
- package/src/CheckboxCard/CheckboxCard.tsx +1 -1
- package/src/CheckboxCard/README.md +1 -1
- package/src/Collapsible/README.md +2 -2
- package/src/Combobox/Combobox.tsx +508 -0
- package/src/Combobox/ComboboxContext.ts +23 -0
- package/src/Combobox/README.md +170 -0
- package/src/Combobox/__tests__/Combobox.asChild.test.tsx +62 -0
- package/src/Combobox/__tests__/Combobox.basic-rendering.test.tsx +16 -0
- package/src/Combobox/__tests__/Combobox.content.test.tsx +36 -0
- package/src/Combobox/__tests__/Combobox.controlled-state.test.tsx +88 -0
- package/src/Combobox/__tests__/Combobox.empty.test.tsx +42 -0
- package/src/Combobox/__tests__/Combobox.ids.test.tsx +66 -0
- package/src/Combobox/__tests__/Combobox.item.test.tsx +76 -0
- package/src/Combobox/__tests__/Combobox.keyboard-interaction.test.tsx +196 -0
- package/src/Combobox/__tests__/Combobox.light-dismiss.test.tsx +101 -0
- package/src/Combobox/__tests__/Combobox.query.test.tsx +105 -0
- package/src/Combobox/hooks/index.ts +1 -0
- package/src/Combobox/hooks/useComboboxRoot.ts +2 -0
- package/src/Combobox/index.ts +3 -0
- package/src/Combobox/types.ts +133 -0
- package/src/ContextMenu/ContextMenu.tsx +3 -3
- package/src/ContextMenu/README.md +1 -1
- package/src/DirectionProvider/DirectionProvider.tsx +5 -5
- package/src/DirectionProvider/README.md +4 -4
- package/src/Divider/__tests__/Divider.contract.test.tsx +1 -1
- package/src/Drawer/Drawer.tsx +5 -5
- package/src/Drawer/README.md +2 -2
- package/src/Dropdown/Dropdown.tsx +2 -2
- package/src/Dropdown/README.md +7 -5
- package/src/EmptyState/README.md +2 -2
- package/src/Field/README.md +1 -1
- package/src/Field/hooks/useFieldProps.ts +1 -1
- package/src/Fieldset/Fieldset.tsx +3 -3
- package/src/Fieldset/README.md +2 -2
- package/src/Input/Input.tsx +1 -1
- package/src/Input/README.md +2 -2
- package/src/Listbox/Listbox.tsx +475 -0
- package/src/Listbox/ListboxContext.ts +48 -0
- package/src/Listbox/README.md +248 -0
- package/src/Listbox/__tests__/Listbox.active-descendant.test.tsx +128 -0
- package/src/Listbox/__tests__/Listbox.asChild.test.tsx +91 -0
- package/src/Listbox/__tests__/Listbox.basic-rendering.test.tsx +16 -0
- package/src/Listbox/__tests__/Listbox.controlled-state.test.tsx +114 -0
- package/src/Listbox/__tests__/Listbox.disabled.test.tsx +90 -0
- package/src/Listbox/__tests__/Listbox.fixtures.ts +27 -0
- package/src/Listbox/__tests__/Listbox.group-label.test.tsx +97 -0
- package/src/Listbox/__tests__/Listbox.group.test.tsx +45 -0
- package/src/Listbox/__tests__/Listbox.keyboard-edge-cases.test.tsx +144 -0
- package/src/Listbox/__tests__/Listbox.keyboard-interaction.test.tsx +112 -0
- package/src/Listbox/__tests__/Listbox.modifier-keys.test.tsx +86 -0
- package/src/Listbox/__tests__/Listbox.multi-select-keyboard.test.tsx +368 -0
- package/src/Listbox/__tests__/Listbox.multiple-mode.test.tsx +91 -0
- package/src/Listbox/__tests__/Listbox.mutation-hardening.test.tsx +480 -0
- package/src/Listbox/__tests__/Listbox.orientation.test.tsx +59 -0
- package/src/Listbox/__tests__/Listbox.reading-direction.test.tsx +65 -0
- package/src/Listbox/__tests__/Listbox.reorder.test.tsx +78 -0
- package/src/Listbox/__tests__/Listbox.selection-follows-focus.test.tsx +64 -0
- package/src/Listbox/__tests__/Listbox.strict-context.test.tsx +21 -0
- package/src/Listbox/__tests__/Listbox.typeahead.test.tsx +123 -0
- package/src/Listbox/__tests__/Listbox.uncontrolled-state.test.tsx +48 -0
- package/src/Listbox/constants.ts +2 -0
- package/src/Listbox/hooks/index.ts +1 -0
- package/src/Listbox/hooks/useListboxRoot.ts +407 -0
- package/src/Listbox/index.ts +3 -0
- package/src/Listbox/types.ts +238 -0
- package/src/MillerColumns/MillerColumns.tsx +104 -30
- package/src/MillerColumns/README.md +90 -20
- package/src/MillerColumns/__tests__/MillerColumns.aria.test.tsx +45 -0
- package/src/MillerColumns/__tests__/MillerColumns.auto-scroll.test.tsx +4 -2
- package/src/MillerColumns/__tests__/MillerColumns.basic-rendering.test.tsx +18 -3
- package/src/MillerColumns/__tests__/MillerColumns.data-attributes.test.tsx +48 -0
- package/src/MillerColumns/__tests__/MillerColumns.keyboard-interaction.test.tsx +35 -2
- package/src/MillerColumns/__tests__/MillerColumns.keyboard-selection-state.test.tsx +96 -0
- package/src/MillerColumns/__tests__/MillerColumns.preview-panel.test.tsx +29 -4
- package/src/MillerColumns/__tests__/MillerColumns.preview-visibility.test.tsx +79 -0
- package/src/MillerColumns/__tests__/MillerColumns.resize.test.tsx +189 -0
- package/src/MillerColumns/__tests__/MillerColumns.styled-parts.test.tsx +143 -0
- package/src/MillerColumns/__tests__/MillerColumns.typeahead.test.tsx +104 -0
- package/src/MillerColumns/constants.ts +2 -0
- package/src/MillerColumns/hooks/useMillerColumnsColumn.ts +5 -2
- package/src/MillerColumns/hooks/useMillerColumnsItem.ts +29 -7
- package/src/MillerColumns/hooks/useMillerColumnsResizeHandle.ts +80 -10
- package/src/MillerColumns/hooks/useMillerColumnsRoot.ts +0 -0
- package/src/MillerColumns/index.ts +4 -0
- package/src/MillerColumns/types.ts +56 -6
- package/src/MillerColumns/utils.ts +69 -3
- package/src/Modal/Modal.tsx +4 -4
- package/src/Modal/README.md +5 -5
- package/src/Modal/types.ts +1 -1
- package/src/NavigationMenu/NavigationMenu.tsx +6 -6
- package/src/NavigationMenu/README.md +3 -3
- package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +1 -1
- package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +1 -1
- package/src/Popover/Popover.tsx +2 -2
- package/src/Popover/README.md +1 -1
- package/src/Portal/Portal.tsx +2 -2
- package/src/Portal/README.md +2 -2
- package/src/Progress/README.md +1 -1
- package/src/Radio/Radio.tsx +1 -1
- package/src/RadioGroup/README.md +2 -2
- package/src/RadioGroup/RadioGroup.tsx +1 -1
- package/src/SegmentedControl/README.md +2 -2
- package/src/SegmentedControl/SegmentedControl.tsx +1 -1
- package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +1 -0
- package/src/Select/README.md +2 -2
- package/src/Select/Select.tsx +8 -8
- package/src/Select/__tests__/Select.placeholder.test.tsx +3 -3
- package/src/Select/__tests__/Select.typeahead.test.tsx +3 -3
- package/src/Select/__tests__/Select.value-mirror.test.tsx +1 -1
- package/src/Select/types.ts +2 -2
- package/src/SkipNav/README.md +3 -3
- package/src/SkipNav/SkipNav.tsx +5 -5
- package/src/Slider/README.md +37 -7
- package/src/Slider/Slider.tsx +9 -3
- package/src/Slot/Slot.tsx +2 -2
- package/src/Slot/__tests__/Slot.test.tsx +2 -2
- package/src/Slot/types.ts +1 -1
- package/src/SplitButton/README.md +225 -0
- package/src/SplitButton/SplitButton.tsx +457 -0
- package/src/SplitButton/SplitButtonContext.ts +26 -0
- package/src/SplitButton/__tests__/SplitButton.accessibility.test.tsx +55 -0
- package/src/SplitButton/__tests__/SplitButton.asChild.test.tsx +51 -0
- package/src/SplitButton/__tests__/SplitButton.basic-rendering.test.tsx +57 -0
- package/src/SplitButton/__tests__/SplitButton.data-attributes.test.tsx +28 -0
- package/src/SplitButton/__tests__/SplitButton.disabled.test.tsx +79 -0
- package/src/SplitButton/__tests__/SplitButton.error-handling.test.tsx +29 -0
- package/src/SplitButton/__tests__/SplitButton.keyboard-interaction.test.tsx +56 -0
- package/src/SplitButton/__tests__/SplitButton.state-modes.test.tsx +68 -0
- package/src/SplitButton/index.ts +2 -0
- package/src/SplitButton/types.ts +186 -0
- package/src/Switch/README.md +1 -1
- package/src/Switch/Switch.tsx +2 -2
- package/src/Switch/types.ts +1 -1
- package/src/Table/README.md +110 -8
- package/src/Table/Table.tsx +206 -10
- package/src/Table/TableExpandableContext.ts +7 -0
- package/src/Table/__tests__/Table.DetailRow.test.tsx +83 -0
- package/src/Table/__tests__/Table.ExpandTrigger.test.tsx +102 -0
- package/src/Table/__tests__/Table.Expandable.test.tsx +121 -0
- package/src/Table/types.ts +125 -0
- package/src/Tabs/README.md +4 -4
- package/src/Tabs/Tabs.tsx +5 -4
- package/src/Tabs/__tests__/Tabs.activation-mode.test.tsx +58 -0
- package/src/Tabs/__tests__/Tabs.basic-rendering.test.tsx +15 -0
- package/src/Tabs/__tests__/Tabs.change-event-callbacks.test.tsx +24 -0
- package/src/Tabs/__tests__/Tabs.disabled-tabs.test.tsx +36 -0
- package/src/Tabs/__tests__/Tabs.lazy-mount.test.tsx +17 -0
- package/src/Tabs/hooks/useTabsContent.ts +5 -0
- package/src/Tabs/hooks/useTabsRoot.ts +29 -5
- package/src/Tabs/hooks/useTabsTrigger.ts +7 -2
- package/src/ToggleGroup/README.md +4 -4
- package/src/Tooltip/README.md +7 -7
- package/src/Tooltip/Tooltip.tsx +2 -2
- package/src/Tooltip/__tests__/Tooltip.basic-rendering.test.tsx +50 -0
- package/src/Tooltip/__tests__/Tooltip.error-handling.test.tsx +37 -0
- package/src/Tooltip/__tests__/Tooltip.escape-hatches.test.tsx +73 -0
- package/src/Tooltip/__tests__/Tooltip.keyboard-interaction.test.tsx +134 -0
- package/src/Tooltip/__tests__/Tooltip.skip-delay.test.tsx +216 -0
- package/src/Tooltip/__tests__/Tooltip.timer-coordination.test.tsx +223 -0
- package/src/Tooltip/hooks/useTooltipContent.ts +3 -0
- package/src/Tooltip/hooks/useTooltipProvider.ts +40 -14
- package/src/Tooltip/hooks/useTooltipRoot.ts +30 -6
- package/src/Tree/README.md +69 -5
- package/src/Tree/Tree.tsx +1 -1
- package/src/Tree/__tests__/Tree.styled-wrapper.test.tsx +159 -0
- package/src/Tree/hooks/useTreePath.ts +33 -1
- package/src/Tree/index.ts +6 -1
- package/src/Tree/utils.ts +46 -4
- package/src/VisuallyHidden/VisuallyHidden.tsx +1 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useTypeahead.ts +95 -0
- package/src/index.ts +6 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.reading.test.tsx +31 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.resilience.test.tsx +97 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.ssr.test.tsx +20 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.writing.test.tsx +72 -0
- package/src/useLocalStorage/index.ts +1 -0
- package/src/useLocalStorage/useLocalStorage.ts +140 -0
- package/src/useMediaQuery/README.md +66 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.matching.test.tsx +42 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.ssr.test.tsx +19 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.subscription.test.tsx +89 -0
- package/src/useMediaQuery/index.ts +1 -0
- package/src/useMediaQuery/useMediaQuery.ts +49 -0
- package/src/usePagination/README.md +170 -0
- package/src/usePagination/__tests__/usePagination.navigation.test.tsx +109 -0
- package/src/usePagination/__tests__/usePagination.page-count.test.tsx +56 -0
- package/src/usePagination/__tests__/usePagination.page-state.test.tsx +87 -0
- package/src/usePagination/__tests__/usePagination.range.test.tsx +82 -0
- package/src/usePagination/__tests__/usePagination.slice.test.tsx +59 -0
- package/src/usePagination/__tests__/usePagination.windowing.test.tsx +68 -0
- package/src/usePagination/index.ts +1 -0
- package/src/usePagination/usePagination.ts +292 -0
- package/src/utils/getKeyToActionMap.ts +1 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { render } from "@testing-library/react";
|
|
2
|
+
|
|
3
|
+
import { Tooltip } from "../Tooltip";
|
|
4
|
+
|
|
5
|
+
describe("Tooltip error handling", () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.restoreAllMocks();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("throws when Tooltip.Root is used outside Tooltip.Provider", () => {
|
|
15
|
+
expect(() =>
|
|
16
|
+
render(
|
|
17
|
+
<Tooltip.Root>
|
|
18
|
+
<Tooltip.Trigger>Orphan</Tooltip.Trigger>
|
|
19
|
+
</Tooltip.Root>,
|
|
20
|
+
),
|
|
21
|
+
).toThrowError(
|
|
22
|
+
"Tooltip sub-components must be rendered inside a <Tooltip.Provider>.",
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("throws when Tooltip.Trigger is used outside Tooltip.Root", () => {
|
|
27
|
+
expect(() =>
|
|
28
|
+
render(
|
|
29
|
+
<Tooltip.Provider>
|
|
30
|
+
<Tooltip.Trigger>Orphan</Tooltip.Trigger>
|
|
31
|
+
</Tooltip.Provider>,
|
|
32
|
+
),
|
|
33
|
+
).toThrowError(
|
|
34
|
+
"Tooltip sub-components must be rendered inside a <Tooltip.Root>.",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -70,4 +70,77 @@ describe("Tooltip — escape hatches", () => {
|
|
|
70
70
|
|
|
71
71
|
expect(screen.getByRole("tooltip")).toBeInTheDocument();
|
|
72
72
|
});
|
|
73
|
+
|
|
74
|
+
it("does not respond to an outside pointer-down while closed, even when Content is forceMounted", async () => {
|
|
75
|
+
const user = userEvent.setup();
|
|
76
|
+
const onPointerDownOutside = vi.fn();
|
|
77
|
+
|
|
78
|
+
render(
|
|
79
|
+
<div>
|
|
80
|
+
<Tooltip.Provider>
|
|
81
|
+
<Tooltip.Root>
|
|
82
|
+
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
83
|
+
<Tooltip.Content
|
|
84
|
+
forceMount
|
|
85
|
+
onPointerDownOutside={onPointerDownOutside}
|
|
86
|
+
>
|
|
87
|
+
Tooltip text
|
|
88
|
+
</Tooltip.Content>
|
|
89
|
+
</Tooltip.Root>
|
|
90
|
+
</Tooltip.Provider>
|
|
91
|
+
<button>Outside</button>
|
|
92
|
+
</div>,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
await user.click(screen.getByRole("button", { name: "Outside" }));
|
|
96
|
+
|
|
97
|
+
expect(onPointerDownOutside).not.toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("removes its document pointerdown listener on unmount", () => {
|
|
101
|
+
const addEventListener = vi.spyOn(document, "addEventListener");
|
|
102
|
+
const removeEventListener = vi.spyOn(document, "removeEventListener");
|
|
103
|
+
|
|
104
|
+
const { unmount } = render(
|
|
105
|
+
<Tooltip.Provider>
|
|
106
|
+
<Tooltip.Root defaultOpen>
|
|
107
|
+
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
108
|
+
<Tooltip.Content>Tooltip text</Tooltip.Content>
|
|
109
|
+
</Tooltip.Root>
|
|
110
|
+
</Tooltip.Provider>,
|
|
111
|
+
);
|
|
112
|
+
const handler = addEventListener.mock.calls.find(
|
|
113
|
+
([type]) => type === "pointerdown",
|
|
114
|
+
)?.[1];
|
|
115
|
+
unmount();
|
|
116
|
+
|
|
117
|
+
expect(handler).toBeTypeOf("function");
|
|
118
|
+
expect(removeEventListener).toHaveBeenCalledWith("pointerdown", handler);
|
|
119
|
+
|
|
120
|
+
addEventListener.mockRestore();
|
|
121
|
+
removeEventListener.mockRestore();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("closes on an outside pointer-down after opening via hover (the listener effect must re-run once open flips)", async () => {
|
|
125
|
+
const user = userEvent.setup();
|
|
126
|
+
|
|
127
|
+
render(
|
|
128
|
+
<div>
|
|
129
|
+
<Tooltip.Provider delayDuration={0}>
|
|
130
|
+
<Tooltip.Root>
|
|
131
|
+
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
132
|
+
<Tooltip.Content>Tooltip text</Tooltip.Content>
|
|
133
|
+
</Tooltip.Root>
|
|
134
|
+
</Tooltip.Provider>
|
|
135
|
+
<button>Outside</button>
|
|
136
|
+
</div>,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
await user.hover(screen.getByRole("button", { name: "Hover me" }));
|
|
140
|
+
expect(screen.getByRole("tooltip")).toBeInTheDocument();
|
|
141
|
+
|
|
142
|
+
await user.click(screen.getByRole("button", { name: "Outside" }));
|
|
143
|
+
|
|
144
|
+
expect(screen.queryByRole("tooltip")).toBeNull();
|
|
145
|
+
});
|
|
73
146
|
});
|
|
@@ -82,4 +82,138 @@ describe("Tooltip — keyboard interaction", () => {
|
|
|
82
82
|
await user.keyboard("{Escape}");
|
|
83
83
|
expect(screen.queryByRole("tooltip")).toBeNull();
|
|
84
84
|
});
|
|
85
|
+
|
|
86
|
+
it("ignores keys other than Escape pressed while the tooltip is open", async () => {
|
|
87
|
+
const user = userEvent.setup();
|
|
88
|
+
const onEscapeKeyDown = vi.fn();
|
|
89
|
+
|
|
90
|
+
render(
|
|
91
|
+
<Tooltip.Provider delayDuration={0}>
|
|
92
|
+
<Tooltip.Root>
|
|
93
|
+
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
94
|
+
<Tooltip.Content onEscapeKeyDown={onEscapeKeyDown}>
|
|
95
|
+
Tooltip text
|
|
96
|
+
</Tooltip.Content>
|
|
97
|
+
</Tooltip.Root>
|
|
98
|
+
</Tooltip.Provider>,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
await user.hover(screen.getByRole("button", { name: "Hover me" }));
|
|
102
|
+
expect(screen.getByRole("tooltip")).toBeInTheDocument();
|
|
103
|
+
|
|
104
|
+
await user.keyboard("{Enter}");
|
|
105
|
+
|
|
106
|
+
expect(onEscapeKeyDown).not.toHaveBeenCalled();
|
|
107
|
+
expect(screen.getByRole("tooltip")).toBeInTheDocument();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("does not respond to Escape while closed, even when Content is forceMounted", async () => {
|
|
111
|
+
const user = userEvent.setup();
|
|
112
|
+
const onEscapeKeyDown = vi.fn();
|
|
113
|
+
|
|
114
|
+
render(
|
|
115
|
+
<Tooltip.Provider>
|
|
116
|
+
<Tooltip.Root>
|
|
117
|
+
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
118
|
+
<Tooltip.Content forceMount onEscapeKeyDown={onEscapeKeyDown}>
|
|
119
|
+
Tooltip text
|
|
120
|
+
</Tooltip.Content>
|
|
121
|
+
</Tooltip.Root>
|
|
122
|
+
</Tooltip.Provider>,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
await user.keyboard("{Escape}");
|
|
126
|
+
|
|
127
|
+
expect(onEscapeKeyDown).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("removes its document Escape listener on unmount", () => {
|
|
131
|
+
const addEventListener = vi.spyOn(document, "addEventListener");
|
|
132
|
+
const removeEventListener = vi.spyOn(document, "removeEventListener");
|
|
133
|
+
|
|
134
|
+
const { unmount } = render(
|
|
135
|
+
<Tooltip.Provider>
|
|
136
|
+
<Tooltip.Root defaultOpen>
|
|
137
|
+
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
138
|
+
<Tooltip.Content>Tooltip text</Tooltip.Content>
|
|
139
|
+
</Tooltip.Root>
|
|
140
|
+
</Tooltip.Provider>,
|
|
141
|
+
);
|
|
142
|
+
const handler = addEventListener.mock.calls.find(
|
|
143
|
+
([type]) => type === "keydown",
|
|
144
|
+
)?.[1];
|
|
145
|
+
unmount();
|
|
146
|
+
|
|
147
|
+
expect(handler).toBeTypeOf("function");
|
|
148
|
+
expect(removeEventListener).toHaveBeenCalledWith("keydown", handler);
|
|
149
|
+
|
|
150
|
+
addEventListener.mockRestore();
|
|
151
|
+
removeEventListener.mockRestore();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("responds to Escape after opening via hover (the listener effect must re-run once open flips)", async () => {
|
|
155
|
+
const user = userEvent.setup();
|
|
156
|
+
|
|
157
|
+
render(
|
|
158
|
+
<Tooltip.Provider delayDuration={0}>
|
|
159
|
+
<Tooltip.Root>
|
|
160
|
+
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
161
|
+
<Tooltip.Content>Tooltip text</Tooltip.Content>
|
|
162
|
+
</Tooltip.Root>
|
|
163
|
+
</Tooltip.Provider>,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
await user.hover(screen.getByRole("button", { name: "Hover me" }));
|
|
167
|
+
expect(screen.getByRole("tooltip")).toBeInTheDocument();
|
|
168
|
+
|
|
169
|
+
await user.keyboard("{Escape}");
|
|
170
|
+
|
|
171
|
+
expect(screen.queryByRole("tooltip")).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("closes via the trigger's own Escape handler even when no Tooltip.Content is rendered", async () => {
|
|
175
|
+
const user = userEvent.setup();
|
|
176
|
+
|
|
177
|
+
render(
|
|
178
|
+
<Tooltip.Provider>
|
|
179
|
+
<Tooltip.Root>
|
|
180
|
+
<Tooltip.Trigger>Focusable</Tooltip.Trigger>
|
|
181
|
+
</Tooltip.Root>
|
|
182
|
+
</Tooltip.Provider>,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
await user.tab();
|
|
186
|
+
expect(
|
|
187
|
+
screen.getByRole("button", { name: "Focusable" }),
|
|
188
|
+
).toHaveAttribute("data-state", "open");
|
|
189
|
+
|
|
190
|
+
await user.keyboard("{Escape}");
|
|
191
|
+
|
|
192
|
+
expect(
|
|
193
|
+
screen.getByRole("button", { name: "Focusable" }),
|
|
194
|
+
).toHaveAttribute("data-state", "closed");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("ignores non-Escape keys on the trigger when no Tooltip.Content is rendered", async () => {
|
|
198
|
+
const user = userEvent.setup();
|
|
199
|
+
|
|
200
|
+
render(
|
|
201
|
+
<Tooltip.Provider>
|
|
202
|
+
<Tooltip.Root>
|
|
203
|
+
<Tooltip.Trigger>Focusable</Tooltip.Trigger>
|
|
204
|
+
</Tooltip.Root>
|
|
205
|
+
</Tooltip.Provider>,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
await user.tab();
|
|
209
|
+
expect(
|
|
210
|
+
screen.getByRole("button", { name: "Focusable" }),
|
|
211
|
+
).toHaveAttribute("data-state", "open");
|
|
212
|
+
|
|
213
|
+
await user.keyboard("{Enter}");
|
|
214
|
+
|
|
215
|
+
expect(
|
|
216
|
+
screen.getByRole("button", { name: "Focusable" }),
|
|
217
|
+
).toHaveAttribute("data-state", "open");
|
|
218
|
+
});
|
|
85
219
|
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
|
|
4
|
+
import { Tooltip } from "../Tooltip";
|
|
5
|
+
|
|
6
|
+
// The skip-delay duration is a deliberately odd number, not a round 5000. These
|
|
7
|
+
// tests identify "the skip-delay timer" by the delay passed to setTimeout, and
|
|
8
|
+
// 5000 is common enough that other machinery in the environment schedules one too
|
|
9
|
+
// — which silently made a foreign timer look like ours. That is the most likely
|
|
10
|
+
// root cause of this file being flaky in CI while passing locally: the assertion
|
|
11
|
+
// pinned whichever 5000ms timer happened to be scheduled first.
|
|
12
|
+
const SKIP_DELAY = 4321;
|
|
13
|
+
|
|
14
|
+
describe("Tooltip.Provider — skip-delay coordination", () => {
|
|
15
|
+
it("skips the delay for a tooltip while another is already open, even with a long base delay", async () => {
|
|
16
|
+
const user = userEvent.setup();
|
|
17
|
+
|
|
18
|
+
render(
|
|
19
|
+
<Tooltip.Provider delayDuration={100000} skipDelayDuration={SKIP_DELAY}>
|
|
20
|
+
<Tooltip.Root delayDuration={0}>
|
|
21
|
+
<Tooltip.Trigger>First</Tooltip.Trigger>
|
|
22
|
+
<Tooltip.Content>First tip</Tooltip.Content>
|
|
23
|
+
</Tooltip.Root>
|
|
24
|
+
<Tooltip.Root>
|
|
25
|
+
<Tooltip.Trigger>Second</Tooltip.Trigger>
|
|
26
|
+
<Tooltip.Content>Second tip</Tooltip.Content>
|
|
27
|
+
</Tooltip.Root>
|
|
28
|
+
</Tooltip.Provider>,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
await user.hover(screen.getByRole("button", { name: "First" }));
|
|
32
|
+
expect(screen.getByText("First tip")).toBeInTheDocument();
|
|
33
|
+
|
|
34
|
+
await user.hover(screen.getByRole("button", { name: "Second" }));
|
|
35
|
+
|
|
36
|
+
expect(screen.getByText("Second tip")).toBeInTheDocument();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("clears a pending skip-delay timer when a tooltip opens again within the skip window", async () => {
|
|
40
|
+
const user = userEvent.setup();
|
|
41
|
+
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
|
42
|
+
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
43
|
+
|
|
44
|
+
render(
|
|
45
|
+
<Tooltip.Provider delayDuration={0} skipDelayDuration={SKIP_DELAY}>
|
|
46
|
+
<Tooltip.Root>
|
|
47
|
+
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
48
|
+
<Tooltip.Content>Tooltip text</Tooltip.Content>
|
|
49
|
+
</Tooltip.Root>
|
|
50
|
+
<button>Other</button>
|
|
51
|
+
</Tooltip.Provider>,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
await user.tab();
|
|
55
|
+
expect(screen.getByRole("tooltip")).toBeInTheDocument();
|
|
56
|
+
await user.tab();
|
|
57
|
+
expect(screen.queryByRole("tooltip")).toBeNull();
|
|
58
|
+
|
|
59
|
+
const skipTimerCallIndex = setTimeoutSpy.mock.calls.findIndex(
|
|
60
|
+
([, delay]) => delay === SKIP_DELAY,
|
|
61
|
+
);
|
|
62
|
+
const timerId = setTimeoutSpy.mock.results[skipTimerCallIndex].value;
|
|
63
|
+
|
|
64
|
+
await user.hover(screen.getByRole("button", { name: "Hover me" }));
|
|
65
|
+
|
|
66
|
+
expect(clearTimeoutSpy).toHaveBeenCalledWith(timerId);
|
|
67
|
+
|
|
68
|
+
clearTimeoutSpy.mockRestore();
|
|
69
|
+
setTimeoutSpy.mockRestore();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("does not orphan a skip-delay timer when two closes land in a row", () => {
|
|
73
|
+
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
|
74
|
+
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
75
|
+
|
|
76
|
+
render(
|
|
77
|
+
<Tooltip.Provider delayDuration={0} skipDelayDuration={SKIP_DELAY}>
|
|
78
|
+
<Tooltip.Root>
|
|
79
|
+
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
80
|
+
<Tooltip.Content>Tooltip text</Tooltip.Content>
|
|
81
|
+
</Tooltip.Root>
|
|
82
|
+
<button>Other</button>
|
|
83
|
+
</Tooltip.Provider>,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// `closeImmediate` is deliberately unguarded — it calls `onCloseGlobally()`
|
|
87
|
+
// whether or not the tooltip is open — so two close-triggering events in a row
|
|
88
|
+
// schedule two skip-delay timers. Escape and blur are both wired straight to
|
|
89
|
+
// it; pointer-leave is not (it routes through `closeWithGrace`, which is why an
|
|
90
|
+
// earlier version of this test using unhover only ever produced one timer).
|
|
91
|
+
//
|
|
92
|
+
// Driven with `fireEvent`, not `userEvent`: this asserts an exact timer count,
|
|
93
|
+
// so it must not depend on how a higher-level helper chooses to synthesise
|
|
94
|
+
// focus and key events — which is precisely the kind of environment
|
|
95
|
+
// sensitivity that made this file flaky.
|
|
96
|
+
const trigger = screen.getByRole("button", { name: "Hover me" });
|
|
97
|
+
fireEvent.focus(trigger);
|
|
98
|
+
fireEvent.keyDown(trigger, { key: "Escape" }); // → close #1
|
|
99
|
+
fireEvent.blur(trigger); // → close #2
|
|
100
|
+
|
|
101
|
+
const skipTimers = setTimeoutSpy.mock.calls
|
|
102
|
+
.map((call, i) => ({ delay: call[1], id: setTimeoutSpy.mock.results[i].value }))
|
|
103
|
+
.filter(({ delay }) => delay === SKIP_DELAY)
|
|
104
|
+
.map(({ id }) => id);
|
|
105
|
+
|
|
106
|
+
// At least two timers, asserted rather than assumed: with fewer than two the
|
|
107
|
+
// loop below would pass vacuously and quietly stop testing anything. NOT an
|
|
108
|
+
// exact count — an earlier version demanded exactly two and measured three,
|
|
109
|
+
// because how many close events a given engine and testing helper synthesise
|
|
110
|
+
// is not the property under test. The invariant is what matters, and it holds
|
|
111
|
+
// for any number of closes.
|
|
112
|
+
expect(skipTimers.length).toBeGreaterThanOrEqual(2);
|
|
113
|
+
// Every timer but the newest must have been cancelled. Without that the ref
|
|
114
|
+
// only ever tracks the latest, so the earlier one survives, fires unobserved,
|
|
115
|
+
// and can no longer be cancelled by a later open.
|
|
116
|
+
for (const orphan of skipTimers.slice(0, -1)) {
|
|
117
|
+
expect(clearTimeoutSpy).toHaveBeenCalledWith(orphan);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
clearTimeoutSpy.mockRestore();
|
|
121
|
+
setTimeoutSpy.mockRestore();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("schedules the skip-delay timer using the Provider's skipDelayDuration when a tooltip closes", async () => {
|
|
125
|
+
const user = userEvent.setup();
|
|
126
|
+
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
127
|
+
|
|
128
|
+
render(
|
|
129
|
+
<Tooltip.Provider skipDelayDuration={1234}>
|
|
130
|
+
<Tooltip.Root>
|
|
131
|
+
<Tooltip.Trigger>Focusable</Tooltip.Trigger>
|
|
132
|
+
<Tooltip.Content>Tooltip text</Tooltip.Content>
|
|
133
|
+
</Tooltip.Root>
|
|
134
|
+
<button>Other</button>
|
|
135
|
+
</Tooltip.Provider>,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
await user.tab();
|
|
139
|
+
expect(screen.getByRole("tooltip")).toBeInTheDocument();
|
|
140
|
+
await user.tab();
|
|
141
|
+
|
|
142
|
+
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1234);
|
|
143
|
+
|
|
144
|
+
setTimeoutSpy.mockRestore();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("uses the current skipDelayDuration for the skip-delay timer after the Provider prop changes", async () => {
|
|
148
|
+
const user = userEvent.setup();
|
|
149
|
+
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
150
|
+
|
|
151
|
+
function Wrapper({ skipDelayDuration }: { skipDelayDuration: number }) {
|
|
152
|
+
return (
|
|
153
|
+
<Tooltip.Provider skipDelayDuration={skipDelayDuration}>
|
|
154
|
+
<Tooltip.Root>
|
|
155
|
+
<Tooltip.Trigger>Focusable</Tooltip.Trigger>
|
|
156
|
+
<Tooltip.Content>Tooltip text</Tooltip.Content>
|
|
157
|
+
</Tooltip.Root>
|
|
158
|
+
<button>Other</button>
|
|
159
|
+
</Tooltip.Provider>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const { rerender } = render(<Wrapper skipDelayDuration={1000} />);
|
|
164
|
+
rerender(<Wrapper skipDelayDuration={42} />);
|
|
165
|
+
|
|
166
|
+
await user.tab();
|
|
167
|
+
expect(screen.getByRole("tooltip")).toBeInTheDocument();
|
|
168
|
+
await user.tab();
|
|
169
|
+
|
|
170
|
+
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 42);
|
|
171
|
+
|
|
172
|
+
setTimeoutSpy.mockRestore();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("once the skip-delay window has actually elapsed", () => {
|
|
176
|
+
beforeEach(() => {
|
|
177
|
+
vi.useFakeTimers();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
afterEach(() => {
|
|
181
|
+
vi.useRealTimers();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("re-applies the full base delay to the next tooltip instead of skipping it", () => {
|
|
185
|
+
render(
|
|
186
|
+
<Tooltip.Provider delayDuration={1000} skipDelayDuration={100}>
|
|
187
|
+
<Tooltip.Root delayDuration={0} disableHoverableContent>
|
|
188
|
+
<Tooltip.Trigger>First</Tooltip.Trigger>
|
|
189
|
+
<Tooltip.Content>First tip</Tooltip.Content>
|
|
190
|
+
</Tooltip.Root>
|
|
191
|
+
<Tooltip.Root>
|
|
192
|
+
<Tooltip.Trigger>Second</Tooltip.Trigger>
|
|
193
|
+
<Tooltip.Content>Second tip</Tooltip.Content>
|
|
194
|
+
</Tooltip.Root>
|
|
195
|
+
</Tooltip.Provider>,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
fireEvent.pointerEnter(screen.getByRole("button", { name: "First" }));
|
|
199
|
+
expect(screen.getByText("First tip")).toBeInTheDocument();
|
|
200
|
+
|
|
201
|
+
fireEvent.pointerLeave(screen.getByRole("button", { name: "First" }));
|
|
202
|
+
|
|
203
|
+
act(() => {
|
|
204
|
+
vi.advanceTimersByTime(100);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
fireEvent.pointerEnter(screen.getByRole("button", { name: "Second" }));
|
|
208
|
+
expect(screen.queryByText("Second tip")).toBeNull();
|
|
209
|
+
|
|
210
|
+
act(() => {
|
|
211
|
+
vi.advanceTimersByTime(1000);
|
|
212
|
+
});
|
|
213
|
+
expect(screen.getByText("Second tip")).toBeInTheDocument();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
|
|
4
|
+
import { Tooltip } from "../Tooltip";
|
|
5
|
+
|
|
6
|
+
describe("Tooltip.Root — timer callbacks stay current across re-renders", () => {
|
|
7
|
+
it("uses the current onOpenChange for both a focus-open and a blur-close after the Provider re-renders", async () => {
|
|
8
|
+
const user = userEvent.setup();
|
|
9
|
+
const firstOnOpenChange = vi.fn();
|
|
10
|
+
const secondOnOpenChange = vi.fn();
|
|
11
|
+
|
|
12
|
+
function Wrapper({
|
|
13
|
+
onOpenChange,
|
|
14
|
+
}: {
|
|
15
|
+
onOpenChange: (open: boolean) => void;
|
|
16
|
+
}) {
|
|
17
|
+
return (
|
|
18
|
+
<Tooltip.Provider>
|
|
19
|
+
<Tooltip.Root onOpenChange={onOpenChange}>
|
|
20
|
+
<Tooltip.Trigger>Focusable</Tooltip.Trigger>
|
|
21
|
+
<Tooltip.Content>Tooltip text</Tooltip.Content>
|
|
22
|
+
</Tooltip.Root>
|
|
23
|
+
<button>Other</button>
|
|
24
|
+
</Tooltip.Provider>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { rerender } = render(<Wrapper onOpenChange={firstOnOpenChange} />);
|
|
29
|
+
rerender(<Wrapper onOpenChange={secondOnOpenChange} />);
|
|
30
|
+
|
|
31
|
+
await user.tab();
|
|
32
|
+
await user.tab();
|
|
33
|
+
|
|
34
|
+
expect(firstOnOpenChange).not.toHaveBeenCalled();
|
|
35
|
+
expect(secondOnOpenChange).toHaveBeenCalledWith(true);
|
|
36
|
+
expect(secondOnOpenChange).toHaveBeenCalledWith(false);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("Tooltip.Root — timer coordination (fake timers)", () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.useFakeTimers();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.useRealTimers();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("opens synchronously when the effective delay is 0, not via a scheduled timer", () => {
|
|
50
|
+
render(
|
|
51
|
+
<Tooltip.Provider delayDuration={0}>
|
|
52
|
+
<Tooltip.Root>
|
|
53
|
+
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
54
|
+
<Tooltip.Content>Tooltip text</Tooltip.Content>
|
|
55
|
+
</Tooltip.Root>
|
|
56
|
+
</Tooltip.Provider>,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
fireEvent.pointerEnter(screen.getByRole("button", { name: "Hover me" }));
|
|
60
|
+
|
|
61
|
+
expect(screen.getByRole("tooltip")).toBeInTheDocument();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("cancels a pending hover-delay open timer when the trigger is focused before it elapses", () => {
|
|
65
|
+
const onOpenChange = vi.fn();
|
|
66
|
+
|
|
67
|
+
render(
|
|
68
|
+
<Tooltip.Provider delayDuration={1000}>
|
|
69
|
+
<Tooltip.Root onOpenChange={onOpenChange}>
|
|
70
|
+
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
71
|
+
<Tooltip.Content>Tooltip text</Tooltip.Content>
|
|
72
|
+
</Tooltip.Root>
|
|
73
|
+
</Tooltip.Provider>,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
fireEvent.pointerEnter(screen.getByRole("button", { name: "Hover me" }));
|
|
77
|
+
fireEvent.focus(screen.getByRole("button", { name: "Hover me" }));
|
|
78
|
+
|
|
79
|
+
expect(onOpenChange).toHaveBeenCalledTimes(1);
|
|
80
|
+
|
|
81
|
+
act(() => {
|
|
82
|
+
vi.advanceTimersByTime(1000);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(onOpenChange).toHaveBeenCalledTimes(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("cancels a pending grace-period timer when the tooltip closes immediately during the grace window", () => {
|
|
89
|
+
const onOpenChange = vi.fn();
|
|
90
|
+
|
|
91
|
+
// No Tooltip.Content here: its document-level Escape listener would also
|
|
92
|
+
// fire and call closeImmediate a second time, muddying the call count
|
|
93
|
+
// this test cares about. The trigger's own Escape handler is enough to
|
|
94
|
+
// exercise closeImmediate while a grace-period timer is pending.
|
|
95
|
+
render(
|
|
96
|
+
<Tooltip.Provider delayDuration={0}>
|
|
97
|
+
<Tooltip.Root onOpenChange={onOpenChange}>
|
|
98
|
+
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
99
|
+
</Tooltip.Root>
|
|
100
|
+
</Tooltip.Provider>,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
fireEvent.pointerEnter(screen.getByRole("button", { name: "Hover me" }));
|
|
104
|
+
expect(onOpenChange).toHaveBeenCalledTimes(1);
|
|
105
|
+
|
|
106
|
+
// Leaving the trigger starts the grace-period close timer...
|
|
107
|
+
fireEvent.pointerLeave(screen.getByRole("button", { name: "Hover me" }));
|
|
108
|
+
// ...then Escape closes immediately, which must cancel that pending timer.
|
|
109
|
+
fireEvent.keyDown(screen.getByRole("button", { name: "Hover me" }), {
|
|
110
|
+
key: "Escape",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(onOpenChange).toHaveBeenCalledTimes(2);
|
|
114
|
+
|
|
115
|
+
act(() => {
|
|
116
|
+
vi.advanceTimersByTime(100);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(onOpenChange).toHaveBeenCalledTimes(2);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("cancels the grace-period close timer when the pointer moves into the content", () => {
|
|
123
|
+
render(
|
|
124
|
+
<Tooltip.Provider delayDuration={0}>
|
|
125
|
+
<Tooltip.Root>
|
|
126
|
+
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
127
|
+
<Tooltip.Content>Tooltip text</Tooltip.Content>
|
|
128
|
+
</Tooltip.Root>
|
|
129
|
+
</Tooltip.Provider>,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
fireEvent.pointerEnter(screen.getByRole("button", { name: "Hover me" }));
|
|
133
|
+
expect(screen.getByRole("tooltip")).toBeInTheDocument();
|
|
134
|
+
|
|
135
|
+
fireEvent.pointerLeave(screen.getByRole("button", { name: "Hover me" }));
|
|
136
|
+
fireEvent.pointerEnter(screen.getByRole("tooltip"));
|
|
137
|
+
|
|
138
|
+
act(() => {
|
|
139
|
+
vi.advanceTimersByTime(100);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(screen.getByRole("tooltip")).toBeInTheDocument();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("closes after the grace period once the pointer leaves the trigger without re-entering the content", () => {
|
|
146
|
+
render(
|
|
147
|
+
<Tooltip.Provider delayDuration={0}>
|
|
148
|
+
<Tooltip.Root>
|
|
149
|
+
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
150
|
+
<Tooltip.Content>Tooltip text</Tooltip.Content>
|
|
151
|
+
</Tooltip.Root>
|
|
152
|
+
</Tooltip.Provider>,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
fireEvent.pointerEnter(screen.getByRole("button", { name: "Hover me" }));
|
|
156
|
+
expect(screen.getByRole("tooltip")).toBeInTheDocument();
|
|
157
|
+
|
|
158
|
+
fireEvent.pointerLeave(screen.getByRole("button", { name: "Hover me" }));
|
|
159
|
+
|
|
160
|
+
// Still open right up until the grace period elapses.
|
|
161
|
+
expect(screen.getByRole("tooltip")).toBeInTheDocument();
|
|
162
|
+
|
|
163
|
+
act(() => {
|
|
164
|
+
vi.advanceTimersByTime(100);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(screen.queryByRole("tooltip")).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("cancels a pending hover-delay open timer when the pointer leaves before it elapses, so the tooltip does not open later", () => {
|
|
171
|
+
render(
|
|
172
|
+
<Tooltip.Provider delayDuration={500}>
|
|
173
|
+
<Tooltip.Root>
|
|
174
|
+
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
175
|
+
<Tooltip.Content>Tooltip text</Tooltip.Content>
|
|
176
|
+
</Tooltip.Root>
|
|
177
|
+
</Tooltip.Provider>,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
fireEvent.pointerEnter(screen.getByRole("button", { name: "Hover me" }));
|
|
181
|
+
fireEvent.pointerLeave(screen.getByRole("button", { name: "Hover me" }));
|
|
182
|
+
|
|
183
|
+
act(() => {
|
|
184
|
+
vi.advanceTimersByTime(500);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(screen.queryByRole("tooltip")).toBeNull();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("uses the current onOpenChange callback for a grace-period close after a re-render", () => {
|
|
191
|
+
const firstOnOpenChange = vi.fn();
|
|
192
|
+
const secondOnOpenChange = vi.fn();
|
|
193
|
+
|
|
194
|
+
function Wrapper({
|
|
195
|
+
onOpenChange,
|
|
196
|
+
}: {
|
|
197
|
+
onOpenChange: (open: boolean) => void;
|
|
198
|
+
}) {
|
|
199
|
+
return (
|
|
200
|
+
<Tooltip.Provider delayDuration={0}>
|
|
201
|
+
<Tooltip.Root onOpenChange={onOpenChange}>
|
|
202
|
+
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
203
|
+
<Tooltip.Content>Tooltip text</Tooltip.Content>
|
|
204
|
+
</Tooltip.Root>
|
|
205
|
+
</Tooltip.Provider>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const { rerender } = render(<Wrapper onOpenChange={firstOnOpenChange} />);
|
|
210
|
+
rerender(<Wrapper onOpenChange={secondOnOpenChange} />);
|
|
211
|
+
|
|
212
|
+
fireEvent.pointerEnter(screen.getByRole("button", { name: "Hover me" }));
|
|
213
|
+
fireEvent.pointerLeave(screen.getByRole("button", { name: "Hover me" }));
|
|
214
|
+
|
|
215
|
+
act(() => {
|
|
216
|
+
vi.advanceTimersByTime(100);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(firstOnOpenChange).not.toHaveBeenCalled();
|
|
220
|
+
expect(secondOnOpenChange).toHaveBeenCalledWith(true);
|
|
221
|
+
expect(secondOnOpenChange).toHaveBeenCalledWith(false);
|
|
222
|
+
});
|
|
223
|
+
});
|