@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.
Files changed (55) hide show
  1. package/dist/index.d.ts +823 -135
  2. package/dist/index.js +2048 -358
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +44 -2
  5. package/dist/theme-v2.css +228 -0
  6. package/dist/tokens.css +123 -8
  7. package/package.json +1 -1
  8. package/src/__tests__/anchor.test.tsx +422 -0
  9. package/src/__tests__/combobox.test.tsx +677 -0
  10. package/src/__tests__/dropdown-menu.test.tsx +418 -0
  11. package/src/__tests__/helpers/geometry.ts +58 -0
  12. package/src/__tests__/layer-stack.test.tsx +228 -0
  13. package/src/__tests__/modal.test.tsx +180 -6
  14. package/src/__tests__/popover.test.tsx +460 -0
  15. package/src/__tests__/select.test.tsx +543 -0
  16. package/src/__tests__/tooltip.test.tsx +355 -0
  17. package/src/calculator-shell-v2.tsx +19 -39
  18. package/src/code-block.tsx +15 -26
  19. package/src/combobox.tsx +796 -0
  20. package/src/dropdown-menu.tsx +142 -152
  21. package/src/icons/brand.tsx +81 -2
  22. package/src/index.ts +111 -0
  23. package/src/lib/anchor.ts +427 -0
  24. package/src/lib/focus.ts +32 -0
  25. package/src/lib/layer-stack.ts +188 -0
  26. package/src/lib/refs.ts +31 -0
  27. package/src/metric-card.tsx +57 -22
  28. package/src/modal.tsx +149 -9
  29. package/src/page-shell.tsx +91 -2
  30. package/src/popover.tsx +407 -0
  31. package/src/segmented-pill.tsx +33 -10
  32. package/src/select.tsx +646 -0
  33. package/src/stat-row.tsx +108 -70
  34. package/src/styles.css +44 -2
  35. package/src/theme-v2.css +7 -245
  36. package/src/tokens.css +123 -8
  37. package/src/tooltip.tsx +297 -0
  38. package/src/react-syntax-highlighter-prism.d.ts +0 -34
  39. package/src/v2/README.md +0 -208
  40. package/src/v2/__demo__/showcase.tsx +0 -1045
  41. package/src/v2/action.tsx +0 -91
  42. package/src/v2/callout.tsx +0 -76
  43. package/src/v2/document-section.tsx +0 -82
  44. package/src/v2/document-shell.tsx +0 -0
  45. package/src/v2/field-row.tsx +0 -113
  46. package/src/v2/icons.tsx +0 -165
  47. package/src/v2/index.ts +0 -147
  48. package/src/v2/layout.tsx +0 -293
  49. package/src/v2/progress-track.tsx +0 -89
  50. package/src/v2/stat-tile.tsx +0 -129
  51. package/src/v2/states.tsx +0 -271
  52. package/src/v2/status-pill.tsx +0 -74
  53. package/src/v2/theme.css +0 -1861
  54. package/src/v2/timeline.tsx +0 -81
  55. 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
+ })