@lovett/ui 0.0.10 → 0.0.11

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.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * lib/layer-stack — the topmost-wins dismiss order shared by Modal,
3
+ * DropdownMenu and Popover.
4
+ *
5
+ * The load-bearing test is the Modal one: a layer registered AFTER a Modal
6
+ * opens takes Escape away from it. Revert Modal to a private `openPanels`
7
+ * array and both fire — that test goes red.
8
+ */
9
+ import { useEffect } from 'react'
10
+ import { describe, expect, it, vi } from 'vitest'
11
+ import { render } from '@testing-library/react'
12
+ import userEvent from '@testing-library/user-event'
13
+ import Modal from '../modal'
14
+ import { useEscapeKey, useLayer, type LayerHandle, type LayerKind } from '../lib/layer-stack'
15
+
16
+ function EscapeListener({
17
+ onEscape,
18
+ enabled = true,
19
+ }: {
20
+ onEscape: () => void
21
+ enabled?: boolean
22
+ }) {
23
+ useEscapeKey(onEscape, { enabled })
24
+ return null
25
+ }
26
+
27
+ function LayerProbe({
28
+ kind,
29
+ onHandle,
30
+ }: {
31
+ kind: LayerKind
32
+ onHandle: (handle: LayerHandle) => void
33
+ }) {
34
+ const handle = useLayer({ enabled: true, kind, onEscape: () => {} })
35
+ useEffect(() => {
36
+ onHandle(handle)
37
+ }, [handle, onHandle])
38
+ return null
39
+ }
40
+
41
+ describe('useEscapeKey / useLayer', () => {
42
+ it('dispatches Escape to the most recently registered layer only', async () => {
43
+ const first = vi.fn()
44
+ const second = vi.fn()
45
+ render(
46
+ <>
47
+ <EscapeListener onEscape={first} />
48
+ <EscapeListener onEscape={second} />
49
+ </>,
50
+ )
51
+ await userEvent.keyboard('{Escape}')
52
+ expect(second).toHaveBeenCalledTimes(1)
53
+ expect(first).not.toHaveBeenCalled()
54
+ })
55
+
56
+ it('falls back to the layer below when the top one unregisters', async () => {
57
+ const first = vi.fn()
58
+ const second = vi.fn()
59
+ const { rerender } = render(
60
+ <>
61
+ <EscapeListener onEscape={first} />
62
+ <EscapeListener onEscape={second} />
63
+ </>,
64
+ )
65
+ rerender(
66
+ <>
67
+ <EscapeListener onEscape={first} />
68
+ <EscapeListener onEscape={second} enabled={false} />
69
+ </>,
70
+ )
71
+ await userEvent.keyboard('{Escape}')
72
+ expect(first).toHaveBeenCalledTimes(1)
73
+ expect(second).not.toHaveBeenCalled()
74
+ })
75
+
76
+ it('does nothing once every layer has unregistered', async () => {
77
+ const handler = vi.fn()
78
+ const { unmount } = render(<EscapeListener onEscape={handler} />)
79
+ unmount()
80
+ await userEvent.keyboard('{Escape}')
81
+ expect(handler).not.toHaveBeenCalled()
82
+ })
83
+
84
+ it('ignores keys other than Escape', async () => {
85
+ const handler = vi.fn()
86
+ render(<EscapeListener onEscape={handler} />)
87
+ await userEvent.keyboard('{Enter}a{Tab}')
88
+ expect(handler).not.toHaveBeenCalled()
89
+ })
90
+
91
+ it('stops propagation so window-level listeners do not see a consumed Escape', async () => {
92
+ const windowSpy = vi.fn()
93
+ window.addEventListener('keydown', windowSpy)
94
+ try {
95
+ const handler = vi.fn()
96
+ const { unmount } = render(<EscapeListener onEscape={handler} />)
97
+ await userEvent.keyboard('{Escape}')
98
+ expect(handler).toHaveBeenCalledTimes(1)
99
+ expect(windowSpy).not.toHaveBeenCalled()
100
+
101
+ // With no layer open the key reaches window as before.
102
+ unmount()
103
+ await userEvent.keyboard('{Escape}')
104
+ expect(windowSpy).toHaveBeenCalledTimes(1)
105
+ } finally {
106
+ window.removeEventListener('keydown', windowSpy)
107
+ }
108
+ })
109
+
110
+ it('reads the latest handler without re-registering (order is preserved)', async () => {
111
+ const stale = vi.fn()
112
+ const fresh = vi.fn()
113
+ const above = vi.fn()
114
+ const { rerender } = render(
115
+ <>
116
+ <EscapeListener onEscape={stale} />
117
+ <EscapeListener onEscape={above} />
118
+ </>,
119
+ )
120
+ // Swapping the LOWER layer's handler must not move it to the top.
121
+ rerender(
122
+ <>
123
+ <EscapeListener onEscape={fresh} />
124
+ <EscapeListener onEscape={above} />
125
+ </>,
126
+ )
127
+ await userEvent.keyboard('{Escape}')
128
+ expect(above).toHaveBeenCalledTimes(1)
129
+ expect(stale).not.toHaveBeenCalled()
130
+ expect(fresh).not.toHaveBeenCalled()
131
+
132
+ rerender(
133
+ <>
134
+ <EscapeListener onEscape={fresh} />
135
+ <EscapeListener onEscape={above} enabled={false} />
136
+ </>,
137
+ )
138
+ await userEvent.keyboard('{Escape}')
139
+ expect(fresh).toHaveBeenCalledTimes(1)
140
+ expect(stale).not.toHaveBeenCalled()
141
+ })
142
+
143
+ it('isTop / isTopOfKind: a popover above a modal is top, but not top-of-kind for the modal', () => {
144
+ let modal: LayerHandle | undefined
145
+ let popover: LayerHandle | undefined
146
+ render(
147
+ <>
148
+ <LayerProbe kind="modal" onHandle={(h) => (modal = h)} />
149
+ <LayerProbe kind="popover" onHandle={(h) => (popover = h)} />
150
+ </>,
151
+ )
152
+ expect(popover?.isTop()).toBe(true)
153
+ expect(popover?.isTopOfKind()).toBe(true)
154
+ expect(modal?.isTop()).toBe(false)
155
+ expect(modal?.isTopOfKind()).toBe(true)
156
+ })
157
+
158
+ it('a second modal above the first takes both isTop and isTopOfKind', () => {
159
+ let lower: LayerHandle | undefined
160
+ let upper: LayerHandle | undefined
161
+ render(
162
+ <>
163
+ <LayerProbe kind="modal" onHandle={(h) => (lower = h)} />
164
+ <LayerProbe kind="modal" onHandle={(h) => (upper = h)} />
165
+ </>,
166
+ )
167
+ expect(upper?.isTop()).toBe(true)
168
+ expect(lower?.isTopOfKind()).toBe(false)
169
+ })
170
+
171
+ it('an unregistered handle answers false everywhere', () => {
172
+ let handle: LayerHandle | undefined
173
+ function Disabled() {
174
+ handle = useLayer({ enabled: false, kind: 'popover', onEscape: () => {} })
175
+ return null
176
+ }
177
+ render(<Disabled />)
178
+ expect(handle?.isTop()).toBe(false)
179
+ expect(handle?.isTopOfKind()).toBe(false)
180
+ expect(handle?.containsInLayerAbove(document.body)).toBe(false)
181
+ })
182
+ })
183
+
184
+ describe('shared with Modal', () => {
185
+ it('a layer opened above a Modal takes Escape; the Modal gets the next one', async () => {
186
+ const onClose = vi.fn()
187
+ const onEscape = vi.fn()
188
+ const { rerender } = render(
189
+ <>
190
+ <Modal isOpen onClose={onClose} title="Confirm">
191
+ body
192
+ </Modal>
193
+ <EscapeListener onEscape={onEscape} />
194
+ </>,
195
+ )
196
+ await userEvent.keyboard('{Escape}')
197
+ expect(onEscape).toHaveBeenCalledTimes(1)
198
+ expect(onClose).not.toHaveBeenCalled()
199
+
200
+ rerender(
201
+ <>
202
+ <Modal isOpen onClose={onClose} title="Confirm">
203
+ body
204
+ </Modal>
205
+ <EscapeListener onEscape={onEscape} enabled={false} />
206
+ </>,
207
+ )
208
+ await userEvent.keyboard('{Escape}')
209
+ expect(onClose).toHaveBeenCalledTimes(1)
210
+ expect(onEscape).toHaveBeenCalledTimes(1)
211
+ })
212
+
213
+ it('a Modal opened above an existing layer takes Escape from it', async () => {
214
+ const onClose = vi.fn()
215
+ const onEscape = vi.fn()
216
+ render(
217
+ <>
218
+ <EscapeListener onEscape={onEscape} />
219
+ <Modal isOpen onClose={onClose} title="Confirm">
220
+ body
221
+ </Modal>
222
+ </>,
223
+ )
224
+ await userEvent.keyboard('{Escape}')
225
+ expect(onClose).toHaveBeenCalledTimes(1)
226
+ expect(onEscape).not.toHaveBeenCalled()
227
+ })
228
+ })
@@ -0,0 +1,460 @@
1
+ /**
2
+ * Popover — anchored non-modal surface.
3
+ *
4
+ * Contract under test: open / close (controlled and not), portal to body,
5
+ * dialog role (or none), focus into the content on open and back to the
6
+ * trigger on close — but NOT after an outside click — Escape closes the
7
+ * topmost layer only (a Popover inside a Modal), outside-click closes
8
+ * unless the click landed in a layer stacked above (a nested Popover), and
9
+ * `asChild` makes the child element the trigger.
10
+ */
11
+ import { useState } from 'react'
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
13
+ import { render, screen } from '@testing-library/react'
14
+ import userEvent from '@testing-library/user-event'
15
+ import Modal from '../modal'
16
+ import { Popover, type PopoverContentProps } from '../popover'
17
+ import { mockGeometry, setViewport } from './helpers/geometry'
18
+
19
+ function Basic({
20
+ content = <p>popover body</p>,
21
+ contentProps,
22
+ defaultOpen,
23
+ }: {
24
+ content?: React.ReactNode
25
+ contentProps?: Partial<PopoverContentProps>
26
+ defaultOpen?: boolean
27
+ }) {
28
+ return (
29
+ <Popover {...(defaultOpen ? { defaultOpen } : {})}>
30
+ <Popover.Trigger>Open</Popover.Trigger>
31
+ <Popover.Content aria-label="Details" {...contentProps}>
32
+ {content}
33
+ </Popover.Content>
34
+ </Popover>
35
+ )
36
+ }
37
+
38
+ const trigger = () => screen.getByRole('button', { name: 'Open' })
39
+
40
+ describe('Popover', () => {
41
+ describe('open / close', () => {
42
+ it('renders the trigger closed with the dialog affordance', () => {
43
+ render(<Basic />)
44
+ const t = trigger()
45
+ expect(t).toHaveAttribute('aria-haspopup', 'dialog')
46
+ expect(t).toHaveAttribute('aria-expanded', 'false')
47
+ expect(t).not.toHaveAttribute('aria-controls')
48
+ expect(t).toHaveAttribute('data-state', 'closed')
49
+ expect(t).toHaveAttribute('type', 'button')
50
+ expect(screen.queryByRole('dialog')).toBeNull()
51
+ })
52
+
53
+ it('opens on click and wires aria-controls to the content id', async () => {
54
+ render(<Basic />)
55
+ await userEvent.click(trigger())
56
+ const dialog = screen.getByRole('dialog', { name: 'Details' })
57
+ expect(trigger()).toHaveAttribute('aria-expanded', 'true')
58
+ expect(trigger()).toHaveAttribute('aria-controls', dialog.id)
59
+ expect(trigger()).toHaveAttribute('data-state', 'open')
60
+ expect(dialog).toHaveAttribute('data-slot', 'popover-content')
61
+ expect(dialog).toHaveAttribute('data-state', 'open')
62
+ expect(dialog).not.toHaveAttribute('aria-modal')
63
+ })
64
+
65
+ it('toggles closed on a second trigger click', async () => {
66
+ render(<Basic />)
67
+ await userEvent.click(trigger())
68
+ await userEvent.click(trigger())
69
+ expect(screen.queryByRole('dialog')).toBeNull()
70
+ })
71
+
72
+ it('portals the content to document.body, outside the render container', async () => {
73
+ const { container } = render(<Basic />)
74
+ await userEvent.click(trigger())
75
+ const dialog = screen.getByRole('dialog')
76
+ expect(container.contains(dialog)).toBe(false)
77
+ expect(dialog.parentElement).toBe(document.body)
78
+ })
79
+
80
+ it('respects defaultOpen', () => {
81
+ render(<Basic defaultOpen />)
82
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
83
+ })
84
+
85
+ it('works controlled: open + onOpenChange', async () => {
86
+ function Controlled() {
87
+ const [open, setOpen] = useState(false)
88
+ return (
89
+ <>
90
+ <span data-testid="state">{open ? 'open' : 'closed'}</span>
91
+ <Popover open={open} onOpenChange={setOpen}>
92
+ <Popover.Trigger>Open</Popover.Trigger>
93
+ <Popover.Content aria-label="Details">body</Popover.Content>
94
+ </Popover>
95
+ </>
96
+ )
97
+ }
98
+ render(<Controlled />)
99
+ await userEvent.click(trigger())
100
+ expect(screen.getByTestId('state')).toHaveTextContent('open')
101
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
102
+ await userEvent.keyboard('{Escape}')
103
+ expect(screen.getByTestId('state')).toHaveTextContent('closed')
104
+ expect(screen.queryByRole('dialog')).toBeNull()
105
+ })
106
+
107
+ it('does not change internal state when controlled and the parent ignores onOpenChange', async () => {
108
+ const onOpenChange = vi.fn()
109
+ render(
110
+ <Popover open={false} onOpenChange={onOpenChange}>
111
+ <Popover.Trigger>Open</Popover.Trigger>
112
+ <Popover.Content aria-label="Details">body</Popover.Content>
113
+ </Popover>,
114
+ )
115
+ await userEvent.click(trigger())
116
+ expect(onOpenChange).toHaveBeenCalledWith(true)
117
+ expect(screen.queryByRole('dialog')).toBeNull()
118
+ })
119
+
120
+ it('throws when a part is rendered outside the root', () => {
121
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
122
+ expect(() => render(<Popover.Trigger>Open</Popover.Trigger>)).toThrow(/inside <Popover>/)
123
+ spy.mockRestore()
124
+ })
125
+ })
126
+
127
+ describe('roles', () => {
128
+ it('is a dialog by default', async () => {
129
+ render(<Basic />)
130
+ await userEvent.click(trigger())
131
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
132
+ })
133
+
134
+ it('renders no role attribute for role="none"', async () => {
135
+ render(<Basic contentProps={{ role: 'none' }} />)
136
+ await userEvent.click(trigger())
137
+ const content = document.querySelector('[data-slot="popover-content"]')
138
+ expect(content).not.toBeNull()
139
+ expect(content).not.toHaveAttribute('role')
140
+ expect(screen.queryByRole('dialog')).toBeNull()
141
+ })
142
+ })
143
+
144
+ describe('focus', () => {
145
+ it('moves focus to a [data-autofocus] control on open', async () => {
146
+ render(<Basic content={<input data-autofocus aria-label="Search" />} />)
147
+ await userEvent.click(trigger())
148
+ expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Search' }))
149
+ })
150
+
151
+ it('falls back to the first tabbable control', async () => {
152
+ render(
153
+ <Basic
154
+ content={
155
+ <>
156
+ <button type="button">First</button>
157
+ <button type="button">Second</button>
158
+ </>
159
+ }
160
+ />,
161
+ )
162
+ await userEvent.click(trigger())
163
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'First' }))
164
+ })
165
+
166
+ it('falls back to the content itself when nothing inside is tabbable', async () => {
167
+ render(<Basic />)
168
+ await userEvent.click(trigger())
169
+ const dialog = screen.getByRole('dialog')
170
+ expect(dialog).toHaveAttribute('tabindex', '-1')
171
+ expect(document.activeElement).toBe(dialog)
172
+ })
173
+
174
+ it('focuses only once the content is positioned - the parked, visibility:hidden frame cannot take focus in a browser', async () => {
175
+ // jsdom honours focus() on a hidden node, so the assertion is on the
176
+ // content's state AT the moment focus lands, not on where focus ended.
177
+ const atFocus: Array<{ positioned: string | null; visibility: string }> = []
178
+ render(
179
+ <Basic
180
+ content={
181
+ <input
182
+ data-autofocus
183
+ aria-label="Search"
184
+ onFocus={(event) => {
185
+ const content = event.currentTarget.closest<HTMLElement>(
186
+ '[data-slot="popover-content"]',
187
+ )
188
+ atFocus.push({
189
+ positioned: content?.getAttribute('data-positioned') ?? null,
190
+ visibility: content?.style.visibility ?? '',
191
+ })
192
+ }}
193
+ />
194
+ }
195
+ />,
196
+ )
197
+ await userEvent.click(trigger())
198
+ expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Search' }))
199
+ expect(atFocus).toEqual([{ positioned: 'true', visibility: '' }])
200
+ })
201
+
202
+ it('the content fallback is focused positioned too, and still returns focus to the trigger on Escape', async () => {
203
+ const atFocus: string[] = []
204
+ const onFocus = (event: FocusEvent) => {
205
+ const target = event.target
206
+ if (target instanceof HTMLElement && target.dataset.slot === 'popover-content') {
207
+ atFocus.push(target.getAttribute('data-positioned') ?? 'missing')
208
+ }
209
+ }
210
+ document.addEventListener('focus', onFocus, true)
211
+ try {
212
+ render(<Basic />)
213
+ await userEvent.click(trigger())
214
+ expect(document.activeElement).toBe(screen.getByRole('dialog'))
215
+ expect(atFocus).toEqual(['true'])
216
+ await userEvent.keyboard('{Escape}')
217
+ expect(document.activeElement).toBe(trigger())
218
+ } finally {
219
+ document.removeEventListener('focus', onFocus, true)
220
+ }
221
+ })
222
+
223
+ it('returns focus to the trigger on Escape', async () => {
224
+ render(<Basic content={<input data-autofocus aria-label="Search" />} />)
225
+ await userEvent.click(trigger())
226
+ expect(document.activeElement).not.toBe(trigger())
227
+ await userEvent.keyboard('{Escape}')
228
+ expect(screen.queryByRole('dialog')).toBeNull()
229
+ expect(document.activeElement).toBe(trigger())
230
+ })
231
+
232
+ it('returns focus to the trigger when closed programmatically', async () => {
233
+ function Programmatic() {
234
+ const [open, setOpen] = useState(false)
235
+ return (
236
+ <Popover open={open} onOpenChange={setOpen}>
237
+ <Popover.Trigger>Open</Popover.Trigger>
238
+ <Popover.Content aria-label="Details">
239
+ <button type="button" onClick={() => setOpen(false)}>
240
+ Done
241
+ </button>
242
+ </Popover.Content>
243
+ </Popover>
244
+ )
245
+ }
246
+ render(<Programmatic />)
247
+ await userEvent.click(trigger())
248
+ await userEvent.click(screen.getByRole('button', { name: 'Done' }))
249
+ expect(screen.queryByRole('dialog')).toBeNull()
250
+ expect(document.activeElement).toBe(trigger())
251
+ })
252
+
253
+ it('does NOT pull focus back to the trigger after an outside click', async () => {
254
+ render(
255
+ <>
256
+ <Basic />
257
+ <input aria-label="Elsewhere" />
258
+ </>,
259
+ )
260
+ await userEvent.click(trigger())
261
+ const elsewhere = screen.getByRole('textbox', { name: 'Elsewhere' })
262
+ await userEvent.click(elsewhere)
263
+ expect(screen.queryByRole('dialog')).toBeNull()
264
+ expect(document.activeElement).toBe(elsewhere)
265
+ })
266
+ })
267
+
268
+ describe('dismissal', () => {
269
+ it('closes on Escape', async () => {
270
+ render(<Basic />)
271
+ await userEvent.click(trigger())
272
+ await userEvent.keyboard('{Escape}')
273
+ expect(screen.queryByRole('dialog')).toBeNull()
274
+ })
275
+
276
+ it('closes on an outside click', async () => {
277
+ render(
278
+ <>
279
+ <Basic />
280
+ <button type="button">Elsewhere</button>
281
+ </>,
282
+ )
283
+ await userEvent.click(trigger())
284
+ await userEvent.click(screen.getByRole('button', { name: 'Elsewhere' }))
285
+ expect(screen.queryByRole('dialog')).toBeNull()
286
+ })
287
+
288
+ it('stays open on a click inside the content', async () => {
289
+ render(<Basic content={<button type="button">Inside</button>} />)
290
+ await userEvent.click(trigger())
291
+ await userEvent.click(screen.getByRole('button', { name: 'Inside' }))
292
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
293
+ })
294
+
295
+ it('inside a Modal, Escape closes only the popover; the next Escape closes the modal', async () => {
296
+ const onClose = vi.fn()
297
+ render(
298
+ <Modal isOpen onClose={onClose} title="Settings">
299
+ <Basic />
300
+ </Modal>,
301
+ )
302
+ await userEvent.click(trigger())
303
+ expect(screen.getByRole('dialog', { name: 'Details' })).toBeInTheDocument()
304
+
305
+ await userEvent.keyboard('{Escape}')
306
+ expect(screen.queryByRole('dialog', { name: 'Details' })).toBeNull()
307
+ expect(onClose).not.toHaveBeenCalled()
308
+ expect(screen.getByRole('dialog', { name: 'Settings' })).toBeInTheDocument()
309
+
310
+ await userEvent.keyboard('{Escape}')
311
+ expect(onClose).toHaveBeenCalledTimes(1)
312
+ })
313
+
314
+ it('a click inside a nested popover does not close the outer one', async () => {
315
+ render(
316
+ <Popover>
317
+ <Popover.Trigger>Open</Popover.Trigger>
318
+ <Popover.Content aria-label="Outer">
319
+ <Popover>
320
+ <Popover.Trigger>Inner trigger</Popover.Trigger>
321
+ <Popover.Content aria-label="Inner">
322
+ <button type="button">Inner action</button>
323
+ </Popover.Content>
324
+ </Popover>
325
+ </Popover.Content>
326
+ </Popover>,
327
+ )
328
+ await userEvent.click(trigger())
329
+ await userEvent.click(screen.getByRole('button', { name: 'Inner trigger' }))
330
+ expect(screen.getByRole('dialog', { name: 'Inner' })).toBeInTheDocument()
331
+
332
+ await userEvent.click(screen.getByRole('button', { name: 'Inner action' }))
333
+ expect(screen.getByRole('dialog', { name: 'Inner' })).toBeInTheDocument()
334
+ expect(screen.getByRole('dialog', { name: 'Outer' })).toBeInTheDocument()
335
+
336
+ // Escape peels one layer at a time.
337
+ await userEvent.keyboard('{Escape}')
338
+ expect(screen.queryByRole('dialog', { name: 'Inner' })).toBeNull()
339
+ expect(screen.getByRole('dialog', { name: 'Outer' })).toBeInTheDocument()
340
+ })
341
+ })
342
+
343
+ describe('asChild trigger', () => {
344
+ it('makes the child element the trigger — no wrapping <button>', async () => {
345
+ render(
346
+ <Popover>
347
+ <Popover.Trigger asChild>
348
+ <span role="button" tabIndex={0}>
349
+ Open
350
+ </span>
351
+ </Popover.Trigger>
352
+ <Popover.Content aria-label="Details">body</Popover.Content>
353
+ </Popover>,
354
+ )
355
+ const t = trigger()
356
+ expect(t.tagName).toBe('SPAN')
357
+ expect(t).toHaveAttribute('aria-haspopup', 'dialog')
358
+ expect(t).toHaveAttribute('aria-expanded', 'false')
359
+ expect(document.querySelectorAll('button')).toHaveLength(0)
360
+ await userEvent.click(t)
361
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
362
+ expect(t).toHaveAttribute('aria-expanded', 'true')
363
+ })
364
+
365
+ it('calls the child onClick and the trigger onClick, and honours preventDefault', async () => {
366
+ const childClick = vi.fn()
367
+ const triggerClick = vi.fn((e: React.MouseEvent) => e.preventDefault())
368
+ render(
369
+ <Popover>
370
+ <Popover.Trigger asChild onClick={triggerClick}>
371
+ <button type="button" onClick={childClick}>
372
+ Open
373
+ </button>
374
+ </Popover.Trigger>
375
+ <Popover.Content aria-label="Details">body</Popover.Content>
376
+ </Popover>,
377
+ )
378
+ await userEvent.click(trigger())
379
+ expect(childClick).toHaveBeenCalledTimes(1)
380
+ expect(triggerClick).toHaveBeenCalledTimes(1)
381
+ expect(screen.queryByRole('dialog')).toBeNull()
382
+ })
383
+ })
384
+
385
+ describe('surface', () => {
386
+ it('spreads className and style onto the content, caller style last', async () => {
387
+ render(<Basic contentProps={{ className: 'w-64', style: { width: '20rem' } }} />)
388
+ await userEvent.click(trigger())
389
+ const dialog = screen.getByRole('dialog')
390
+ expect(dialog).toHaveClass('w-64')
391
+ expect(dialog.style.width).toBe('20rem')
392
+ expect(dialog.style.background).toContain('--popover')
393
+ })
394
+
395
+ it('unstyled drops the surface chrome but keeps positioning', async () => {
396
+ render(<Basic contentProps={{ unstyled: true }} />)
397
+ await userEvent.click(trigger())
398
+ const dialog = screen.getByRole('dialog')
399
+ expect(dialog.style.background).toBe('')
400
+ expect(dialog.style.position).toBe('fixed')
401
+ })
402
+ })
403
+
404
+ describe('positioning (mocked geometry)', () => {
405
+ let restore: () => void
406
+ beforeEach(() => {
407
+ restore = mockGeometry()
408
+ setViewport(1000, 800)
409
+ })
410
+ afterEach(() => restore())
411
+
412
+ it('positions under the trigger and animates in once positioned', async () => {
413
+ render(
414
+ <Popover>
415
+ <Popover.Trigger data-rect="400,300,80,32">Open</Popover.Trigger>
416
+ <Popover.Content aria-label="Details" data-size="200,120">
417
+ body
418
+ </Popover.Content>
419
+ </Popover>,
420
+ )
421
+ await userEvent.click(trigger())
422
+ const dialog = screen.getByRole('dialog')
423
+ expect(dialog.style.left).toBe('400px')
424
+ expect(dialog.style.top).toBe('338px')
425
+ expect(dialog).toHaveAttribute('data-positioned', 'true')
426
+ expect(dialog).toHaveAttribute('data-side', 'bottom')
427
+ expect(dialog).toHaveClass('ds-enter-pop')
428
+ })
429
+
430
+ it('flips above the trigger at the bottom of the viewport', async () => {
431
+ render(
432
+ <Popover>
433
+ <Popover.Trigger data-rect="400,700,80,32">Open</Popover.Trigger>
434
+ <Popover.Content aria-label="Details" data-size="200,120" align="end">
435
+ body
436
+ </Popover.Content>
437
+ </Popover>,
438
+ )
439
+ await userEvent.click(trigger())
440
+ const dialog = screen.getByRole('dialog')
441
+ expect(dialog).toHaveAttribute('data-side', 'top')
442
+ expect(dialog).toHaveAttribute('data-align', 'end')
443
+ expect(dialog.style.top).toBe(`${700 - 120 - 6}px`)
444
+ expect(dialog.style.left).toBe(`${480 - 200}px`)
445
+ })
446
+
447
+ it('positions against a virtual anchor rect with no trigger at all', () => {
448
+ render(
449
+ <Popover open anchorRect={{ left: 300, top: 200, width: 0, height: 0 }}>
450
+ <Popover.Content aria-label="Context" data-size="200,120" offset={0}>
451
+ body
452
+ </Popover.Content>
453
+ </Popover>,
454
+ )
455
+ const dialog = screen.getByRole('dialog')
456
+ expect(dialog.style.left).toBe('300px')
457
+ expect(dialog.style.top).toBe('200px')
458
+ })
459
+ })
460
+ })