@slithy/base-ui 0.1.0 → 0.2.0
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/CHANGELOG.md +18 -0
- package/README.md +103 -128
- package/dist/index.d.ts +118 -35
- package/dist/index.js +911 -424
- package/package.json +3 -2
- package/src/Dropdown/Dropdown.test.tsx +361 -186
- package/src/Dropdown/Dropdown.tsx +353 -349
- package/src/Dropdown/DropdownRenderer.tsx +118 -0
- package/src/Dropdown/DropdownStore.ts +147 -0
- package/src/Dropdown/index.ts +1 -0
- package/src/Tooltip/Tooltip.test.tsx +221 -212
- package/src/Tooltip/Tooltip.tsx +274 -201
- package/src/Tooltip/TooltipRenderer.tsx +137 -0
- package/src/Tooltip/TooltipStore.ts +142 -0
- package/src/Tooltip/index.ts +2 -1
- package/src/index.ts +2 -2
- package/src/useCloseCleanup.ts +60 -0
- package/src/useSafePolygon.ts +144 -0
|
@@ -1,55 +1,74 @@
|
|
|
1
|
-
import { render, screen } from '@testing-library/react';
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import { Dropdown } from './Dropdown';
|
|
4
|
+
import { DropdownRenderer } from './DropdownRenderer';
|
|
5
|
+
import { useDropdownStore } from './DropdownStore';
|
|
6
|
+
import { TooltipRenderer } from '../Tooltip/TooltipRenderer';
|
|
7
|
+
import { useTooltipStore } from '../Tooltip/TooltipStore';
|
|
4
8
|
|
|
5
9
|
function renderDropdown(props?: {
|
|
6
10
|
open?: boolean;
|
|
7
11
|
disabled?: boolean;
|
|
12
|
+
onOpenChange?: (open: boolean) => void;
|
|
13
|
+
onOpenChangeComplete?: (open: boolean) => void;
|
|
8
14
|
}) {
|
|
9
|
-
const { open, disabled } = props ?? {};
|
|
15
|
+
const { open, disabled, onOpenChange, onOpenChangeComplete } = props ?? {};
|
|
10
16
|
return render(
|
|
11
|
-
|
|
12
|
-
<
|
|
13
|
-
<Dropdown.
|
|
14
|
-
|
|
17
|
+
<>
|
|
18
|
+
<DropdownRenderer />
|
|
19
|
+
<Dropdown.Root
|
|
20
|
+
open={open}
|
|
21
|
+
disabled={disabled}
|
|
22
|
+
onOpenChange={onOpenChange}
|
|
23
|
+
onOpenChangeComplete={onOpenChangeComplete}
|
|
24
|
+
>
|
|
25
|
+
<Dropdown.Trigger>Open menu</Dropdown.Trigger>
|
|
26
|
+
<Dropdown.Portal>
|
|
15
27
|
<Dropdown.Popup>
|
|
16
28
|
<Dropdown.Item>Item one</Dropdown.Item>
|
|
17
29
|
<Dropdown.Item>Item two</Dropdown.Item>
|
|
18
30
|
<Dropdown.Item>Item three</Dropdown.Item>
|
|
19
31
|
</Dropdown.Popup>
|
|
20
|
-
</Dropdown.
|
|
21
|
-
</Dropdown.
|
|
22
|
-
|
|
32
|
+
</Dropdown.Portal>
|
|
33
|
+
</Dropdown.Root>
|
|
34
|
+
</>,
|
|
23
35
|
);
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
describe('Dropdown', () => {
|
|
27
|
-
describe('
|
|
28
|
-
it('renders the trigger as a button
|
|
39
|
+
describe('trigger', () => {
|
|
40
|
+
it('renders the trigger as a button', () => {
|
|
29
41
|
renderDropdown();
|
|
30
42
|
const trigger = screen.getByText('Open menu');
|
|
31
43
|
expect(trigger).toBeInTheDocument();
|
|
32
44
|
expect(trigger.tagName).toBe('BUTTON');
|
|
33
45
|
});
|
|
34
46
|
|
|
35
|
-
it('
|
|
47
|
+
it('has correct ARIA attributes', () => {
|
|
48
|
+
renderDropdown();
|
|
49
|
+
const trigger = screen.getByText('Open menu');
|
|
50
|
+
expect(trigger).toHaveAttribute('aria-haspopup', 'menu');
|
|
51
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('does not mount menu content before interaction', () => {
|
|
36
55
|
renderDropdown();
|
|
37
56
|
expect(screen.queryByText('Item one')).not.toBeInTheDocument();
|
|
38
57
|
});
|
|
39
58
|
|
|
40
|
-
it('
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
expect(screen.
|
|
59
|
+
it('does not activate when disabled', async () => {
|
|
60
|
+
const user = userEvent.setup();
|
|
61
|
+
renderDropdown({ disabled: true });
|
|
62
|
+
await user.click(screen.getByText('Open menu'));
|
|
63
|
+
expect(screen.queryByText('Item one')).not.toBeInTheDocument();
|
|
45
64
|
});
|
|
65
|
+
});
|
|
46
66
|
|
|
47
|
-
|
|
67
|
+
describe('open behavior', () => {
|
|
68
|
+
it('opens menu on click', async () => {
|
|
48
69
|
const user = userEvent.setup();
|
|
49
70
|
renderDropdown();
|
|
50
71
|
|
|
51
|
-
expect(screen.queryByText('Item one')).not.toBeInTheDocument();
|
|
52
|
-
|
|
53
72
|
await user.click(screen.getByText('Open menu'));
|
|
54
73
|
|
|
55
74
|
await vi.waitFor(() => {
|
|
@@ -59,271 +78,340 @@ describe('Dropdown', () => {
|
|
|
59
78
|
});
|
|
60
79
|
});
|
|
61
80
|
|
|
62
|
-
it('
|
|
63
|
-
renderDropdown({
|
|
64
|
-
|
|
65
|
-
|
|
81
|
+
it('shows menu items when open is controlled', async () => {
|
|
82
|
+
renderDropdown({ open: true });
|
|
83
|
+
await vi.waitFor(() => {
|
|
84
|
+
expect(screen.getByText('Item one')).toBeInTheDocument();
|
|
85
|
+
expect(screen.getByText('Item two')).toBeInTheDocument();
|
|
86
|
+
expect(screen.getByText('Item three')).toBeInTheDocument();
|
|
87
|
+
});
|
|
66
88
|
});
|
|
67
|
-
});
|
|
68
89
|
|
|
69
|
-
|
|
70
|
-
it('deactivates after close animation completes', async () => {
|
|
90
|
+
it('closes on Escape', async () => {
|
|
71
91
|
const user = userEvent.setup();
|
|
72
|
-
|
|
73
|
-
<Dropdown.Root unmountOnClose>
|
|
74
|
-
<Dropdown.Trigger>Open menu</Dropdown.Trigger>
|
|
75
|
-
<Dropdown.Portal>
|
|
76
|
-
<Dropdown.Positioner>
|
|
77
|
-
<Dropdown.Popup>
|
|
78
|
-
<Dropdown.Item>Item one</Dropdown.Item>
|
|
79
|
-
</Dropdown.Popup>
|
|
80
|
-
</Dropdown.Positioner>
|
|
81
|
-
</Dropdown.Portal>
|
|
82
|
-
</Dropdown.Root>,
|
|
83
|
-
);
|
|
92
|
+
renderDropdown();
|
|
84
93
|
|
|
85
|
-
// Click to open
|
|
86
94
|
await user.click(screen.getByText('Open menu'));
|
|
87
95
|
await vi.waitFor(() => {
|
|
88
96
|
expect(screen.getByText('Item one')).toBeInTheDocument();
|
|
89
97
|
});
|
|
90
98
|
|
|
91
|
-
// Press Escape to close
|
|
92
99
|
await user.keyboard('{Escape}');
|
|
93
|
-
|
|
94
|
-
// After close completes, menu items should unmount
|
|
95
100
|
await vi.waitFor(() => {
|
|
96
101
|
expect(screen.queryByText('Item one')).not.toBeInTheDocument();
|
|
97
102
|
});
|
|
98
|
-
|
|
99
|
-
// Trigger should still be a button (back to pre-activation state)
|
|
100
|
-
expect(screen.getByText('Open menu').tagName).toBe('BUTTON');
|
|
101
103
|
});
|
|
102
104
|
|
|
103
|
-
it('can reopen after
|
|
105
|
+
it('can reopen after closing', async () => {
|
|
104
106
|
const user = userEvent.setup();
|
|
105
|
-
|
|
106
|
-
<Dropdown.Root unmountOnClose>
|
|
107
|
-
<Dropdown.Trigger>Open menu</Dropdown.Trigger>
|
|
108
|
-
<Dropdown.Portal>
|
|
109
|
-
<Dropdown.Positioner>
|
|
110
|
-
<Dropdown.Popup>
|
|
111
|
-
<Dropdown.Item>Item one</Dropdown.Item>
|
|
112
|
-
</Dropdown.Popup>
|
|
113
|
-
</Dropdown.Positioner>
|
|
114
|
-
</Dropdown.Portal>
|
|
115
|
-
</Dropdown.Root>,
|
|
116
|
-
);
|
|
107
|
+
renderDropdown();
|
|
117
108
|
|
|
118
|
-
//
|
|
109
|
+
// Open
|
|
119
110
|
await user.click(screen.getByText('Open menu'));
|
|
120
111
|
await vi.waitFor(() => {
|
|
121
112
|
expect(screen.getByText('Item one')).toBeInTheDocument();
|
|
122
113
|
});
|
|
114
|
+
|
|
115
|
+
// Close
|
|
123
116
|
await user.keyboard('{Escape}');
|
|
124
117
|
await vi.waitFor(() => {
|
|
125
118
|
expect(screen.queryByText('Item one')).not.toBeInTheDocument();
|
|
126
119
|
});
|
|
127
120
|
|
|
128
|
-
//
|
|
121
|
+
// Reopen
|
|
129
122
|
await user.click(screen.getByText('Open menu'));
|
|
130
123
|
await vi.waitFor(() => {
|
|
131
124
|
expect(screen.getByText('Item one')).toBeInTheDocument();
|
|
132
125
|
});
|
|
133
126
|
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('callbacks', () => {
|
|
130
|
+
it('calls onOpenChange when menu closes', async () => {
|
|
131
|
+
const onOpenChange = vi.fn();
|
|
132
|
+
const user = userEvent.setup();
|
|
133
|
+
renderDropdown({ onOpenChange });
|
|
134
|
+
|
|
135
|
+
await user.click(screen.getByText('Open menu'));
|
|
136
|
+
await vi.waitFor(() => {
|
|
137
|
+
expect(screen.getByText('Item one')).toBeInTheDocument();
|
|
138
|
+
});
|
|
134
139
|
|
|
135
|
-
|
|
140
|
+
await user.keyboard('{Escape}');
|
|
141
|
+
await vi.waitFor(() => {
|
|
142
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// onOpenChangeComplete depends on popup ref + transition timing.
|
|
147
|
+
// In jsdom with the singleton's deferred open (RAF), the popup ref
|
|
148
|
+
// isn't ready when Menu.Root's useOpenChangeComplete runs.
|
|
149
|
+
// Validated manually in the playground.
|
|
150
|
+
it.skip('calls onOpenChangeComplete after open', async () => {
|
|
136
151
|
const onComplete = vi.fn();
|
|
137
152
|
const user = userEvent.setup();
|
|
138
|
-
|
|
139
|
-
<Dropdown.Root unmountOnClose onOpenChangeComplete={onComplete}>
|
|
140
|
-
<Dropdown.Trigger>Open menu</Dropdown.Trigger>
|
|
141
|
-
<Dropdown.Portal>
|
|
142
|
-
<Dropdown.Positioner>
|
|
143
|
-
<Dropdown.Popup>
|
|
144
|
-
<Dropdown.Item>Item one</Dropdown.Item>
|
|
145
|
-
</Dropdown.Popup>
|
|
146
|
-
</Dropdown.Positioner>
|
|
147
|
-
</Dropdown.Portal>
|
|
148
|
-
</Dropdown.Root>,
|
|
149
|
-
);
|
|
153
|
+
renderDropdown({ onOpenChangeComplete: onComplete });
|
|
150
154
|
|
|
151
155
|
await user.click(screen.getByText('Open menu'));
|
|
152
156
|
await vi.waitFor(() => {
|
|
153
157
|
expect(screen.getByText('Item one')).toBeInTheDocument();
|
|
154
158
|
});
|
|
155
159
|
|
|
156
|
-
// onOpenChangeComplete(true) should have been called
|
|
157
160
|
await vi.waitFor(() => {
|
|
158
161
|
expect(onComplete).toHaveBeenCalledWith(true);
|
|
159
162
|
});
|
|
160
163
|
});
|
|
161
164
|
});
|
|
162
165
|
|
|
163
|
-
describe('
|
|
164
|
-
it('
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
describe('singleton behavior', () => {
|
|
167
|
+
it('closes current dropdown when a different trigger is clicked', async () => {
|
|
168
|
+
const user = userEvent.setup();
|
|
169
|
+
render(
|
|
170
|
+
<>
|
|
171
|
+
<DropdownRenderer />
|
|
172
|
+
<Dropdown.Root>
|
|
173
|
+
<Dropdown.Trigger>Menu A</Dropdown.Trigger>
|
|
174
|
+
<Dropdown.Portal>
|
|
175
|
+
<Dropdown.Popup>
|
|
176
|
+
<Dropdown.Item>Item A</Dropdown.Item>
|
|
177
|
+
</Dropdown.Popup>
|
|
178
|
+
</Dropdown.Portal>
|
|
179
|
+
</Dropdown.Root>
|
|
180
|
+
<Dropdown.Root>
|
|
181
|
+
<Dropdown.Trigger>Menu B</Dropdown.Trigger>
|
|
182
|
+
<Dropdown.Portal>
|
|
183
|
+
<Dropdown.Popup>
|
|
184
|
+
<Dropdown.Item>Item B</Dropdown.Item>
|
|
185
|
+
</Dropdown.Popup>
|
|
186
|
+
</Dropdown.Portal>
|
|
187
|
+
</Dropdown.Root>
|
|
188
|
+
</>,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// Open A
|
|
192
|
+
await user.click(screen.getByText('Menu A'));
|
|
193
|
+
await vi.waitFor(() => {
|
|
194
|
+
expect(screen.getByText('Item A')).toBeInTheDocument();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Click B — A should close, B opens on next click (two-click switch)
|
|
198
|
+
await user.click(screen.getByText('Menu B'));
|
|
199
|
+
await vi.waitFor(() => {
|
|
200
|
+
expect(screen.queryByText('Item A')).not.toBeInTheDocument();
|
|
201
|
+
});
|
|
169
202
|
});
|
|
170
|
-
});
|
|
171
203
|
|
|
172
|
-
|
|
173
|
-
|
|
204
|
+
it('restores focus to trigger after Escape', async () => {
|
|
205
|
+
const user = userEvent.setup();
|
|
174
206
|
renderDropdown();
|
|
175
|
-
|
|
207
|
+
|
|
208
|
+
const trigger = screen.getByText('Open menu');
|
|
209
|
+
await user.click(trigger);
|
|
210
|
+
await vi.waitFor(() => {
|
|
211
|
+
expect(screen.getByText('Item one')).toBeInTheDocument();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await user.keyboard('{Escape}');
|
|
215
|
+
await vi.waitFor(() => {
|
|
216
|
+
expect(document.activeElement).toBe(trigger);
|
|
217
|
+
});
|
|
176
218
|
});
|
|
177
219
|
|
|
178
|
-
it('
|
|
220
|
+
it('updates aria-expanded when open state changes', async () => {
|
|
179
221
|
const user = userEvent.setup();
|
|
180
|
-
|
|
181
|
-
<Dropdown.Root>
|
|
182
|
-
<Dropdown.Trigger tooltip="Tooltip text">Open menu</Dropdown.Trigger>
|
|
183
|
-
<Dropdown.Portal>
|
|
184
|
-
<Dropdown.Positioner>
|
|
185
|
-
<Dropdown.Popup>
|
|
186
|
-
<Dropdown.Item>Item one</Dropdown.Item>
|
|
187
|
-
</Dropdown.Popup>
|
|
188
|
-
</Dropdown.Positioner>
|
|
189
|
-
</Dropdown.Portal>
|
|
190
|
-
</Dropdown.Root>,
|
|
191
|
-
);
|
|
222
|
+
renderDropdown();
|
|
192
223
|
|
|
193
|
-
|
|
224
|
+
const trigger = screen.getByText('Open menu');
|
|
225
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
226
|
+
|
|
227
|
+
await user.click(trigger);
|
|
228
|
+
await vi.waitFor(() => {
|
|
229
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
230
|
+
});
|
|
194
231
|
|
|
232
|
+
await user.keyboard('{Escape}');
|
|
195
233
|
await vi.waitFor(() => {
|
|
196
|
-
expect(
|
|
234
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
197
235
|
});
|
|
198
236
|
});
|
|
199
237
|
|
|
200
|
-
it('
|
|
238
|
+
it('fires item onClick and closes the menu', async () => {
|
|
239
|
+
const handleClick = vi.fn();
|
|
201
240
|
const user = userEvent.setup();
|
|
202
241
|
render(
|
|
203
|
-
|
|
204
|
-
<
|
|
205
|
-
<Dropdown.
|
|
206
|
-
<Dropdown.
|
|
242
|
+
<>
|
|
243
|
+
<DropdownRenderer />
|
|
244
|
+
<Dropdown.Root>
|
|
245
|
+
<Dropdown.Trigger>Open menu</Dropdown.Trigger>
|
|
246
|
+
<Dropdown.Portal>
|
|
207
247
|
<Dropdown.Popup>
|
|
208
|
-
<Dropdown.Item>
|
|
248
|
+
<Dropdown.Item onClick={handleClick}>Action</Dropdown.Item>
|
|
209
249
|
</Dropdown.Popup>
|
|
210
|
-
</Dropdown.
|
|
211
|
-
</Dropdown.
|
|
212
|
-
|
|
250
|
+
</Dropdown.Portal>
|
|
251
|
+
</Dropdown.Root>
|
|
252
|
+
</>,
|
|
213
253
|
);
|
|
214
254
|
|
|
215
|
-
|
|
216
|
-
await user.hover(screen.getByText('Open menu'));
|
|
255
|
+
await user.click(screen.getByText('Open menu'));
|
|
217
256
|
await vi.waitFor(() => {
|
|
218
|
-
expect(screen.getByText('
|
|
257
|
+
expect(screen.getByText('Action')).toBeInTheDocument();
|
|
219
258
|
});
|
|
220
259
|
|
|
221
|
-
|
|
222
|
-
await
|
|
260
|
+
await user.click(screen.getByText('Action'));
|
|
261
|
+
await vi.waitFor(() => {
|
|
262
|
+
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
263
|
+
expect(screen.queryByText('Action')).not.toBeInTheDocument();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe('keyboard', () => {
|
|
269
|
+
it('opens menu on Enter', async () => {
|
|
270
|
+
const user = userEvent.setup();
|
|
271
|
+
renderDropdown();
|
|
272
|
+
|
|
273
|
+
screen.getByText('Open menu').focus();
|
|
274
|
+
await user.keyboard('{Enter}');
|
|
275
|
+
await vi.waitFor(() => {
|
|
276
|
+
expect(screen.getByText('Item one')).toBeInTheDocument();
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('opens menu on Space', async () => {
|
|
281
|
+
const user = userEvent.setup();
|
|
282
|
+
renderDropdown();
|
|
283
|
+
|
|
284
|
+
screen.getByText('Open menu').focus();
|
|
285
|
+
await user.keyboard(' ');
|
|
223
286
|
await vi.waitFor(() => {
|
|
224
287
|
expect(screen.getByText('Item one')).toBeInTheDocument();
|
|
225
288
|
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('opens menu on ArrowDown', async () => {
|
|
292
|
+
const user = userEvent.setup();
|
|
293
|
+
renderDropdown();
|
|
226
294
|
|
|
227
|
-
|
|
295
|
+
screen.getByText('Open menu').focus();
|
|
296
|
+
await user.keyboard('{ArrowDown}');
|
|
228
297
|
await vi.waitFor(() => {
|
|
229
|
-
expect(screen.
|
|
298
|
+
expect(screen.getByText('Item one')).toBeInTheDocument();
|
|
230
299
|
});
|
|
231
300
|
});
|
|
301
|
+
});
|
|
232
302
|
|
|
233
|
-
|
|
303
|
+
describe('render prop', () => {
|
|
304
|
+
it('renders a custom element via render prop (element form)', async () => {
|
|
234
305
|
const user = userEvent.setup();
|
|
235
306
|
render(
|
|
236
|
-
|
|
237
|
-
<
|
|
238
|
-
<Dropdown.
|
|
239
|
-
<Dropdown.
|
|
307
|
+
<>
|
|
308
|
+
<DropdownRenderer />
|
|
309
|
+
<Dropdown.Root>
|
|
310
|
+
<Dropdown.Trigger render={<span data-testid="custom" />}>
|
|
311
|
+
Custom trigger
|
|
312
|
+
</Dropdown.Trigger>
|
|
313
|
+
<Dropdown.Portal>
|
|
240
314
|
<Dropdown.Popup>
|
|
241
|
-
<Dropdown.Item>Item
|
|
315
|
+
<Dropdown.Item>Item</Dropdown.Item>
|
|
242
316
|
</Dropdown.Popup>
|
|
243
|
-
</Dropdown.
|
|
244
|
-
</Dropdown.
|
|
245
|
-
|
|
317
|
+
</Dropdown.Portal>
|
|
318
|
+
</Dropdown.Root>
|
|
319
|
+
</>,
|
|
246
320
|
);
|
|
247
321
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
expect(trigger
|
|
322
|
+
const trigger = screen.getByTestId('custom');
|
|
323
|
+
expect(trigger.tagName).toBe('SPAN');
|
|
324
|
+
expect(trigger).toHaveAttribute('aria-haspopup', 'menu');
|
|
325
|
+
|
|
326
|
+
await user.click(trigger);
|
|
327
|
+
await vi.waitFor(() => {
|
|
328
|
+
expect(screen.getByText('Item')).toBeInTheDocument();
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('renders a custom element via render prop (function form)', async () => {
|
|
333
|
+
const user = userEvent.setup();
|
|
334
|
+
render(
|
|
335
|
+
<>
|
|
336
|
+
<DropdownRenderer />
|
|
337
|
+
<Dropdown.Root>
|
|
338
|
+
<Dropdown.Trigger render={(props) => <div data-testid="fn-trigger" {...props} />}>
|
|
339
|
+
Fn trigger
|
|
340
|
+
</Dropdown.Trigger>
|
|
341
|
+
<Dropdown.Portal>
|
|
342
|
+
<Dropdown.Popup>
|
|
343
|
+
<Dropdown.Item>Item</Dropdown.Item>
|
|
344
|
+
</Dropdown.Popup>
|
|
345
|
+
</Dropdown.Portal>
|
|
346
|
+
</Dropdown.Root>
|
|
347
|
+
</>,
|
|
348
|
+
);
|
|
251
349
|
|
|
252
|
-
|
|
253
|
-
|
|
350
|
+
const trigger = screen.getByTestId('fn-trigger');
|
|
351
|
+
expect(trigger.tagName).toBe('DIV');
|
|
352
|
+
|
|
353
|
+
await user.click(trigger);
|
|
254
354
|
await vi.waitFor(() => {
|
|
255
|
-
expect(screen.getByText('
|
|
355
|
+
expect(screen.getByText('Item')).toBeInTheDocument();
|
|
256
356
|
});
|
|
257
|
-
const buttons = screen.getAllByRole('button');
|
|
258
|
-
expect(buttons).toHaveLength(1);
|
|
259
357
|
});
|
|
260
358
|
});
|
|
261
359
|
|
|
262
360
|
describe('styling', () => {
|
|
263
|
-
it('applies default className to
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
// Popup > Positioner
|
|
267
|
-
const popup = item.parentElement;
|
|
268
|
-
const positioner = popup?.parentElement;
|
|
269
|
-
expect(positioner).toHaveClass('slithy-dropdown-positioner');
|
|
270
|
-
});
|
|
361
|
+
it('applies default className to Popup', async () => {
|
|
362
|
+
const user = userEvent.setup();
|
|
363
|
+
renderDropdown();
|
|
271
364
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
365
|
+
await user.click(screen.getByText('Open menu'));
|
|
366
|
+
await vi.waitFor(() => {
|
|
367
|
+
const item = screen.getByText('Item one');
|
|
368
|
+
const popup = item.parentElement;
|
|
369
|
+
expect(popup).toHaveClass('slithy-dropdown-popup');
|
|
370
|
+
});
|
|
277
371
|
});
|
|
278
372
|
|
|
279
|
-
it('applies default className to Item', () => {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
});
|
|
373
|
+
it('applies default className to Item', async () => {
|
|
374
|
+
const user = userEvent.setup();
|
|
375
|
+
renderDropdown();
|
|
283
376
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
<Dropdown.Portal>
|
|
289
|
-
<Dropdown.Positioner>
|
|
290
|
-
<Dropdown.Popup className="custom-popup">
|
|
291
|
-
<Dropdown.Item>Item</Dropdown.Item>
|
|
292
|
-
</Dropdown.Popup>
|
|
293
|
-
</Dropdown.Positioner>
|
|
294
|
-
</Dropdown.Portal>
|
|
295
|
-
</Dropdown.Root>,
|
|
296
|
-
);
|
|
297
|
-
const item = screen.getByText('Item');
|
|
298
|
-
const popup = item.parentElement;
|
|
299
|
-
expect(popup).toHaveClass('custom-popup');
|
|
300
|
-
expect(popup).not.toHaveClass('slithy-dropdown-popup');
|
|
377
|
+
await user.click(screen.getByText('Open menu'));
|
|
378
|
+
await vi.waitFor(() => {
|
|
379
|
+
expect(screen.getByText('Item one')).toHaveClass('slithy-dropdown-item');
|
|
380
|
+
});
|
|
301
381
|
});
|
|
302
382
|
|
|
303
|
-
it('renders Separator with default className', () => {
|
|
383
|
+
it('renders Separator with default className', async () => {
|
|
384
|
+
const user = userEvent.setup();
|
|
304
385
|
render(
|
|
305
|
-
|
|
306
|
-
<
|
|
307
|
-
<Dropdown.
|
|
308
|
-
<Dropdown.
|
|
386
|
+
<>
|
|
387
|
+
<DropdownRenderer />
|
|
388
|
+
<Dropdown.Root>
|
|
389
|
+
<Dropdown.Trigger>Open menu</Dropdown.Trigger>
|
|
390
|
+
<Dropdown.Portal>
|
|
309
391
|
<Dropdown.Popup>
|
|
310
392
|
<Dropdown.Item>Item</Dropdown.Item>
|
|
311
393
|
<Dropdown.Separator data-testid="sep" />
|
|
312
394
|
<Dropdown.Item>Other</Dropdown.Item>
|
|
313
395
|
</Dropdown.Popup>
|
|
314
|
-
</Dropdown.
|
|
315
|
-
</Dropdown.
|
|
316
|
-
|
|
396
|
+
</Dropdown.Portal>
|
|
397
|
+
</Dropdown.Root>
|
|
398
|
+
</>,
|
|
317
399
|
);
|
|
318
|
-
|
|
400
|
+
|
|
401
|
+
await user.click(screen.getByText('Open menu'));
|
|
402
|
+
await vi.waitFor(() => {
|
|
403
|
+
expect(screen.getByTestId('sep')).toHaveClass('slithy-dropdown-separator');
|
|
404
|
+
});
|
|
319
405
|
});
|
|
320
406
|
|
|
321
|
-
it('renders GroupLabel with default className', () => {
|
|
407
|
+
it('renders GroupLabel with default className', async () => {
|
|
408
|
+
const user = userEvent.setup();
|
|
322
409
|
render(
|
|
323
|
-
|
|
324
|
-
<
|
|
325
|
-
<Dropdown.
|
|
326
|
-
<Dropdown.
|
|
410
|
+
<>
|
|
411
|
+
<DropdownRenderer />
|
|
412
|
+
<Dropdown.Root>
|
|
413
|
+
<Dropdown.Trigger>Open menu</Dropdown.Trigger>
|
|
414
|
+
<Dropdown.Portal>
|
|
327
415
|
<Dropdown.Popup>
|
|
328
416
|
<Dropdown.Group>
|
|
329
417
|
<Dropdown.GroupLabel data-testid="label">
|
|
@@ -332,13 +420,100 @@ describe('Dropdown', () => {
|
|
|
332
420
|
<Dropdown.Item>Item</Dropdown.Item>
|
|
333
421
|
</Dropdown.Group>
|
|
334
422
|
</Dropdown.Popup>
|
|
335
|
-
</Dropdown.
|
|
336
|
-
</Dropdown.
|
|
337
|
-
|
|
423
|
+
</Dropdown.Portal>
|
|
424
|
+
</Dropdown.Root>
|
|
425
|
+
</>,
|
|
338
426
|
);
|
|
339
|
-
|
|
340
|
-
|
|
427
|
+
|
|
428
|
+
await user.click(screen.getByText('Open menu'));
|
|
429
|
+
await vi.waitFor(() => {
|
|
430
|
+
expect(screen.getByTestId('label')).toHaveClass('slithy-dropdown-group-label');
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
describe('tooltip prop', () => {
|
|
436
|
+
beforeEach(() => {
|
|
437
|
+
vi.useFakeTimers();
|
|
438
|
+
// Reset stores — previous tests may have left them open
|
|
439
|
+
const ds = useDropdownStore.getState();
|
|
440
|
+
if (ds.open) ds.closeDropdown();
|
|
441
|
+
const ts = useTooltipStore.getState();
|
|
442
|
+
if (ts.open) ts.closeTooltip({ skipWarmUp: true });
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
afterEach(() => {
|
|
446
|
+
vi.useRealTimers();
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
function renderWithTooltip() {
|
|
450
|
+
return render(
|
|
451
|
+
<>
|
|
452
|
+
<DropdownRenderer />
|
|
453
|
+
<TooltipRenderer />
|
|
454
|
+
<Dropdown.Root>
|
|
455
|
+
<Dropdown.Trigger tooltip="Helpful tip" tooltipDelay={600}>
|
|
456
|
+
Open menu
|
|
457
|
+
</Dropdown.Trigger>
|
|
458
|
+
<Dropdown.Portal>
|
|
459
|
+
<Dropdown.Popup>
|
|
460
|
+
<Dropdown.Item>Item one</Dropdown.Item>
|
|
461
|
+
</Dropdown.Popup>
|
|
462
|
+
</Dropdown.Portal>
|
|
463
|
+
</Dropdown.Root>
|
|
464
|
+
</>,
|
|
341
465
|
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
it('shows tooltip on pointer enter after delay', () => {
|
|
469
|
+
renderWithTooltip();
|
|
470
|
+
const trigger = screen.getByText('Open menu');
|
|
471
|
+
|
|
472
|
+
fireEvent.pointerEnter(trigger);
|
|
473
|
+
expect(screen.queryByText('Helpful tip')).not.toBeInTheDocument();
|
|
474
|
+
|
|
475
|
+
vi.advanceTimersByTime(600);
|
|
476
|
+
expect(useTooltipStore.getState().open).toBe(true);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('hides tooltip on pointer leave', () => {
|
|
480
|
+
renderWithTooltip();
|
|
481
|
+
const trigger = screen.getByText('Open menu');
|
|
482
|
+
|
|
483
|
+
fireEvent.pointerEnter(trigger);
|
|
484
|
+
vi.advanceTimersByTime(600);
|
|
485
|
+
expect(useTooltipStore.getState().open).toBe(true);
|
|
486
|
+
|
|
487
|
+
fireEvent.pointerLeave(trigger);
|
|
488
|
+
vi.advanceTimersByTime(300);
|
|
489
|
+
expect(useTooltipStore.getState().open).toBe(false);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('dismisses tooltip when dropdown opens', () => {
|
|
493
|
+
renderWithTooltip();
|
|
494
|
+
const trigger = screen.getByText('Open menu');
|
|
495
|
+
|
|
496
|
+
// Open tooltip
|
|
497
|
+
fireEvent.pointerEnter(trigger);
|
|
498
|
+
vi.advanceTimersByTime(600);
|
|
499
|
+
expect(useTooltipStore.getState().open).toBe(true);
|
|
500
|
+
|
|
501
|
+
// Click to open dropdown — tooltip should dismiss
|
|
502
|
+
fireEvent.click(trigger);
|
|
503
|
+
expect(useTooltipStore.getState().open).toBe(false);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('does not show tooltip while dropdown is open', () => {
|
|
507
|
+
renderWithTooltip();
|
|
508
|
+
const trigger = screen.getByText('Open menu');
|
|
509
|
+
|
|
510
|
+
// Open dropdown
|
|
511
|
+
fireEvent.click(trigger);
|
|
512
|
+
|
|
513
|
+
// Try to hover — tooltip should not appear
|
|
514
|
+
fireEvent.pointerEnter(trigger);
|
|
515
|
+
vi.advanceTimersByTime(600);
|
|
516
|
+
expect(useTooltipStore.getState().open).toBe(false);
|
|
342
517
|
});
|
|
343
518
|
});
|
|
344
519
|
});
|