@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,422 @@
1
+ /**
2
+ * lib/anchor — the one positioning routine every floating surface uses.
3
+ *
4
+ * `computeAnchoredPosition` is pinned as pure math (no DOM), then the hook
5
+ * is exercised through a harness with mocked geometry: an element ref, a
6
+ * virtual rect, a flip at the viewport edge, and re-measure on resize.
7
+ * `useOutsideClick` closes the file.
8
+ */
9
+ import { useRef, useState } from 'react'
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
11
+ import { act, render, screen } from '@testing-library/react'
12
+ import userEvent from '@testing-library/user-event'
13
+ import {
14
+ computeAnchoredPosition,
15
+ useAnchoredPosition,
16
+ useOutsideClick,
17
+ type AnchorAlign,
18
+ type AnchorRect,
19
+ type AnchorSide,
20
+ } from '../lib/anchor'
21
+ import { mockGeometry, setViewport } from './helpers/geometry'
22
+
23
+ const VIEWPORT = { width: 1000, height: 800 }
24
+ // right 480, bottom 332 — room on every side.
25
+ const ANCHOR = { left: 400, top: 300, width: 80, height: 32 }
26
+ const FLOATING = { width: 200, height: 120 }
27
+
28
+ describe('computeAnchoredPosition', () => {
29
+ it('bottom-start: below the anchor, left edges aligned, offset applied', () => {
30
+ expect(
31
+ computeAnchoredPosition(ANCHOR, FLOATING, VIEWPORT, { side: 'bottom', align: 'start' }),
32
+ ).toEqual({ left: 400, top: 338, placement: { side: 'bottom', align: 'start' } })
33
+ })
34
+
35
+ it.each<[AnchorAlign, number]>([
36
+ ['start', 400],
37
+ ['center', 340],
38
+ ['end', 280],
39
+ ])('aligns %s along the cross axis for a vertical side', (align, left) => {
40
+ const r = computeAnchoredPosition(ANCHOR, FLOATING, VIEWPORT, { side: 'bottom', align })
41
+ expect(r.left).toBe(left)
42
+ expect(r.top).toBe(338)
43
+ })
44
+
45
+ it('side top places the element above the anchor', () => {
46
+ const r = computeAnchoredPosition(ANCHOR, FLOATING, VIEWPORT, { side: 'top' })
47
+ expect(r.top).toBe(300 - 120 - 6)
48
+ expect(r.placement.side).toBe('top')
49
+ })
50
+
51
+ it.each<[AnchorAlign, number]>([
52
+ ['start', 300],
53
+ ['center', 256],
54
+ ['end', 212],
55
+ ])('side right: to the right of the anchor, %s-aligned vertically', (align, top) => {
56
+ const r = computeAnchoredPosition(ANCHOR, FLOATING, VIEWPORT, { side: 'right', align })
57
+ expect(r.left).toBe(480 + 6)
58
+ expect(r.top).toBe(top)
59
+ })
60
+
61
+ it('side left places the element to the left of the anchor', () => {
62
+ const r = computeAnchoredPosition(ANCHOR, FLOATING, VIEWPORT, { side: 'left' })
63
+ expect(r.left).toBe(400 - 200 - 6)
64
+ expect(r.placement.side).toBe('left')
65
+ })
66
+
67
+ it('alignOffset shifts along the cross axis and is mirrored for end', () => {
68
+ expect(
69
+ computeAnchoredPosition(ANCHOR, FLOATING, VIEWPORT, { align: 'start', alignOffset: 10 }).left,
70
+ ).toBe(410)
71
+ expect(
72
+ computeAnchoredPosition(ANCHOR, FLOATING, VIEWPORT, { align: 'end', alignOffset: 10 }).left,
73
+ ).toBe(270)
74
+ })
75
+
76
+ it('honours a custom offset', () => {
77
+ expect(computeAnchoredPosition(ANCHOR, FLOATING, VIEWPORT, { offset: 0 }).top).toBe(332)
78
+ })
79
+
80
+ describe('flip', () => {
81
+ // bottom 732 → 60px of room below, 692 above.
82
+ const nearBottom = { left: 400, top: 700, width: 80, height: 32 }
83
+
84
+ it('flips to top when bottom cannot fit', () => {
85
+ const r = computeAnchoredPosition(nearBottom, FLOATING, VIEWPORT, { side: 'bottom' })
86
+ expect(r.placement.side).toBe('top')
87
+ expect(r.top).toBe(700 - 120 - 6)
88
+ })
89
+
90
+ it('does not flip when flip is false — clamps instead', () => {
91
+ const r = computeAnchoredPosition(nearBottom, FLOATING, VIEWPORT, {
92
+ side: 'bottom',
93
+ flip: false,
94
+ })
95
+ expect(r.placement.side).toBe('bottom')
96
+ expect(r.top).toBe(800 - 120 - 8)
97
+ })
98
+
99
+ it('flips to whichever side has more room when neither fits', () => {
100
+ const short = { width: 1000, height: 200 }
101
+ // top 90 / bottom 122: 82px above, 70px below; needs 126.
102
+ const r = computeAnchoredPosition(
103
+ { left: 400, top: 90, width: 80, height: 32 },
104
+ FLOATING,
105
+ short,
106
+ { side: 'bottom' },
107
+ )
108
+ expect(r.placement.side).toBe('top')
109
+ expect(r.top).toBe(8)
110
+ })
111
+
112
+ it('stays on the preferred side when the opposite has even less room', () => {
113
+ const short = { width: 1000, height: 200 }
114
+ // top 60 / bottom 92: 52px above, 100px below.
115
+ const r = computeAnchoredPosition(
116
+ { left: 400, top: 60, width: 80, height: 32 },
117
+ FLOATING,
118
+ short,
119
+ { side: 'bottom' },
120
+ )
121
+ expect(r.placement.side).toBe('bottom')
122
+ expect(r.top).toBe(200 - 120 - 8)
123
+ })
124
+
125
+ it('flips horizontally for side right at the right viewport edge', () => {
126
+ const r = computeAnchoredPosition(
127
+ { left: 900, top: 300, width: 80, height: 32 },
128
+ FLOATING,
129
+ VIEWPORT,
130
+ { side: 'right' },
131
+ )
132
+ expect(r.placement.side).toBe('left')
133
+ expect(r.left).toBe(900 - 200 - 6)
134
+ })
135
+
136
+ it('keeps the requested align in the placement it reports', () => {
137
+ const r = computeAnchoredPosition(nearBottom, FLOATING, VIEWPORT, { align: 'end' })
138
+ expect(r.placement).toEqual({ side: 'top', align: 'end' })
139
+ })
140
+ })
141
+
142
+ describe('clamp', () => {
143
+ const nearRight = { left: 950, top: 300, width: 40, height: 32 }
144
+
145
+ it('clamps into the viewport, gutter in from the edge', () => {
146
+ const r = computeAnchoredPosition(nearRight, FLOATING, VIEWPORT)
147
+ expect(r.left).toBe(1000 - 200 - 8)
148
+ })
149
+
150
+ it('leaves the overflow alone when clampToViewport is false', () => {
151
+ const r = computeAnchoredPosition(nearRight, FLOATING, VIEWPORT, { clampToViewport: false })
152
+ expect(r.left).toBe(950)
153
+ })
154
+
155
+ it('uses a custom gutter', () => {
156
+ const r = computeAnchoredPosition(nearRight, FLOATING, VIEWPORT, { gutter: 20 })
157
+ expect(r.left).toBe(1000 - 200 - 20)
158
+ })
159
+
160
+ it('pins to the gutter when the element is wider than the viewport', () => {
161
+ const r = computeAnchoredPosition(ANCHOR, { width: 1200, height: 40 }, VIEWPORT)
162
+ expect(r.left).toBe(8)
163
+ })
164
+ })
165
+
166
+ it('positions against a virtual point (zero-size rect)', () => {
167
+ const point: AnchorRect = { left: 300, top: 200, width: 0, height: 0 }
168
+ expect(computeAnchoredPosition(point, FLOATING, VIEWPORT, { offset: 0 })).toEqual({
169
+ left: 300,
170
+ top: 200,
171
+ placement: { side: 'bottom', align: 'start' },
172
+ })
173
+ expect(computeAnchoredPosition(point, FLOATING, VIEWPORT, { offset: 0, align: 'end' }).left).toBe(
174
+ 100,
175
+ )
176
+ })
177
+
178
+ it('rounds to whole pixels', () => {
179
+ const r = computeAnchoredPosition(
180
+ { left: 100.4, top: 100.6, width: 80, height: 32 },
181
+ FLOATING,
182
+ VIEWPORT,
183
+ { align: 'center' },
184
+ )
185
+ expect(r.left).toBe(40)
186
+ expect(Number.isInteger(r.top)).toBe(true)
187
+ })
188
+ })
189
+
190
+ interface HarnessProps {
191
+ anchorRect?: AnchorRect | null
192
+ side?: AnchorSide
193
+ align?: AnchorAlign
194
+ flip?: boolean
195
+ rect?: string
196
+ size?: string
197
+ }
198
+
199
+ function Harness({
200
+ anchorRect = null,
201
+ side = 'bottom',
202
+ align = 'start',
203
+ flip = true,
204
+ rect = '400,300,80,32',
205
+ size = '200,120',
206
+ }: HarnessProps) {
207
+ const anchorRef = useRef<HTMLButtonElement>(null)
208
+ const { ref, style, placement, positioned } = useAnchoredPosition<HTMLDivElement>({
209
+ anchorRef,
210
+ anchorRect,
211
+ side,
212
+ align,
213
+ flip,
214
+ })
215
+ return (
216
+ <>
217
+ <button type="button" ref={anchorRef} data-rect={rect}>
218
+ anchor
219
+ </button>
220
+ <div
221
+ ref={ref}
222
+ data-testid="floating"
223
+ data-size={size}
224
+ data-side={placement?.side}
225
+ data-align={placement?.align}
226
+ data-positioned={String(positioned)}
227
+ style={style}
228
+ >
229
+ floating
230
+ </div>
231
+ </>
232
+ )
233
+ }
234
+
235
+ describe('useAnchoredPosition', () => {
236
+ let restore: () => void
237
+ beforeEach(() => {
238
+ restore = mockGeometry()
239
+ setViewport(1000, 800)
240
+ })
241
+ afterEach(() => restore())
242
+
243
+ it('positions the floating element under the anchor ref with position: fixed', () => {
244
+ render(<Harness />)
245
+ const floating = screen.getByTestId('floating')
246
+ expect(floating.style.position).toBe('fixed')
247
+ expect(floating.style.left).toBe('400px')
248
+ expect(floating.style.top).toBe('338px')
249
+ expect(floating.dataset.positioned).toBe('true')
250
+ expect(floating.dataset.side).toBe('bottom')
251
+ expect(floating.dataset.align).toBe('start')
252
+ expect(floating.style.visibility).toBe('')
253
+ })
254
+
255
+ it('reports the flipped placement at a viewport edge', () => {
256
+ render(<Harness rect="400,700,80,32" />)
257
+ const floating = screen.getByTestId('floating')
258
+ expect(floating.dataset.side).toBe('top')
259
+ expect(floating.style.top).toBe(`${700 - 120 - 6}px`)
260
+ })
261
+
262
+ it('positions against a virtual rect, ignoring the anchor ref', () => {
263
+ render(<Harness anchorRect={{ left: 300, top: 200, width: 0, height: 0 }} />)
264
+ const floating = screen.getByTestId('floating')
265
+ expect(floating.style.left).toBe('300px')
266
+ expect(floating.style.top).toBe('206px')
267
+ })
268
+
269
+ it('re-measures when the virtual rect changes', () => {
270
+ const { rerender } = render(
271
+ <Harness anchorRect={{ left: 300, top: 200, width: 0, height: 0 }} />,
272
+ )
273
+ rerender(<Harness anchorRect={{ left: 500, top: 100, width: 0, height: 0 }} />)
274
+ const floating = screen.getByTestId('floating')
275
+ expect(floating.style.left).toBe('500px')
276
+ expect(floating.style.top).toBe('106px')
277
+ })
278
+
279
+ it('re-measures on window resize and scroll', () => {
280
+ const { rerender } = render(<Harness />)
281
+ // The anchor moved (re-declared rect) but nothing told React — a
282
+ // scroll or resize is what should re-read it.
283
+ rerender(<Harness rect="120,50,80,32" />)
284
+ act(() => {
285
+ window.dispatchEvent(new Event('resize'))
286
+ })
287
+ expect(screen.getByTestId('floating').style.left).toBe('120px')
288
+
289
+ rerender(<Harness rect="220,50,80,32" />)
290
+ act(() => {
291
+ window.dispatchEvent(new Event('scroll'))
292
+ })
293
+ expect(screen.getByTestId('floating').style.left).toBe('220px')
294
+ })
295
+
296
+ it('honours side and align props', () => {
297
+ render(<Harness side="right" align="end" />)
298
+ const floating = screen.getByTestId('floating')
299
+ expect(floating.dataset.side).toBe('right')
300
+ expect(floating.style.left).toBe('486px')
301
+ expect(floating.style.top).toBe('212px')
302
+ })
303
+
304
+ it('parks the element off-screen and hidden while it is not measured', () => {
305
+ // No geometry at all: the anchor rect is 0×0 at (0, 0) — that still
306
+ // measures. To see the parked frame, disable measuring.
307
+ function Disabled() {
308
+ const anchorRef = useRef<HTMLButtonElement>(null)
309
+ const { ref, style, positioned } = useAnchoredPosition<HTMLDivElement>({
310
+ anchorRef,
311
+ enabled: false,
312
+ })
313
+ return (
314
+ <>
315
+ <button type="button" ref={anchorRef} />
316
+ <div ref={ref} data-testid="floating" data-positioned={String(positioned)} style={style} />
317
+ </>
318
+ )
319
+ }
320
+ render(<Disabled />)
321
+ const floating = screen.getByTestId('floating')
322
+ expect(floating.dataset.positioned).toBe('false')
323
+ expect(floating.style.visibility).toBe('hidden')
324
+ expect(floating.style.pointerEvents).toBe('none')
325
+ expect(Number.parseInt(floating.style.left, 10)).toBeLessThan(-1000)
326
+ })
327
+
328
+ it('detaches its listeners on unmount', () => {
329
+ const add = vi.spyOn(window, 'addEventListener')
330
+ const remove = vi.spyOn(window, 'removeEventListener')
331
+ const { unmount } = render(<Harness />)
332
+ const added = add.mock.calls.filter(([type]) => type === 'resize' || type === 'scroll').length
333
+ unmount()
334
+ const removed = remove.mock.calls.filter(
335
+ ([type]) => type === 'resize' || type === 'scroll',
336
+ ).length
337
+ expect(added).toBeGreaterThan(0)
338
+ expect(removed).toBe(added)
339
+ add.mockRestore()
340
+ remove.mockRestore()
341
+ })
342
+ })
343
+
344
+ describe('useOutsideClick', () => {
345
+ function OutsideHarness({
346
+ onOutside,
347
+ enabled = true,
348
+ }: {
349
+ onOutside: () => void
350
+ enabled?: boolean
351
+ }) {
352
+ const a = useRef<HTMLDivElement>(null)
353
+ const b = useRef<HTMLDivElement>(null)
354
+ useOutsideClick([a, b], onOutside, { enabled })
355
+ return (
356
+ <>
357
+ <div ref={a}>
358
+ <button type="button">inside a</button>
359
+ </div>
360
+ <div ref={b}>
361
+ <button type="button">inside b</button>
362
+ </div>
363
+ <button type="button">outside</button>
364
+ </>
365
+ )
366
+ }
367
+
368
+ it('fires for a pointerdown outside every target', async () => {
369
+ const onOutside = vi.fn()
370
+ render(<OutsideHarness onOutside={onOutside} />)
371
+ await userEvent.click(screen.getByRole('button', { name: 'outside' }))
372
+ expect(onOutside).toHaveBeenCalledTimes(1)
373
+ })
374
+
375
+ it('ignores pointerdowns inside any target', async () => {
376
+ const onOutside = vi.fn()
377
+ render(<OutsideHarness onOutside={onOutside} />)
378
+ await userEvent.click(screen.getByRole('button', { name: 'inside a' }))
379
+ await userEvent.click(screen.getByRole('button', { name: 'inside b' }))
380
+ expect(onOutside).not.toHaveBeenCalled()
381
+ })
382
+
383
+ it('does nothing while disabled', async () => {
384
+ const onOutside = vi.fn()
385
+ render(<OutsideHarness onOutside={onOutside} enabled={false} />)
386
+ await userEvent.click(screen.getByRole('button', { name: 'outside' }))
387
+ expect(onOutside).not.toHaveBeenCalled()
388
+ })
389
+
390
+ it('detaches on unmount', async () => {
391
+ const onOutside = vi.fn()
392
+ const { unmount } = render(<OutsideHarness onOutside={onOutside} />)
393
+ unmount()
394
+ await userEvent.click(document.body)
395
+ expect(onOutside).not.toHaveBeenCalled()
396
+ })
397
+
398
+ it('reads the latest handler without re-subscribing', async () => {
399
+ const first = vi.fn()
400
+ const second = vi.fn()
401
+ function Latest() {
402
+ const [swap, setSwap] = useState(false)
403
+ const a = useRef<HTMLDivElement>(null)
404
+ useOutsideClick([a], swap ? second : first)
405
+ return (
406
+ <>
407
+ <div ref={a}>
408
+ <button type="button" onClick={() => setSwap(true)}>
409
+ swap
410
+ </button>
411
+ </div>
412
+ <button type="button">outside</button>
413
+ </>
414
+ )
415
+ }
416
+ render(<Latest />)
417
+ await userEvent.click(screen.getByRole('button', { name: 'swap' }))
418
+ await userEvent.click(screen.getByRole('button', { name: 'outside' }))
419
+ expect(first).not.toHaveBeenCalled()
420
+ expect(second).toHaveBeenCalledTimes(1)
421
+ })
422
+ })