@lovett/ui 0.0.10 → 0.1.0

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 (158) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +6064 -80
  4. package/dist/index.js +21561 -9696
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1586 -2
  9. package/dist/theme-v2.css +228 -0
  10. package/dist/tokens.css +121 -0
  11. package/package.json +8 -1
  12. package/src/__tests__/anchor.test.tsx +422 -0
  13. package/src/__tests__/avatar.test.tsx +272 -0
  14. package/src/__tests__/bar-chart.test.tsx +809 -0
  15. package/src/__tests__/board.test.tsx +420 -0
  16. package/src/__tests__/chart-math.test.ts +922 -0
  17. package/src/__tests__/chart-series.test.ts +339 -0
  18. package/src/__tests__/code-block.test.tsx +134 -0
  19. package/src/__tests__/combobox.test.tsx +677 -0
  20. package/src/__tests__/display-popover.test.tsx +195 -0
  21. package/src/__tests__/display-store.test.tsx +208 -0
  22. package/src/__tests__/donut-chart.test.tsx +397 -0
  23. package/src/__tests__/dropdown-menu.test.tsx +572 -0
  24. package/src/__tests__/filter-menu.test.tsx +175 -0
  25. package/src/__tests__/gauge-ring.test.tsx +233 -0
  26. package/src/__tests__/helpers/geometry.ts +58 -0
  27. package/src/__tests__/layer-stack.test.tsx +228 -0
  28. package/src/__tests__/line-chart.test.tsx +612 -0
  29. package/src/__tests__/popover.test.tsx +460 -0
  30. package/src/__tests__/ranked-bars.test.tsx +343 -0
  31. package/src/__tests__/remark-underline.test.ts +194 -0
  32. package/src/__tests__/select.test.tsx +543 -0
  33. package/src/__tests__/sparkline.test.tsx +368 -0
  34. package/src/__tests__/stat-layer.test.tsx +271 -0
  35. package/src/__tests__/stat-strip.test.tsx +175 -0
  36. package/src/__tests__/status.test.tsx +293 -0
  37. package/src/__tests__/tabs.test.tsx +303 -0
  38. package/src/__tests__/token-shape.test.ts +132 -2
  39. package/src/__tests__/tooltip.test.tsx +355 -0
  40. package/src/avatar.tsx +352 -0
  41. package/src/bar-chart.tsx +1214 -0
  42. package/src/board.tsx +658 -0
  43. package/src/calculator-shell-v2.tsx +19 -39
  44. package/src/chart-frame.tsx +960 -0
  45. package/src/chart-legend.tsx +304 -0
  46. package/src/chart-tooltip.tsx +267 -0
  47. package/src/code-block.tsx +62 -8
  48. package/src/combobox.tsx +796 -0
  49. package/src/delta-chip.tsx +263 -0
  50. package/src/detail/__tests__/activity-pane.test.tsx +186 -0
  51. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  52. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  53. package/src/detail/__tests__/field-row.test.tsx +357 -0
  54. package/src/detail/activity-pane.tsx +507 -0
  55. package/src/detail/calendar.tsx +355 -0
  56. package/src/detail/detail-divider.tsx +261 -0
  57. package/src/detail/detail-header.tsx +287 -0
  58. package/src/detail/detail-menu.tsx +254 -0
  59. package/src/detail/detail-surface.tsx +1110 -0
  60. package/src/detail/field-list.tsx +196 -0
  61. package/src/detail/field-row.tsx +1131 -0
  62. package/src/detail/index.ts +58 -0
  63. package/src/detail/segmented-choice.tsx +94 -0
  64. package/src/detail/types.ts +129 -0
  65. package/src/display-popover.tsx +487 -0
  66. package/src/display-store.tsx +242 -0
  67. package/src/donut-chart.tsx +988 -0
  68. package/src/dropdown-menu.tsx +412 -151
  69. package/src/filter-core/EXPORTS.md +85 -0
  70. package/src/filter-core/__tests__/columns.test.ts +159 -0
  71. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  72. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  73. package/src/filter-core/__tests__/operators.test.ts +235 -0
  74. package/src/filter-core/__tests__/state.test.ts +268 -0
  75. package/src/filter-core/__tests__/url.test.ts +350 -0
  76. package/src/filter-core/columns.ts +134 -0
  77. package/src/filter-core/date-utils.ts +38 -0
  78. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  79. package/src/filter-core/faceting.ts +120 -0
  80. package/src/filter-core/filter-fns.ts +335 -0
  81. package/src/filter-core/index.ts +105 -0
  82. package/src/filter-core/operators.ts +433 -0
  83. package/src/filter-core/state.ts +280 -0
  84. package/src/filter-core/types.ts +247 -0
  85. package/src/filter-core/url.ts +261 -0
  86. package/src/filter-dropdown.tsx +12 -0
  87. package/src/filter-menu.tsx +649 -0
  88. package/src/floating-drawer.tsx +19 -1
  89. package/src/gauge-ring.tsx +435 -0
  90. package/src/hue.ts +52 -0
  91. package/src/index.ts +402 -0
  92. package/src/kbd.tsx +27 -4
  93. package/src/lib/anchor.ts +427 -0
  94. package/src/lib/chart.ts +866 -0
  95. package/src/lib/focus.ts +74 -0
  96. package/src/lib/layer-stack.ts +215 -0
  97. package/src/lib/refs.ts +31 -0
  98. package/src/lib/remark-underline.ts +443 -0
  99. package/src/lib/series.ts +169 -0
  100. package/src/line-chart.tsx +1176 -0
  101. package/src/markdown.tsx +26 -7
  102. package/src/metric-card.tsx +57 -22
  103. package/src/modal.tsx +73 -66
  104. package/src/popover.tsx +407 -0
  105. package/src/progress-ledger.tsx +304 -0
  106. package/src/ranked-bars.tsx +386 -0
  107. package/src/segmented-pill.tsx +32 -9
  108. package/src/select.tsx +646 -0
  109. package/src/sortable.tsx +315 -1
  110. package/src/sparkline.tsx +416 -0
  111. package/src/stat-card.tsx +376 -0
  112. package/src/stat-row.tsx +108 -70
  113. package/src/stat-strip.tsx +327 -0
  114. package/src/status.tsx +215 -0
  115. package/src/styles.css +1586 -2
  116. package/src/tabs.tsx +206 -25
  117. package/src/task-card.tsx +610 -0
  118. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  119. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  120. package/src/thread/__tests__/emoji.test.ts +187 -0
  121. package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
  122. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  123. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  124. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  125. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  126. package/src/thread/__tests__/relative-time.test.ts +71 -0
  127. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  128. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  129. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  130. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  131. package/src/thread/__tests__/thread.test.tsx +662 -0
  132. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  133. package/src/thread/actions.tsx +196 -0
  134. package/src/thread/attachments.tsx +1071 -0
  135. package/src/thread/comment-body.tsx +148 -0
  136. package/src/thread/comment-tree.ts +182 -0
  137. package/src/thread/comment.tsx +915 -0
  138. package/src/thread/composer-footer.tsx +125 -0
  139. package/src/thread/composer.tsx +319 -0
  140. package/src/thread/emoji.ts +283 -0
  141. package/src/thread/index.ts +153 -0
  142. package/src/thread/link-preview.tsx +341 -0
  143. package/src/thread/markdown-format.ts +155 -0
  144. package/src/thread/markdown-spec.ts +754 -0
  145. package/src/thread/rail.tsx +372 -0
  146. package/src/thread/reactions.tsx +415 -0
  147. package/src/thread/relative-time.tsx +94 -0
  148. package/src/thread/rich-composer-impl.tsx +1601 -0
  149. package/src/thread/rich-composer.tsx +195 -0
  150. package/src/thread/scroll-caret.ts +37 -0
  151. package/src/thread/suggestion-list.tsx +182 -0
  152. package/src/thread/thread.tsx +718 -0
  153. package/src/thread/types.ts +218 -0
  154. package/src/thread/use-attachments.ts +598 -0
  155. package/src/thread/use-now.ts +73 -0
  156. package/src/thread/use-thread.ts +316 -0
  157. package/src/tokens.css +121 -0
  158. package/src/tooltip.tsx +297 -0
@@ -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
+ })
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Avatar / AvatarGroup — ADR-147 D14.
3
+ *
4
+ * The contract worth pinning is not "it renders a circle". It is the four
5
+ * things the consolidated forks got wrong or could not do, plus the two D5
6
+ * degradations that a comment thread will hit in production:
7
+ *
8
+ * • one `initials()`, capped at two words, first + last
9
+ * • one tint derivation, stable per identity, `red` never in the set
10
+ * • the monogram stays in the DOM UNDER the photo, so a broken `src`
11
+ * degrades to a letter rather than a coloured hole
12
+ * • `max` and `total` are separate, so 3 of 40 renders from 3 children
13
+ * • a NULL name does not crash and is not fabricated
14
+ *
15
+ * `css: false` in the vitest config, so nothing here asserts a painted value —
16
+ * these are class and DOM contracts. Colour is verified by the measured note
17
+ * in `styles.css` and an eyeball pass in both themes.
18
+ */
19
+ import { describe, expect, it } from 'vitest'
20
+ import { fireEvent, render, screen } from '@testing-library/react'
21
+ import {
22
+ AVATAR_TINTS,
23
+ Avatar,
24
+ AvatarGroup,
25
+ asAvatarTint,
26
+ avatarTintForKey,
27
+ initials,
28
+ } from '../avatar'
29
+
30
+ describe('initials()', () => {
31
+ it('takes the first and last word, capped at two', () => {
32
+ expect(initials('Marcus Webb')).toBe('MW')
33
+ // The divergence being resolved: community and admin returned 'MJ' here.
34
+ expect(initials('Mary Jane Watson')).toBe('MW')
35
+ })
36
+
37
+ it('takes one letter from a single-word name', () => {
38
+ expect(initials('Cher')).toBe('C')
39
+ })
40
+
41
+ it('uppercases and tolerates ragged whitespace', () => {
42
+ expect(initials(' ada lovelace ')).toBe('AL')
43
+ })
44
+
45
+ it('does not slice a surrogate pair in half', () => {
46
+ // A name whose first character is outside the BMP. `name[0]` returns half a
47
+ // code point and renders as a replacement glyph.
48
+ expect(initials('\u{1D400}lpha Beta')).toBe('\u{1D400}B')
49
+ })
50
+
51
+ it('returns ? for a null, undefined, empty or blank name and never invents one', () => {
52
+ // D5: users.name / given_name / family_name are ALL nullable by policy.
53
+ expect(initials(null)).toBe('?')
54
+ expect(initials(undefined)).toBe('?')
55
+ expect(initials('')).toBe('?')
56
+ expect(initials(' ')).toBe('?')
57
+ })
58
+ })
59
+
60
+ describe('avatarTintForKey()', () => {
61
+ it('is stable for the same seed', () => {
62
+ expect(avatarTintForKey('usr_9f2')).toBe(avatarTintForKey('usr_9f2'))
63
+ })
64
+
65
+ it('never returns red — that is the accent, and an avatar may not spend it', () => {
66
+ expect(AVATAR_TINTS).not.toContain('red')
67
+ const seeds = Array.from({ length: 400 }, (_, i) => `usr_${i}`)
68
+ for (const seed of seeds) {
69
+ expect(AVATAR_TINTS).toContain(avatarTintForKey(seed))
70
+ }
71
+ })
72
+
73
+ it('hashes the whole string, not the first character', () => {
74
+ // The community/admin fork keyed on `name.charCodeAt(0) % 4`, so every
75
+ // name starting with the same letter collided by construction.
76
+ expect(avatarTintForKey('Alice Adams')).not.toBe(avatarTintForKey('Aaron Ash'))
77
+ })
78
+
79
+ it('is not degenerate — it reaches most of the palette over a small sample', () => {
80
+ const seen = new Set(
81
+ Array.from({ length: 200 }, (_, i) => avatarTintForKey(`usr_${i}`)),
82
+ )
83
+ expect(seen.size).toBeGreaterThanOrEqual(AVATAR_TINTS.length - 1)
84
+ })
85
+ })
86
+
87
+ describe('asAvatarTint()', () => {
88
+ it('passes a known key through', () => {
89
+ expect(asAvatarTint('violet')).toBe('violet')
90
+ })
91
+
92
+ it('rejects anything that is not a key, so an untrusted string cannot reach the DOM', () => {
93
+ // `users.avatar_color` is a nullable TEXT column, so its contents are a
94
+ // string from the database, not a domain value.
95
+ expect(asAvatarTint(null)).toBeUndefined()
96
+ expect(asAvatarTint(undefined)).toBeUndefined()
97
+ expect(asAvatarTint('')).toBeUndefined()
98
+ expect(asAvatarTint('rebeccapurple')).toBeUndefined()
99
+ expect(asAvatarTint('var(--accent)')).toBeUndefined()
100
+ })
101
+
102
+ it('rejects red — it is not in the set, so it cannot be stored into one', () => {
103
+ expect(asAvatarTint('red')).toBeUndefined()
104
+ })
105
+ })
106
+
107
+ describe('Avatar', () => {
108
+ it('names itself for a screen reader and shows the monogram', () => {
109
+ render(<Avatar name="Marcus Webb" />)
110
+ const el = screen.getByRole('img', { name: 'Marcus Webb' })
111
+ expect(el).toHaveTextContent('MW')
112
+ })
113
+
114
+ it('takes a tint prop over the hash, and carries it as a class not a style', () => {
115
+ // Identity colour is DATA (D6). A key, so the token boundary holds — an
116
+ // arbitrary colour string out of the database would not.
117
+ const { container } = render(<Avatar name="Marcus Webb" tint="violet" />)
118
+ expect(container.firstChild).toHaveClass('ds-avatar', 'ds-avatar--violet')
119
+ })
120
+
121
+ it('falls back to the hash when no tint is given, seeded by id in preference to name', () => {
122
+ const { container: byName } = render(<Avatar name="Marcus Webb" />)
123
+ const { container: bySeed } = render(<Avatar name="Marcus Webb" seed="usr_9f2" />)
124
+ expect(byName.firstChild).toHaveClass(`ds-avatar--${avatarTintForKey('Marcus Webb')}`)
125
+ expect(bySeed.firstChild).toHaveClass(`ds-avatar--${avatarTintForKey('usr_9f2')}`)
126
+ })
127
+
128
+ it('gives the monogram its OWN element, not a bare text node', () => {
129
+ // Measured in @lovett/ds: as a bare text node it is an anonymous grid item,
130
+ // `text-box-trim` reaches nothing, and the glyph sat 1.3px high in a 24px
131
+ // avatar. Deleting the span is a silent 1.3px regression, so it is pinned.
132
+ const { container } = render(<Avatar name="Marcus Webb" />)
133
+ const mono = container.querySelector('.ds-avatar__mono')
134
+ expect(mono).not.toBeNull()
135
+ expect(mono).toHaveTextContent('MW')
136
+ })
137
+
138
+ it('renders the photo OVER the monogram, keeping the monogram in the DOM', () => {
139
+ const { container } = render(<Avatar name="Marcus Webb" src="https://x.test/m.png" />)
140
+ expect(container.querySelector('.ds-avatar__img')).not.toBeNull()
141
+ expect(container.querySelector('.ds-avatar__mono')).toHaveTextContent('MW')
142
+ })
143
+
144
+ it('degrades a broken src to the letter rather than a coloured hole', () => {
145
+ const { container } = render(<Avatar name="Marcus Webb" src="https://x.test/404.png" />)
146
+ const img = container.querySelector('.ds-avatar__img')
147
+ expect(img).not.toBeNull()
148
+ if (img) fireEvent.error(img)
149
+ expect(container.querySelector('.ds-avatar__img')).toBeNull()
150
+ expect(screen.getByRole('img', { name: 'Marcus Webb' })).toHaveTextContent('MW')
151
+ })
152
+
153
+ it('re-tries a NEW src after an earlier one failed', () => {
154
+ // A latching boolean would suppress every later photo for the life of the
155
+ // component once any URL 404s. Keyed by the failed src instead.
156
+ const { container, rerender } = render(<Avatar name="M W" src="https://x.test/404.png" />)
157
+ const img = container.querySelector('.ds-avatar__img')
158
+ if (img) fireEvent.error(img)
159
+ expect(container.querySelector('.ds-avatar__img')).toBeNull()
160
+ rerender(<Avatar name="M W" src="https://x.test/ok.png" />)
161
+ expect(container.querySelector('.ds-avatar__img')).not.toBeNull()
162
+ })
163
+
164
+ it('accepts an initial override', () => {
165
+ render(<Avatar name="Edwin Lovett" initial="You" />)
166
+ expect(screen.getByRole('img', { name: 'Edwin Lovett' })).toHaveTextContent('You')
167
+ })
168
+
169
+ describe('D5 — degraded identities', () => {
170
+ it('does not crash on a null name, and does not fabricate one', () => {
171
+ render(<Avatar name={null} />)
172
+ const el = screen.getByRole('img', { name: 'Unknown person' })
173
+ expect(el).toHaveTextContent('?')
174
+ })
175
+
176
+ it('treats a blank name the same as a null one', () => {
177
+ render(<Avatar name=" " />)
178
+ expect(screen.getByRole('img', { name: 'Unknown person' })).toHaveTextContent('?')
179
+ })
180
+
181
+ it('gives a null-named person a stable tint rather than throwing', () => {
182
+ const { container } = render(<Avatar name={null} />)
183
+ expect(container.firstChild).toHaveClass(
184
+ `ds-avatar--${avatarTintForKey('Unknown person')}`,
185
+ )
186
+ })
187
+ })
188
+
189
+ describe('sizing', () => {
190
+ it('is sm by default and carries no size modifier for it', () => {
191
+ const { container } = render(<Avatar name="Marcus Webb" />)
192
+ expect(container.firstChild).not.toHaveClass('ds-avatar--md', 'ds-avatar--lg')
193
+ })
194
+
195
+ it('carries a modifier class for a named size', () => {
196
+ const { container } = render(<Avatar name="Marcus Webb" size="lg" />)
197
+ expect(container.firstChild).toHaveClass('ds-avatar--lg')
198
+ })
199
+
200
+ it('carries a pixel size as custom properties, never as a hue or size class', () => {
201
+ const { container } = render(<Avatar name="Marcus Webb" size={26} />)
202
+ const el = container.firstChild
203
+ expect(el).toHaveStyle({ '--avatar-size': '26px' })
204
+ // 0.36 coefficient — the community fork's, so its call sites keep their
205
+ // rendered type size through the consolidation.
206
+ expect(el).toHaveStyle({ '--avatar-font': '9px' })
207
+ expect(el).not.toHaveClass('ds-avatar--md')
208
+ })
209
+ })
210
+
211
+ describe('accessibility', () => {
212
+ it('takes the ring class only when asked', () => {
213
+ const { container: plain } = render(<Avatar name="A B" />)
214
+ const { container: ringed } = render(<Avatar name="A B" ring />)
215
+ expect(plain.firstChild).not.toHaveClass('ds-avatar--ring')
216
+ expect(ringed.firstChild).toHaveClass('ds-avatar--ring')
217
+ })
218
+
219
+ it('hides itself from the a11y tree when decorative', () => {
220
+ // Every table row and byline renders the name beside the avatar;
221
+ // announcing the same person twice is worse than not announcing them.
222
+ const { container } = render(<Avatar name="Marcus Webb" decorative />)
223
+ expect(container.firstChild).toHaveAttribute('aria-hidden', 'true')
224
+ expect(screen.queryByRole('img')).toBeNull()
225
+ })
226
+ })
227
+ })
228
+
229
+ describe('AvatarGroup', () => {
230
+ const face = (n: string) => <Avatar key={n} name={n} />
231
+
232
+ it('shows every face when there are no more than max', () => {
233
+ render(<AvatarGroup max={3}>{['A One', 'B Two'].map(face)}</AvatarGroup>)
234
+ expect(screen.getAllByRole('img')).toHaveLength(2)
235
+ expect(screen.queryByText(/^\+/)).toBeNull()
236
+ })
237
+
238
+ it('collapses the tail into a +N', () => {
239
+ render(<AvatarGroup max={2}>{['A One', 'B Two', 'C Three'].map(face)}</AvatarGroup>)
240
+ expect(screen.getByRole('img', { name: '1 more' })).toHaveTextContent('+1')
241
+ })
242
+
243
+ it('counts from total, so 3 faces render out of 40 without 40 children', () => {
244
+ // The capability no fork had. `total` is separate from the children on
245
+ // purpose: a thread with 40 participants must not mount 40 avatars to
246
+ // throw 37 away.
247
+ render(
248
+ <AvatarGroup max={3} total={40}>
249
+ {['A One', 'B Two', 'C Three'].map(face)}
250
+ </AvatarGroup>,
251
+ )
252
+ expect(screen.getByRole('img', { name: '37 more' })).toHaveTextContent('+37')
253
+ })
254
+
255
+ it('overlaps by default and lays out spaced when asked', () => {
256
+ const { container: over } = render(<AvatarGroup>{[face('A One')]}</AvatarGroup>)
257
+ const { container: flat } = render(
258
+ <AvatarGroup overlap={false}>{[face('A One')]}</AvatarGroup>,
259
+ )
260
+ expect(over.firstChild).toHaveClass('ds-avatar-group--overlap')
261
+ expect(flat.firstChild).toHaveClass('ds-avatar-group--spaced')
262
+ })
263
+
264
+ it('sizes the +N chip with the faces', () => {
265
+ const { container } = render(
266
+ <AvatarGroup max={1} total={9} size="md">
267
+ {[face('A One')]}
268
+ </AvatarGroup>,
269
+ )
270
+ expect(container.querySelector('.ds-avatar-group__more')).toHaveClass('ds-avatar--md')
271
+ })
272
+ })