@lovett/ui 0.0.9 → 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.
- package/dist/index.d.ts +823 -135
- package/dist/index.js +2048 -358
- package/dist/index.js.map +1 -1
- package/dist/styles.css +44 -2
- package/dist/theme-v2.css +228 -0
- package/dist/tokens.css +123 -8
- package/package.json +1 -1
- package/src/__tests__/anchor.test.tsx +422 -0
- package/src/__tests__/combobox.test.tsx +677 -0
- package/src/__tests__/dropdown-menu.test.tsx +418 -0
- package/src/__tests__/helpers/geometry.ts +58 -0
- package/src/__tests__/layer-stack.test.tsx +228 -0
- package/src/__tests__/modal.test.tsx +180 -6
- package/src/__tests__/popover.test.tsx +460 -0
- package/src/__tests__/select.test.tsx +543 -0
- package/src/__tests__/tooltip.test.tsx +355 -0
- package/src/calculator-shell-v2.tsx +19 -39
- package/src/code-block.tsx +15 -26
- package/src/combobox.tsx +796 -0
- package/src/dropdown-menu.tsx +142 -152
- package/src/icons/brand.tsx +81 -2
- package/src/index.ts +111 -0
- package/src/lib/anchor.ts +427 -0
- package/src/lib/focus.ts +32 -0
- package/src/lib/layer-stack.ts +188 -0
- package/src/lib/refs.ts +31 -0
- package/src/metric-card.tsx +57 -22
- package/src/modal.tsx +149 -9
- package/src/page-shell.tsx +91 -2
- package/src/popover.tsx +407 -0
- package/src/segmented-pill.tsx +33 -10
- package/src/select.tsx +646 -0
- package/src/stat-row.tsx +108 -70
- package/src/styles.css +44 -2
- package/src/theme-v2.css +7 -245
- package/src/tokens.css +123 -8
- package/src/tooltip.tsx +297 -0
- package/src/react-syntax-highlighter-prism.d.ts +0 -34
- package/src/v2/README.md +0 -208
- package/src/v2/__demo__/showcase.tsx +0 -1045
- package/src/v2/action.tsx +0 -91
- package/src/v2/callout.tsx +0 -76
- package/src/v2/document-section.tsx +0 -82
- package/src/v2/document-shell.tsx +0 -0
- package/src/v2/field-row.tsx +0 -113
- package/src/v2/icons.tsx +0 -165
- package/src/v2/index.ts +0 -147
- package/src/v2/layout.tsx +0 -293
- package/src/v2/progress-track.tsx +0 -89
- package/src/v2/stat-tile.tsx +0 -129
- package/src/v2/states.tsx +0 -271
- package/src/v2/status-pill.tsx +0 -74
- package/src/v2/theme.css +0 -1861
- package/src/v2/timeline.tsx +0 -81
- package/src/v2/tokens.ts +0 -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
|
+
})
|