@lovett/ui 0.0.11 → 0.2.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 (142) 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 +5574 -57
  4. package/dist/index.js +21650 -11206
  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 +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
@@ -0,0 +1,679 @@
1
+ /**
2
+ * Composer attachments — ADR-151 D1, D3 (client-side courtesy caps) and D5.
3
+ *
4
+ * The tests that matter here are AC#6 and AC#7, and they are a pair: a paste at
5
+ * or above 2,500 characters becomes a file **and can be turned back**, and a
6
+ * paste below it is untouched. Threshold behaviour is exactly the kind of thing
7
+ * that ships subtly wrong — off by one, or intercepting but losing the text —
8
+ * so both directions and both boundaries are pinned, and the restore is
9
+ * asserted byte for byte rather than "roughly the same".
10
+ */
11
+
12
+ import { afterEach, describe, expect, it, vi } from 'vitest'
13
+ import { act, cleanup, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
14
+ import userEvent from '@testing-library/user-event'
15
+ import {
16
+ GIF_SEARCH_DEBOUNCE_MS,
17
+ PASTE_ATTACHMENT_THRESHOLD,
18
+ pastedTextFilename,
19
+ shouldAttachPaste,
20
+ useAttachmentUrl,
21
+ useAttachments,
22
+ type AttachmentTicketLike,
23
+ } from '../use-attachments'
24
+ import { AttachmentGif, AttachmentImage, AttachmentTray, GifPicker } from '../attachments'
25
+
26
+ afterEach(cleanup)
27
+
28
+ function ticket(over: Partial<AttachmentTicketLike> = {}): AttachmentTicketLike {
29
+ return {
30
+ id: 'tup_1',
31
+ kind: 'file',
32
+ name: 'pasted-text-1.txt',
33
+ sizeBytes: 10,
34
+ mime: 'text/plain',
35
+ aspectRatio: null,
36
+ ...over,
37
+ }
38
+ }
39
+
40
+ /** A resolved uploader that records the Blob it was handed. */
41
+ function recordingUploader() {
42
+ const seen: { blob: Blob; filename: string }[] = []
43
+ const upload = vi.fn(async (blob: Blob, opts: { filename: string }) => {
44
+ seen.push({ blob, filename: opts.filename })
45
+ return ticket({ id: `tup_${String(seen.length)}`, name: opts.filename, sizeBytes: blob.size })
46
+ })
47
+ return { upload, seen }
48
+ }
49
+
50
+ /**
51
+ * jsdom has no media queries, so `matchMedia` is stubbed rather than mocked
52
+ * away: the component subscribes to `change`, and a stub that returned a bare
53
+ * boolean would let a regression in the subscription pass unnoticed.
54
+ */
55
+ function stubReducedMotion(reduce: boolean): () => void {
56
+ const listeners = new Set<(e: MediaQueryListEvent) => void>()
57
+ const original = window.matchMedia
58
+ Object.defineProperty(window, 'matchMedia', {
59
+ configurable: true,
60
+ writable: true,
61
+ value: (queryText: string) => ({
62
+ matches: reduce && queryText.includes('prefers-reduced-motion'),
63
+ media: queryText,
64
+ addEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.add(fn),
65
+ removeEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) =>
66
+ listeners.delete(fn),
67
+ onchange: null,
68
+ addListener: () => undefined,
69
+ removeListener: () => undefined,
70
+ dispatchEvent: () => false,
71
+ }),
72
+ })
73
+ return () => {
74
+ Object.defineProperty(window, 'matchMedia', {
75
+ configurable: true,
76
+ writable: true,
77
+ value: original,
78
+ })
79
+ }
80
+ }
81
+
82
+ describe('D5 — the paste threshold, in both directions', () => {
83
+ it('AC#7: a paste below 2,500 characters is not intercepted at all', async () => {
84
+ const { upload } = recordingUploader()
85
+ const { result } = renderHook(() => useAttachments({ upload }))
86
+
87
+ let intercepted = true
88
+ act(() => {
89
+ intercepted = result.current.addPastedText('x'.repeat(PASTE_ATTACHMENT_THRESHOLD - 1))
90
+ })
91
+
92
+ // `false` is the whole contract: the caller does NOT preventDefault, so
93
+ // ADR-148 D8's paste handling runs exactly as it did before this ADR.
94
+ expect(intercepted).toBe(false)
95
+ expect(upload).not.toHaveBeenCalled()
96
+ expect(result.current.attachments).toHaveLength(0)
97
+ })
98
+
99
+ it('AC#6: a paste at exactly 2,500 characters becomes an attachment', async () => {
100
+ const { upload, seen } = recordingUploader()
101
+ const { result } = renderHook(() => useAttachments({ upload }))
102
+ const text = 'y'.repeat(PASTE_ATTACHMENT_THRESHOLD)
103
+
104
+ act(() => {
105
+ expect(result.current.addPastedText(text)).toBe(true)
106
+ })
107
+
108
+ // The boundary is inclusive, and it is where it says it is.
109
+ expect(shouldAttachPaste(text)).toBe(true)
110
+ expect(shouldAttachPaste(text.slice(1))).toBe(false)
111
+
112
+ await waitFor(() => {
113
+ expect(result.current.attachments[0]?.state).toBe('ready')
114
+ })
115
+ // Derived and honest — never a fabricated filename.
116
+ expect(result.current.attachments[0]?.name).toBe(pastedTextFilename(1))
117
+ // It went through the FILE path: a Blob, the same uploader, no second
118
+ // mechanism. That is the whole reason D5 is cheap.
119
+ expect(seen[0]?.blob).toBeInstanceOf(Blob)
120
+ expect(seen[0]?.blob.type).toBe('text/plain')
121
+ expect(await seen[0]?.blob.text()).toBe(text)
122
+ })
123
+
124
+ it('AC#6: "Insert as text instead" restores the paste byte for byte', async () => {
125
+ const { upload } = recordingUploader()
126
+ const { result } = renderHook(() => useAttachments({ upload }))
127
+ // Deliberately awkward content: a trailing newline, a tab, a non-BMP
128
+ // codepoint, and CRLF. "Byte for byte" is only a claim worth testing on
129
+ // text that a lossy round trip would visibly damage.
130
+ const text = `${'z'.repeat(PASTE_ATTACHMENT_THRESHOLD)}\r\n\tEnd \u{1F600}\n`
131
+
132
+ act(() => {
133
+ result.current.addPastedText(text)
134
+ })
135
+ await waitFor(() => {
136
+ expect(result.current.attachments[0]?.state).toBe('ready')
137
+ })
138
+
139
+ const localId = result.current.attachments[0]?.localId ?? ''
140
+ let restored: string | null = null
141
+ act(() => {
142
+ restored = result.current.insertAsText(localId)
143
+ })
144
+
145
+ expect(restored).toBe(text)
146
+ expect(result.current.attachments).toHaveLength(0)
147
+ })
148
+
149
+ it('names successive pastes in order', async () => {
150
+ const { upload } = recordingUploader()
151
+ const { result } = renderHook(() => useAttachments({ upload }))
152
+ const text = 'q'.repeat(PASTE_ATTACHMENT_THRESHOLD)
153
+
154
+ act(() => {
155
+ result.current.addPastedText(text)
156
+ result.current.addPastedText(text)
157
+ })
158
+ await waitFor(() => {
159
+ expect(result.current.attachments).toHaveLength(2)
160
+ })
161
+ expect(result.current.attachments.map((a) => a.name)).toEqual([
162
+ pastedTextFilename(1),
163
+ pastedTextFilename(2),
164
+ ])
165
+ })
166
+
167
+ it('leaves an over-large paste as prose rather than dropping it', async () => {
168
+ // The refusal path must not eat the text: `false` means the caller lets the
169
+ // paste land in the body, which is the honest failure for "too big to
170
+ // attach".
171
+ const { upload } = recordingUploader()
172
+ const { result } = renderHook(() =>
173
+ useAttachments({ upload, maxFileBytes: PASTE_ATTACHMENT_THRESHOLD - 1 }),
174
+ )
175
+ act(() => {
176
+ expect(result.current.addPastedText('w'.repeat(PASTE_ATTACHMENT_THRESHOLD))).toBe(false)
177
+ })
178
+ expect(result.current.attachments).toHaveLength(0)
179
+ expect(upload).not.toHaveBeenCalled()
180
+ })
181
+ })
182
+
183
+ describe('D1 — each file is its own unit', () => {
184
+ it('reports per-file progress and hands back tickets in order', async () => {
185
+ const progressSinks: ((f: number) => void)[] = []
186
+ const upload = vi.fn(
187
+ (blob: Blob, opts: { filename: string; onProgress: (f: number) => void }) => {
188
+ progressSinks.push(opts.onProgress)
189
+ return new Promise<AttachmentTicketLike>((resolve) => {
190
+ setTimeout(() => {
191
+ resolve(ticket({ id: `tup_${opts.filename}`, name: opts.filename, sizeBytes: blob.size }))
192
+ }, 0)
193
+ })
194
+ },
195
+ )
196
+ const { result } = renderHook(() => useAttachments({ upload }))
197
+
198
+ act(() => {
199
+ result.current.addFiles([
200
+ new File(['a'], 'a.png', { type: 'image/png' }),
201
+ new File(['bb'], 'b.png', { type: 'image/png' }),
202
+ ])
203
+ })
204
+ expect(result.current.attachments).toHaveLength(2)
205
+ expect(result.current.isUploading).toBe(true)
206
+
207
+ act(() => {
208
+ progressSinks[0]?.(0.5)
209
+ })
210
+ expect(result.current.attachments[0]?.progress).toBe(0.5)
211
+ // Progress on one file does not touch the other.
212
+ expect(result.current.attachments[1]?.progress).toBe(0)
213
+
214
+ await waitFor(() => {
215
+ expect(result.current.isUploading).toBe(false)
216
+ })
217
+ expect(result.current.ticketIds).toEqual(['tup_a.png', 'tup_b.png'])
218
+ })
219
+
220
+ it('cancel aborts the transfer, not just the chip', async () => {
221
+ let captured: AbortSignal | undefined
222
+ const upload = vi.fn(
223
+ (_blob: Blob, opts: { signal: AbortSignal }) =>
224
+ new Promise<AttachmentTicketLike>((_resolve, reject) => {
225
+ captured = opts.signal
226
+ opts.signal.addEventListener('abort', () => {
227
+ reject(new DOMException('aborted', 'AbortError'))
228
+ })
229
+ }),
230
+ )
231
+ const { result } = renderHook(() => useAttachments({ upload }))
232
+
233
+ act(() => {
234
+ result.current.addFiles([new File(['a'], 'a.png', { type: 'image/png' })])
235
+ })
236
+ const localId = result.current.attachments[0]?.localId ?? ''
237
+ act(() => {
238
+ result.current.remove(localId)
239
+ })
240
+
241
+ expect(captured?.aborted).toBe(true)
242
+ // And an abort is not a failure: no red chip is left behind.
243
+ await waitFor(() => {
244
+ expect(result.current.attachments).toHaveLength(0)
245
+ })
246
+ })
247
+
248
+ it('skips a refused file and keeps going, rather than dropping the rest silently', async () => {
249
+ // `break` here would upload #1, report #2, and make #3-#5 vanish with no
250
+ // message — D3 requires every refusal be named and shown.
251
+ const { upload } = recordingUploader()
252
+ const errors: string[] = []
253
+ const { result } = renderHook(() =>
254
+ useAttachments({
255
+ upload,
256
+ maxFileBytes: 10,
257
+ onError: (m) => {
258
+ errors.push(m)
259
+ },
260
+ }),
261
+ )
262
+ act(() => {
263
+ result.current.addFiles([
264
+ new File(['ok'], 'a.png', { type: 'image/png' }),
265
+ new File(['way too long for the cap'], 'big.png', { type: 'image/png' }),
266
+ new File(['ok'], 'c.png', { type: 'image/png' }),
267
+ ])
268
+ })
269
+ await waitFor(() => {
270
+ expect(result.current.attachments).toHaveLength(2)
271
+ })
272
+ expect(result.current.attachments.map((a) => a.name)).toEqual(['a.png', 'c.png'])
273
+ expect(errors).toHaveLength(1)
274
+ expect(errors[0]).toContain('big.png')
275
+ })
276
+
277
+ it('refuses the eleventh file in a single drop', async () => {
278
+ const { upload } = recordingUploader()
279
+ const errors: string[] = []
280
+ const { result } = renderHook(() =>
281
+ useAttachments({
282
+ upload,
283
+ onError: (m) => {
284
+ errors.push(m)
285
+ },
286
+ }),
287
+ )
288
+
289
+ const files = Array.from(
290
+ { length: 11 },
291
+ (_, i) => new File(['x'], `f${String(i)}.png`, { type: 'image/png' }),
292
+ )
293
+ act(() => {
294
+ result.current.addFiles(files)
295
+ })
296
+
297
+ // The ceiling has to see the batch it is inside — eleven at once must stop
298
+ // at ten, not start all eleven against a list that has not re-rendered.
299
+ expect(result.current.attachments).toHaveLength(10)
300
+ expect(errors[0]).toContain('up to 10')
301
+ })
302
+
303
+ it('surfaces the server’s own message on a refusal', async () => {
304
+ const upload = vi.fn(() => Promise.reject(new Error('Files can be up to 25 MB.')))
305
+ const errors: string[] = []
306
+ const { result } = renderHook(() =>
307
+ useAttachments({
308
+ upload,
309
+ onError: (m) => {
310
+ errors.push(m)
311
+ },
312
+ }),
313
+ )
314
+ act(() => {
315
+ result.current.addFiles([new File(['x'], 'big.png', { type: 'image/png' })])
316
+ })
317
+ await waitFor(() => {
318
+ expect(result.current.attachments[0]?.state).toBe('failed')
319
+ })
320
+ // Not re-worded here: the server already said it in the product's voice.
321
+ expect(result.current.attachments[0]?.error).toBe('Files can be up to 25 MB.')
322
+ expect(errors).toEqual(['Files can be up to 25 MB.'])
323
+ })
324
+ })
325
+
326
+ describe('the tray', () => {
327
+ it('offers the undo only on a pasted chip, and only when a handler exists', async () => {
328
+ const onInsertAsText = vi.fn()
329
+ render(
330
+ <AttachmentTray
331
+ attachments={[
332
+ {
333
+ localId: 'a',
334
+ name: 'pasted-text-1.txt',
335
+ sizeBytes: 3000,
336
+ kind: 'file',
337
+ state: 'ready',
338
+ progress: 1,
339
+ ticketId: 'tup_1',
340
+ pastedText: 'hello',
341
+ },
342
+ {
343
+ localId: 'b',
344
+ name: 'shot.png',
345
+ sizeBytes: 1200,
346
+ kind: 'image',
347
+ state: 'ready',
348
+ progress: 1,
349
+ ticketId: 'tup_2',
350
+ },
351
+ ]}
352
+ onRemove={vi.fn()}
353
+ onInsertAsText={onInsertAsText}
354
+ />,
355
+ )
356
+
357
+ // One chip has something to restore; the other does not, so it gets no
358
+ // action rather than a disabled one.
359
+ const restore = screen.getAllByRole('button', { name: 'Insert as text instead' })
360
+ expect(restore).toHaveLength(1)
361
+
362
+ await userEvent.click(restore[0] as HTMLElement)
363
+ expect(onInsertAsText).toHaveBeenCalledWith('a')
364
+ })
365
+
366
+ it('shows a CHARACTER count on a pasted chip, not a byte size (D5)', () => {
367
+ render(
368
+ <AttachmentTray
369
+ attachments={[
370
+ {
371
+ localId: 'a',
372
+ name: 'pasted-text-1.txt',
373
+ sizeBytes: 3000,
374
+ kind: 'file',
375
+ state: 'ready',
376
+ progress: 1,
377
+ ticketId: 'tup_1',
378
+ pastedText: 'x'.repeat(2600),
379
+ charCount: 2600,
380
+ },
381
+ ]}
382
+ onRemove={vi.fn()}
383
+ />,
384
+ )
385
+ // D5 says "with the character count shown on the chip" — the person pasted
386
+ // characters, and for anything non-ASCII a byte size is a different number.
387
+ expect(screen.getByText('2,600 characters')).toBeInTheDocument()
388
+ expect(screen.queryByText('2.9 KB')).toBeNull()
389
+ })
390
+
391
+ it('carries the character count through the paste path end to end', async () => {
392
+ const { upload } = recordingUploader()
393
+ const { result } = renderHook(() => useAttachments({ upload }))
394
+ const text = 'e'.repeat(PASTE_ATTACHMENT_THRESHOLD + 17)
395
+ act(() => {
396
+ result.current.addPastedText(text)
397
+ })
398
+ await waitFor(() => {
399
+ expect(result.current.attachments[0]?.state).toBe('ready')
400
+ })
401
+ expect(result.current.attachments[0]?.charCount).toBe(text.length)
402
+ })
403
+
404
+ it('exposes upload progress as a real progressbar', () => {
405
+ render(
406
+ <AttachmentTray
407
+ attachments={[
408
+ {
409
+ localId: 'a',
410
+ name: 'shot.png',
411
+ sizeBytes: 1200,
412
+ kind: 'image',
413
+ state: 'uploading',
414
+ progress: 0.42,
415
+ },
416
+ ]}
417
+ onRemove={vi.fn()}
418
+ />,
419
+ )
420
+ const bar = screen.getByRole('progressbar', { name: 'Uploading shot.png' })
421
+ expect(bar).toHaveAttribute('aria-valuenow', '42')
422
+ })
423
+
424
+ it('labels the remove control by what it will do', () => {
425
+ render(
426
+ <AttachmentTray
427
+ attachments={[
428
+ {
429
+ localId: 'a',
430
+ name: 'shot.png',
431
+ sizeBytes: 1200,
432
+ kind: 'image',
433
+ state: 'uploading',
434
+ progress: 0.1,
435
+ },
436
+ ]}
437
+ onRemove={vi.fn()}
438
+ />,
439
+ )
440
+ // Cancelling an upload and removing a finished file are different acts, and
441
+ // a screen reader should hear which one this is.
442
+ expect(screen.getByRole('button', { name: 'Cancel shot.png' })).toBeInTheDocument()
443
+ })
444
+ })
445
+
446
+ describe('the render-loop guard', () => {
447
+ /**
448
+ * The failure this pins does not look like a failure.
449
+ *
450
+ * A host writing `resolveAttachmentUrl={(id) => api.attachmentUrl(brandId, …, id)}`
451
+ * — the obvious way to write it — hands over a NEW function identity on every
452
+ * render. If that identity were an effect dependency, the effect would re-run,
453
+ * set state, re-render and re-run forever: a hung tab, or a test the runner
454
+ * reports as an out-of-memory "environment" problem rather than as a bug.
455
+ *
456
+ * So both assertions are about CALL COUNT under deliberate identity churn,
457
+ * not about the resolved value.
458
+ */
459
+ it('resolves once even when the resolver identity changes every render', async () => {
460
+ // Typed to take the id, because the hook passes one — a zero-arg mock made
461
+ // `resolve(id)` a type error while the production signature was correct.
462
+ const resolve = vi.fn((_id: string) => Promise.resolve('https://example.test/a.png'))
463
+ let renders = 0
464
+
465
+ function Probe({ tick }: { tick: number }) {
466
+ renders += 1
467
+ // A fresh closure on every render, exactly as a host would write it.
468
+ const { url } = useAttachmentUrl('att_1', (id) => resolve(id))
469
+ return <span data-testid="url">{`${String(tick)}:${url ?? ''}`}</span>
470
+ }
471
+
472
+ const { rerender } = render(<Probe tick={0} />)
473
+ await waitFor(() => {
474
+ expect(screen.getByTestId('url').textContent).toContain('example.test')
475
+ })
476
+
477
+ const after = renders
478
+ rerender(<Probe tick={1} />)
479
+ rerender(<Probe tick={2} />)
480
+ await waitFor(() => {
481
+ expect(screen.getByTestId('url').textContent).toContain('2:')
482
+ })
483
+
484
+ // Three more renders, still one fetch — and the render count is bounded,
485
+ // which is what "no loop" actually means.
486
+ expect(resolve).toHaveBeenCalledTimes(1)
487
+ expect(renders).toBeLessThanOrEqual(after + 4)
488
+ })
489
+
490
+ it('re-resolves when the ATTACHMENT changes, which is the thing that identifies a fetch', async () => {
491
+ const resolve = vi.fn((id: string) => Promise.resolve(`https://example.test/${id}`))
492
+
493
+ function Probe({ id }: { id: string }) {
494
+ const { url } = useAttachmentUrl(id, (next) => resolve(next))
495
+ return <span data-testid="url">{url ?? ''}</span>
496
+ }
497
+
498
+ const { rerender } = render(<Probe id="att_1" />)
499
+ await waitFor(() => {
500
+ expect(screen.getByTestId('url').textContent).toBe('https://example.test/att_1')
501
+ })
502
+ rerender(<Probe id="att_2" />)
503
+ await waitFor(() => {
504
+ expect(screen.getByTestId('url').textContent).toBe('https://example.test/att_2')
505
+ })
506
+ expect(resolve).toHaveBeenCalledTimes(2)
507
+ })
508
+
509
+ it('renders the placeholder, never a collapsed box, when the URL cannot be had', async () => {
510
+ render(
511
+ <AttachmentGif
512
+ attachment={{ id: 'att_x', kind: 'gif', name: 'nope.mp4', aspectRatio: 2 }}
513
+ resolveUrl={() => Promise.resolve(null)}
514
+ />,
515
+ )
516
+ // The placeholder keeps the DECLARED ratio, so the comment's layout is the
517
+ // same whether the bytes arrived or not.
518
+ await waitFor(() => {
519
+ const box = document.querySelector('span[style*="aspect-ratio"]')
520
+ expect(box).not.toBeNull()
521
+ // jsdom normalizes the shorthand to `<w> / <h>`; what matters is that the
522
+ // box declares the ratio at all, and that it is the one the attachment
523
+ // carried rather than the 16/10 fallback.
524
+ expect((box as HTMLElement).style.aspectRatio.replace(/\s/g, '')).toBe('2/1')
525
+ expect(box?.querySelector('svg')).not.toBeNull()
526
+ })
527
+ })
528
+ })
529
+
530
+ describe('AC#5 — a GIF under prefers-reduced-motion', () => {
531
+ const GIF = {
532
+ id: 'att_g',
533
+ kind: 'gif' as const,
534
+ name: 'a cat',
535
+ aspectRatio: 16 / 9,
536
+ }
537
+ const resolveUrl = () => Promise.resolve('https://media.test/cat.mp4')
538
+
539
+ it('shows a still frame with an explicit play control', async () => {
540
+ const restore = stubReducedMotion(true)
541
+ try {
542
+ render(<AttachmentGif attachment={GIF} resolveUrl={resolveUrl} />)
543
+ const video = await waitFor(() => {
544
+ const found = document.querySelector('video')
545
+ expect(found).not.toBeNull()
546
+ return found as HTMLVideoElement
547
+ })
548
+ // Not playing, and it says so in the DOM rather than only in a ref.
549
+ expect(video.hasAttribute('autoplay')).toBe(false)
550
+ // The way OUT of the still is an explicit, labelled control — the point
551
+ // of the decision, not decoration.
552
+ expect(screen.getByRole('button', { name: 'Play a cat' })).toBeInTheDocument()
553
+ } finally {
554
+ restore()
555
+ }
556
+ })
557
+
558
+ it('autoplays, with no play control, when motion is not restricted', async () => {
559
+ const restore = stubReducedMotion(false)
560
+ try {
561
+ render(<AttachmentGif attachment={GIF} resolveUrl={resolveUrl} />)
562
+ const video = await waitFor(() => {
563
+ const found = document.querySelector('video')
564
+ expect(found).not.toBeNull()
565
+ return found as HTMLVideoElement
566
+ })
567
+ expect(video.hasAttribute('autoplay')).toBe(true)
568
+ expect(screen.queryByRole('button', { name: 'Play a cat' })).toBeNull()
569
+ } finally {
570
+ restore()
571
+ }
572
+ })
573
+ })
574
+
575
+ describe('open edge #7 - an uploaded GIF under prefers-reduced-motion', () => {
576
+ it('shows one captured frame until the person explicitly plays the live image', async () => {
577
+ const restoreMotion = stubReducedMotion(true)
578
+ const drawImage = vi.fn()
579
+ const context = { drawImage } as unknown as CanvasRenderingContext2D
580
+ const getContext = vi
581
+ .spyOn(HTMLCanvasElement.prototype, 'getContext')
582
+ .mockImplementation(() => context)
583
+
584
+ try {
585
+ render(
586
+ <AttachmentImage
587
+ attachment={{
588
+ id: 'att_uploaded_gif',
589
+ kind: 'image',
590
+ mime: 'image/gif',
591
+ name: 'uploaded-loop.gif',
592
+ url: 'https://uploads.test/uploaded-loop.gif',
593
+ aspectRatio: 16 / 9,
594
+ }}
595
+ />,
596
+ )
597
+
598
+ const image = screen.getByAltText('uploaded-loop.gif') as HTMLImageElement
599
+ Object.defineProperties(image, {
600
+ naturalWidth: { configurable: true, value: 320 },
601
+ naturalHeight: { configurable: true, value: 180 },
602
+ })
603
+ fireEvent.load(image)
604
+
605
+ const canvas = document.querySelector('canvas')
606
+ expect(canvas).not.toBeNull()
607
+ await waitFor(() => {
608
+ expect(drawImage).toHaveBeenCalledTimes(1)
609
+ expect(canvas).not.toHaveAttribute('hidden')
610
+ expect(image.style.visibility).toBe('hidden')
611
+ })
612
+ expect(drawImage).toHaveBeenCalledWith(image, 0, 0, 320, 180)
613
+ expect(canvas).toHaveProperty('width', 320)
614
+ expect(canvas).toHaveProperty('height', 180)
615
+
616
+ // A second load notification must not take a second snapshot. The
617
+ // captured still is stable rather than following the animation.
618
+ fireEvent.load(image)
619
+ expect(drawImage).toHaveBeenCalledTimes(1)
620
+
621
+ fireEvent.click(screen.getByRole('button', { name: 'Play uploaded-loop.gif' }))
622
+ expect(screen.queryByRole('button', { name: 'Play uploaded-loop.gif' })).toBeNull()
623
+ expect(canvas).toHaveAttribute('hidden')
624
+ expect(image.style.visibility).toBe('visible')
625
+ } finally {
626
+ getContext.mockRestore()
627
+ restoreMotion()
628
+ }
629
+ })
630
+ })
631
+
632
+ describe('D4 — the GIF picker', () => {
633
+ it('debounces, so typing six characters costs one search and not six', async () => {
634
+ vi.useFakeTimers()
635
+ try {
636
+ const search = vi.fn(() => Promise.resolve([]))
637
+ const { rerender } = render(<GifPicker search={search} onPick={vi.fn()} />)
638
+
639
+ // Simulate the keystrokes as controlled-input updates rather than through
640
+ // userEvent, which does not cooperate with fake timers.
641
+ for (const q of ['s', 'sh', 'shi', 'ship', 'shipp', 'shippi']) {
642
+ const field = screen.getByRole('textbox', { name: 'Search GIFs' })
643
+ act(() => {
644
+ fireEvent.change(field, { target: { value: q } })
645
+ })
646
+ }
647
+ // Nothing has gone out yet — the debounce is the whole point, because
648
+ // Giphy's free tier is 100 requests/hour.
649
+ expect(search).not.toHaveBeenCalled()
650
+
651
+ await act(async () => {
652
+ vi.advanceTimersByTime(GIF_SEARCH_DEBOUNCE_MS + 10)
653
+ })
654
+ expect(search).toHaveBeenCalledTimes(1)
655
+ expect(search).toHaveBeenCalledWith('shippi')
656
+ rerender(<GifPicker search={search} onPick={vi.fn()} />)
657
+ } finally {
658
+ vi.useRealTimers()
659
+ }
660
+ })
661
+
662
+ it('carries the attribution Giphy’s terms require', () => {
663
+ render(<GifPicker search={() => Promise.resolve([])} onPick={vi.fn()} />)
664
+ expect(screen.getByText('Powered by GIPHY')).toBeInTheDocument()
665
+ })
666
+
667
+ it('says "switched off" differently from "nothing matched"', async () => {
668
+ const failing = () => Promise.reject(new Error('503'))
669
+ render(<GifPicker search={failing} onPick={vi.fn()} />)
670
+ act(() => {
671
+ fireEvent.change(screen.getByRole('textbox', { name: 'Search GIFs' }), {
672
+ target: { value: 'cat' },
673
+ })
674
+ })
675
+ await waitFor(() => {
676
+ expect(screen.getByText("GIF search isn't available right now.")).toBeInTheDocument()
677
+ })
678
+ })
679
+ })