@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,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
+ })
@@ -1,13 +1,17 @@
1
1
  /**
2
2
  * Modal — 51 consuming files plus 113 sub-component call sites.
3
3
  *
4
- * Note what these tests deliberately do NOT claim: Modal has no
5
- * role="dialog", no aria-modal, no focus trap and no focus restore, so
6
- * there is no dialog role to query by. That is a real accessibility gap
7
- * (a keyboard user can Tab through the page behind the backdrop) and it
8
- * is tracked separately these tests pin the behaviour that exists so
9
- * the a11y work can be verified as a change, not guessed at.
4
+ * The panel is a role="dialog" aria-modal="true" region: named by its own
5
+ * <h2> (or the `ariaLabel` prop when titleless), focused on open, focus
6
+ * RESTORED to the opener on close, and Tab/Shift+Tab wrapped inside it.
7
+ * The "a11y gap" block that used to stand here no dialog role, no trap,
8
+ * no restoreis closed, and the describe() below is what closes it.
9
+ *
10
+ * Background inertness is by focus trap, NOT the `inert` attribute: Modal
11
+ * renders inline inside #root, so inerting the app root would inert the
12
+ * modal too. See the note in modal.tsx.
10
13
  */
14
+ import { useState } from 'react'
11
15
  import { describe, expect, it, vi } from 'vitest'
12
16
  import { render, screen } from '@testing-library/react'
13
17
  import userEvent from '@testing-library/user-event'
@@ -142,6 +146,176 @@ describe('Modal', () => {
142
146
  })
143
147
  })
144
148
 
149
+ describe('accessibility', () => {
150
+ it('exposes the panel as a modal dialog named by its title', () => {
151
+ render(
152
+ <Modal isOpen onClose={noop} title="Confirm">
153
+ body
154
+ </Modal>,
155
+ )
156
+ const dialog = screen.getByRole('dialog', { name: 'Confirm' })
157
+ expect(dialog).toHaveAttribute('aria-modal', 'true')
158
+ expect(dialog).toHaveClass('ds-card-surface')
159
+ })
160
+
161
+ it('gives each open modal its own title id', () => {
162
+ render(
163
+ <>
164
+ <Modal isOpen onClose={noop} title="First">
165
+ a
166
+ </Modal>
167
+ <Modal isOpen onClose={noop} title="Second">
168
+ b
169
+ </Modal>
170
+ </>,
171
+ )
172
+ const [first, second] = screen.getAllByRole('dialog')
173
+ expect(first!.getAttribute('aria-labelledby')).not.toBe(
174
+ second!.getAttribute('aria-labelledby'),
175
+ )
176
+ expect(screen.getByRole('dialog', { name: 'First' })).toBeInTheDocument()
177
+ expect(screen.getByRole('dialog', { name: 'Second' })).toBeInTheDocument()
178
+ })
179
+
180
+ it('falls back to ariaLabel when there is no title', () => {
181
+ render(
182
+ <Modal isOpen onClose={noop} title="" ariaLabel="Asset details">
183
+ body
184
+ </Modal>,
185
+ )
186
+ const dialog = screen.getByRole('dialog', { name: 'Asset details' })
187
+ expect(dialog).not.toHaveAttribute('aria-labelledby')
188
+ })
189
+
190
+ it('moves focus to the panel on open', () => {
191
+ render(
192
+ <Modal isOpen onClose={noop} title="Confirm">
193
+ <button type="button">Inside</button>
194
+ </Modal>,
195
+ )
196
+ expect(document.activeElement).toBe(screen.getByRole('dialog'))
197
+ })
198
+
199
+ it('honours a [data-autofocus] control', () => {
200
+ render(
201
+ <Modal isOpen onClose={noop} title="Confirm">
202
+ <input data-autofocus aria-label="Name" />
203
+ </Modal>,
204
+ )
205
+ expect(document.activeElement).toBe(screen.getByRole('textbox'))
206
+ })
207
+
208
+ it('restores focus to the opener on close', async () => {
209
+ function Harness() {
210
+ const [open, setOpen] = useState(false)
211
+ return (
212
+ <>
213
+ <button type="button" onClick={() => setOpen(true)}>
214
+ Open
215
+ </button>
216
+ <Modal isOpen={open} onClose={() => setOpen(false)} title="Confirm">
217
+ body
218
+ </Modal>
219
+ </>
220
+ )
221
+ }
222
+ render(<Harness />)
223
+ const opener = screen.getByRole('button', { name: 'Open' })
224
+ await userEvent.click(opener)
225
+ expect(document.activeElement).toBe(screen.getByRole('dialog'))
226
+
227
+ await userEvent.click(screen.getByRole('button', { name: 'Close' }))
228
+ expect(screen.queryByRole('dialog')).toBeNull()
229
+ expect(document.activeElement).toBe(opener)
230
+ })
231
+
232
+ it('restores focus when the modal unmounts outright', async () => {
233
+ const opener = document.createElement('button')
234
+ document.body.appendChild(opener)
235
+ opener.focus()
236
+ const { unmount } = render(
237
+ <Modal isOpen onClose={noop} title="Confirm">
238
+ body
239
+ </Modal>,
240
+ )
241
+ expect(document.activeElement).not.toBe(opener)
242
+ unmount()
243
+ expect(document.activeElement).toBe(opener)
244
+ opener.remove()
245
+ })
246
+
247
+ it('wraps Tab from the last control back to the first', async () => {
248
+ render(
249
+ <Modal isOpen onClose={noop} title="Confirm">
250
+ <button type="button">Last</button>
251
+ </Modal>,
252
+ )
253
+ const close = screen.getByRole('button', { name: 'Close' })
254
+ const last = screen.getByRole('button', { name: 'Last' })
255
+ last.focus()
256
+ await userEvent.tab()
257
+ expect(document.activeElement).toBe(close)
258
+ })
259
+
260
+ it('wraps Shift+Tab from the first control round to the last', async () => {
261
+ render(
262
+ <Modal isOpen onClose={noop} title="Confirm">
263
+ <button type="button">Last</button>
264
+ </Modal>,
265
+ )
266
+ const close = screen.getByRole('button', { name: 'Close' })
267
+ close.focus()
268
+ await userEvent.tab({ shift: true })
269
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Last' }))
270
+ })
271
+
272
+ it('wraps Shift+Tab from the panel itself round to the last control', async () => {
273
+ render(
274
+ <Modal isOpen onClose={noop} title="Confirm">
275
+ <button type="button">Last</button>
276
+ </Modal>,
277
+ )
278
+ expect(document.activeElement).toBe(screen.getByRole('dialog'))
279
+ await userEvent.tab({ shift: true })
280
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Last' }))
281
+ })
282
+
283
+ it('does not let Tab leave the panel for the page behind it', async () => {
284
+ const outside = document.createElement('button')
285
+ outside.textContent = 'Behind'
286
+ document.body.appendChild(outside)
287
+ render(
288
+ <Modal isOpen onClose={noop} title="Confirm">
289
+ <button type="button">Last</button>
290
+ </Modal>,
291
+ )
292
+ const dialog = screen.getByRole('dialog')
293
+ for (let i = 0; i < 6; i++) {
294
+ await userEvent.tab()
295
+ expect(dialog.contains(document.activeElement)).toBe(true)
296
+ }
297
+ outside.remove()
298
+ })
299
+
300
+ it('closes only the topmost modal on Escape', async () => {
301
+ const closeOuter = vi.fn()
302
+ const closeInner = vi.fn()
303
+ render(
304
+ <>
305
+ <Modal isOpen onClose={closeOuter} title="Outer">
306
+ outer
307
+ </Modal>
308
+ <Modal isOpen onClose={closeInner} title="Inner">
309
+ inner
310
+ </Modal>
311
+ </>,
312
+ )
313
+ await userEvent.keyboard('{Escape}')
314
+ expect(closeInner).toHaveBeenCalledTimes(1)
315
+ expect(closeOuter).not.toHaveBeenCalled()
316
+ })
317
+ })
318
+
145
319
  it('merges a caller className onto the panel', () => {
146
320
  render(
147
321
  <Modal isOpen onClose={noop} title="Confirm" className="max-w-sm">