@lovett/ui 0.0.11 → 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 (141) 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 +5402 -71
  4. package/dist/index.js +21992 -11618
  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 +208 -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__/sparkline.test.tsx +368 -0
  27. package/src/__tests__/stat-layer.test.tsx +271 -0
  28. package/src/__tests__/stat-strip.test.tsx +175 -0
  29. package/src/__tests__/status.test.tsx +293 -0
  30. package/src/__tests__/tabs.test.tsx +303 -0
  31. package/src/__tests__/token-shape.test.ts +132 -2
  32. package/src/avatar.tsx +352 -0
  33. package/src/bar-chart.tsx +1214 -0
  34. package/src/board.tsx +658 -0
  35. package/src/chart-frame.tsx +960 -0
  36. package/src/chart-legend.tsx +304 -0
  37. package/src/chart-tooltip.tsx +267 -0
  38. package/src/code-block.tsx +62 -8
  39. package/src/delta-chip.tsx +263 -0
  40. package/src/detail/__tests__/activity-pane.test.tsx +186 -0
  41. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  42. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  43. package/src/detail/__tests__/field-row.test.tsx +357 -0
  44. package/src/detail/activity-pane.tsx +507 -0
  45. package/src/detail/calendar.tsx +355 -0
  46. package/src/detail/detail-divider.tsx +261 -0
  47. package/src/detail/detail-header.tsx +287 -0
  48. package/src/detail/detail-menu.tsx +254 -0
  49. package/src/detail/detail-surface.tsx +1110 -0
  50. package/src/detail/field-list.tsx +196 -0
  51. package/src/detail/field-row.tsx +1131 -0
  52. package/src/detail/index.ts +58 -0
  53. package/src/detail/segmented-choice.tsx +94 -0
  54. package/src/detail/types.ts +129 -0
  55. package/src/display-popover.tsx +487 -0
  56. package/src/display-store.tsx +242 -0
  57. package/src/donut-chart.tsx +988 -0
  58. package/src/dropdown-menu.tsx +290 -19
  59. package/src/filter-core/EXPORTS.md +85 -0
  60. package/src/filter-core/__tests__/columns.test.ts +159 -0
  61. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  62. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  63. package/src/filter-core/__tests__/operators.test.ts +235 -0
  64. package/src/filter-core/__tests__/state.test.ts +268 -0
  65. package/src/filter-core/__tests__/url.test.ts +350 -0
  66. package/src/filter-core/columns.ts +134 -0
  67. package/src/filter-core/date-utils.ts +38 -0
  68. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  69. package/src/filter-core/faceting.ts +120 -0
  70. package/src/filter-core/filter-fns.ts +335 -0
  71. package/src/filter-core/index.ts +105 -0
  72. package/src/filter-core/operators.ts +433 -0
  73. package/src/filter-core/state.ts +280 -0
  74. package/src/filter-core/types.ts +247 -0
  75. package/src/filter-core/url.ts +261 -0
  76. package/src/filter-dropdown.tsx +12 -0
  77. package/src/filter-menu.tsx +649 -0
  78. package/src/floating-drawer.tsx +19 -1
  79. package/src/gauge-ring.tsx +435 -0
  80. package/src/hue.ts +52 -0
  81. package/src/index.ts +296 -0
  82. package/src/kbd.tsx +27 -4
  83. package/src/lib/chart.ts +866 -0
  84. package/src/lib/focus.ts +43 -1
  85. package/src/lib/layer-stack.ts +30 -3
  86. package/src/lib/remark-underline.ts +443 -0
  87. package/src/lib/series.ts +169 -0
  88. package/src/line-chart.tsx +1176 -0
  89. package/src/markdown.tsx +26 -7
  90. package/src/modal.tsx +42 -18
  91. package/src/progress-ledger.tsx +304 -0
  92. package/src/ranked-bars.tsx +386 -0
  93. package/src/segmented-pill.tsx +32 -9
  94. package/src/sortable.tsx +315 -1
  95. package/src/sparkline.tsx +416 -0
  96. package/src/stat-card.tsx +376 -0
  97. package/src/stat-strip.tsx +327 -0
  98. package/src/status.tsx +215 -0
  99. package/src/styles.css +1570 -0
  100. package/src/tabs.tsx +206 -25
  101. package/src/task-card.tsx +610 -0
  102. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  103. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  104. package/src/thread/__tests__/emoji.test.ts +187 -0
  105. package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
  106. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  107. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  108. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  109. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  110. package/src/thread/__tests__/relative-time.test.ts +71 -0
  111. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  112. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  113. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  114. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  115. package/src/thread/__tests__/thread.test.tsx +662 -0
  116. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  117. package/src/thread/actions.tsx +196 -0
  118. package/src/thread/attachments.tsx +1071 -0
  119. package/src/thread/comment-body.tsx +148 -0
  120. package/src/thread/comment-tree.ts +182 -0
  121. package/src/thread/comment.tsx +915 -0
  122. package/src/thread/composer-footer.tsx +125 -0
  123. package/src/thread/composer.tsx +319 -0
  124. package/src/thread/emoji.ts +283 -0
  125. package/src/thread/index.ts +153 -0
  126. package/src/thread/link-preview.tsx +341 -0
  127. package/src/thread/markdown-format.ts +155 -0
  128. package/src/thread/markdown-spec.ts +754 -0
  129. package/src/thread/rail.tsx +372 -0
  130. package/src/thread/reactions.tsx +415 -0
  131. package/src/thread/relative-time.tsx +94 -0
  132. package/src/thread/rich-composer-impl.tsx +1601 -0
  133. package/src/thread/rich-composer.tsx +195 -0
  134. package/src/thread/scroll-caret.ts +37 -0
  135. package/src/thread/suggestion-list.tsx +182 -0
  136. package/src/thread/thread.tsx +718 -0
  137. package/src/thread/types.ts +218 -0
  138. package/src/thread/use-attachments.ts +598 -0
  139. package/src/thread/use-now.ts +73 -0
  140. package/src/thread/use-thread.ts +316 -0
  141. package/src/tokens.css +112 -0
@@ -0,0 +1,662 @@
1
+ /**
2
+ * Thread — the behaviours ADR-147 actually promises.
3
+ *
4
+ * Written against the acceptance criteria rather than the happy path. The
5
+ * clock is frozen by passing `now`, so nothing here is time-dependent
6
+ * (CLAUDE.md §7) and the ticker never starts.
7
+ */
8
+
9
+ import { describe, expect, it, vi } from 'vitest'
10
+ import { fireEvent, render, screen, within } from '@testing-library/react'
11
+ import { Thread } from '../thread'
12
+ import { REACTION_META } from '../reactions'
13
+ import type { ThreadComment } from '../types'
14
+ import { FIXTURE_NOW, LONG_BODY, THREAD_FIXTURE } from './fixtures/thread-fixture'
15
+
16
+ function textarea(label: string): HTMLTextAreaElement {
17
+ const node = document.querySelector<HTMLTextAreaElement>(`textarea[aria-label="${label}"]`)
18
+ expect(node).not.toBeNull()
19
+ if (node === null) throw new Error(`no textarea ${label}`)
20
+ return node
21
+ }
22
+
23
+ function commentBox(id: string): HTMLElement {
24
+ const node = document.querySelector<HTMLElement>(`[data-comment-id="${id}"]`)
25
+ expect(node).not.toBeNull()
26
+ if (node === null) throw new Error(`no comment ${id}`)
27
+ return node
28
+ }
29
+
30
+ /**
31
+ * The byline row of one comment.
32
+ *
33
+ * Scoping to it matters now that the reply composer renders "Replying to
34
+ * <name>": a display name legitimately appears twice inside a comment box, and
35
+ * a bare `getByText` on the name would be ambiguous. The composer copy is
36
+ * clipped and `aria-hidden` while closed, so this is a test-ergonomics
37
+ * correction rather than a real duplicate.
38
+ */
39
+ function byline(id: string): HTMLElement {
40
+ const node = commentBox(id).querySelector<HTMLElement>('[data-thread-byline]')
41
+ expect(node).not.toBeNull()
42
+ if (node === null) throw new Error(`no byline ${id}`)
43
+ return node
44
+ }
45
+
46
+ /**
47
+ * THE live region. One per thread, always mounted, so this is a `getBy` rather
48
+ * than a `queryBy` even before it has said anything.
49
+ *
50
+ * The text is read through `spoken()` because the region alternates a trailing
51
+ * NBSP to force a re-announcement when two consecutive messages are identical
52
+ * — every screen reader trims it, so a test that asserted the raw string would
53
+ * be asserting an implementation detail no user can hear.
54
+ */
55
+ function liveRegion(): HTMLElement {
56
+ return screen.getByRole('status')
57
+ }
58
+
59
+ function spoken(): string {
60
+ return (liveRegion().textContent ?? '').replace(/\u{00A0}/gu, '').trim()
61
+ }
62
+
63
+ function renderThread(overrides: Partial<React.ComponentProps<typeof Thread>> = {}) {
64
+ return render(
65
+ <Thread
66
+ comments={THREAD_FIXTURE}
67
+ now={FIXTURE_NOW}
68
+ locale="en-GB"
69
+ defaultSort="new"
70
+ {...overrides}
71
+ />,
72
+ )
73
+ }
74
+
75
+ describe('Thread — rendering', () => {
76
+ it('renders every comment in the fixture and reports the count', () => {
77
+ renderThread()
78
+ expect(screen.getByText(String(THREAD_FIXTURE.length))).toBeTruthy()
79
+ expect(screen.getByText(/Pulling the second flight forward/)).toBeTruthy()
80
+ })
81
+
82
+ it('renders a one-word body and a 2,000-character body without crashing', () => {
83
+ renderThread()
84
+ expect(LONG_BODY.length).toBe(2000)
85
+ expect(screen.getByText('Shipped.')).toBeTruthy()
86
+ expect(commentBox('c-long').textContent).toContain('The flighting plan holds')
87
+ })
88
+
89
+ it('degrades a null author and a null display name without fabricating one', () => {
90
+ renderThread()
91
+ // author === null (ON DELETE SET NULL)
92
+ expect(within(byline('c-deleted-child-a')).getByText('Deleted user')).toBeTruthy()
93
+ // a real user with only an email
94
+ expect(within(byline('c-deleted-child-b')).getByText('jordan@example.com')).toBeTruthy()
95
+ })
96
+
97
+ it('puts the exact instant in a <time datetime>, and the relative label beside it', () => {
98
+ renderThread()
99
+ const time = within(commentBox('c-reactions')).getByText('6m')
100
+ expect(time.tagName).toBe('TIME')
101
+ expect(time.getAttribute('datetime')).toBe(new Date(FIXTURE_NOW - 6 * 60_000).toISOString())
102
+ })
103
+
104
+ it('marks an edited comment', () => {
105
+ renderThread()
106
+ expect(within(commentBox('c-mentions')).getByText('· edited')).toBeTruthy()
107
+ })
108
+
109
+ it('renders a user mention and a task mention as chips, and a plain link as a link', () => {
110
+ renderThread()
111
+ const box = commentBox('c-mentions')
112
+ expect(within(box).getByText('Marcus Webb')).toBeTruthy()
113
+ expect(within(box).getByText('Slice B migration')).toBeTruthy()
114
+ const link = within(box).getByText('the plan')
115
+ expect(link.getAttribute('href')).toBe('https://example.com/plan')
116
+ })
117
+
118
+ it('renders image and file attachments', () => {
119
+ renderThread()
120
+ const box = commentBox('c-attachments')
121
+ expect(within(box).getAllByRole('img').length).toBe(2)
122
+ expect(within(box).getByText('creative-spec.pdf')).toBeTruthy()
123
+ })
124
+ })
125
+
126
+ describe('Thread — D4 deletion and orphans', () => {
127
+ it('renders a deleted parent as a tombstone WITH its replies still under it', () => {
128
+ renderThread()
129
+ const parent = commentBox('c-deleted-parent')
130
+ // The byline stands in for the author, and the body is blanked. Asserted
131
+ // by position rather than by counting, because "Replying to [deleted]" in
132
+ // the always-mounted composer is a legitimate third occurrence.
133
+ expect(within(byline('c-deleted-parent')).getByText('[deleted]')).toBeTruthy()
134
+ expect(within(parent).getByText('[deleted]', { selector: 'p' })).toBeTruthy()
135
+ // The children are nested INSIDE the tombstone, not promoted alongside it.
136
+ expect(within(parent).getByText(/Agreed, and the same applies/)).toBeTruthy()
137
+ expect(within(parent).getByText(/Still true after the rebrief/)).toBeTruthy()
138
+ })
139
+
140
+ it('labels a reply whose parent is not on this page', () => {
141
+ renderThread()
142
+ expect(
143
+ within(commentBox('c-orphan')).getByText(/In reply to a comment that isn/),
144
+ ).toBeTruthy()
145
+ })
146
+ })
147
+
148
+ describe('Thread — collapse, paging and the depth cap', () => {
149
+ it('shows three replies then "Show 5 more replies"', () => {
150
+ renderThread()
151
+ const many = commentBox('c-many')
152
+ expect(within(many).getByText('Market 3 confirmed.')).toBeTruthy()
153
+ expect(within(many).queryByText('Market 4 confirmed.')).toBeNull()
154
+
155
+ fireEvent.click(within(many).getByRole('button', { name: 'Show 5 more replies' }))
156
+ expect(within(commentBox('c-many')).getByText('Market 8 confirmed.')).toBeTruthy()
157
+ })
158
+
159
+ // Collapse folds the SUBTREE, not the comment's own body. The old "byline
160
+ // renders identically in both states" rule existed to stop the row resizing
161
+ // when its body was folded away; folding the replies instead makes that
162
+ // impossible by construction, and the byline check below is what is left of
163
+ // it. There is exactly ONE collapse control per comment — the circle on the
164
+ // rail — and its name deliberately differs from the summary chip's.
165
+ it('folds a subtree into a summary and leaves the comment above it untouched', () => {
166
+ renderThread()
167
+ const before = byline('c-many').textContent
168
+ const collapse = within(commentBox('c-many')).getByRole('button', {
169
+ name: 'Collapse replies to Priya Nair',
170
+ })
171
+ fireEvent.click(collapse)
172
+
173
+ expect(byline('c-many').textContent).toBe(before)
174
+ // The comment itself is still fully readable...
175
+ expect(
176
+ within(commentBox('c-many')).getByText('Dropping the full list of markets below.'),
177
+ ).toBeTruthy()
178
+ // ...and only its replies are gone.
179
+ expect(within(commentBox('c-many')).queryByText('Market 1 confirmed.')).toBeNull()
180
+
181
+ // A summary, not a bare count: faces and a recency, behind one control.
182
+ const summary = within(commentBox('c-many')).getByRole('button', {
183
+ name: 'Show 8 replies to Priya Nair',
184
+ })
185
+ expect(summary.textContent).toContain('8 replies')
186
+ })
187
+
188
+ it('nests without a cap by default — every level of the chain renders', () => {
189
+ renderThread()
190
+ expect(screen.getByText('Level three.')).toBeTruthy()
191
+ expect(screen.getByText('Level four, past the render cap.')).toBeTruthy()
192
+ expect(screen.queryByRole('button', { name: 'Continue this thread' })).toBeNull()
193
+ })
194
+
195
+ it('caps the render when a consumer asks for one, and reaches the rest through "Continue this thread"', () => {
196
+ renderThread({ maxDepth: 2 })
197
+ expect(screen.getByText('Level three.')).toBeTruthy()
198
+ expect(screen.queryByText('Level four, past the render cap.')).toBeNull()
199
+
200
+ fireEvent.click(screen.getByRole('button', { name: 'Continue this thread' }))
201
+ expect(screen.getByText('Level four, past the render cap.')).toBeTruthy()
202
+ })
203
+
204
+ it('hands the cap to a consumer that has somewhere to navigate to', () => {
205
+ const onContinueThread = vi.fn()
206
+ renderThread({ maxDepth: 2, onContinueThread })
207
+ fireEvent.click(screen.getByRole('button', { name: 'Continue this thread' }))
208
+ expect(onContinueThread).toHaveBeenCalledWith('c-deep-2')
209
+ // The consumer owns it now, so nothing expanded in place.
210
+ expect(screen.queryByText('Level four, past the render cap.')).toBeNull()
211
+ })
212
+ })
213
+
214
+ describe('Thread — D17 optimistic send', () => {
215
+ it('shows a pending send', () => {
216
+ renderThread()
217
+ expect(within(commentBox('c-pending')).getByText('Sending…')).toBeTruthy()
218
+ })
219
+
220
+ it('announces a failed send with role="alert" and offers a retry', () => {
221
+ const onRetry = vi.fn()
222
+ renderThread({ onRetry })
223
+ const alert = within(commentBox('c-failed')).getByRole('alert')
224
+ expect(alert.textContent).toContain('This comment did not send.')
225
+ fireEvent.click(within(alert).getByRole('button', { name: 'Try again' }))
226
+ expect(onRetry).toHaveBeenCalledWith('c-failed')
227
+ })
228
+ })
229
+
230
+ describe('Thread — D8 reactions', () => {
231
+ it('reports the reaction key rather than holding state locally', () => {
232
+ const onReact = vi.fn()
233
+ renderThread({ onReact })
234
+ const chip = within(commentBox('c-reactions')).getByRole('button', { name: 'Agree — 7' })
235
+ expect(chip.getAttribute('aria-pressed')).toBe('true')
236
+ fireEvent.click(chip)
237
+ expect(onReact).toHaveBeenCalledWith('c-reactions', 'up')
238
+ })
239
+
240
+ it('offers the bounded set behind an "Add a reaction" control', () => {
241
+ const onReact = vi.fn()
242
+ renderThread({ onReact })
243
+ const box = commentBox('c-one-word')
244
+ fireEvent.click(within(box).getByRole('button', { name: 'Add a reaction' }))
245
+ fireEvent.click(within(box).getByRole('button', { name: 'Celebrate' }))
246
+ expect(onReact).toHaveBeenCalledWith('c-one-word', 'celebrate')
247
+ })
248
+ })
249
+
250
+ describe('Thread — D8 reactions are emoji, keys are not', () => {
251
+ it('draws the glyph but keeps the accessible name a WORD', () => {
252
+ const onReact = vi.fn()
253
+ renderThread({ onReact })
254
+ const chip = within(commentBox('c-reactions')).getByRole('button', { name: 'Agree — 7' })
255
+ // The glyph is what a sighted reader sees...
256
+ expect(chip.textContent).toContain(REACTION_META.up.emoji)
257
+ // ...and "party popper" is never what a screen reader says. The label is
258
+ // the meaning, not the picture.
259
+ expect(chip.getAttribute('aria-label')).toBe('Agree — 7')
260
+ })
261
+
262
+ it('still stores a bounded KEY, never the emoji literal', () => {
263
+ const onReact = vi.fn()
264
+ renderThread({ onReact })
265
+ fireEvent.click(within(commentBox('c-reactions')).getByRole('button', { name: 'Agree — 7' }))
266
+ expect(onReact).toHaveBeenCalledWith('c-reactions', 'up')
267
+ })
268
+ })
269
+
270
+ describe('Thread — the byline overflow menu', () => {
271
+ it('renders only the items whose callbacks exist', () => {
272
+ const onEdit = vi.fn()
273
+ renderThread({ onEdit })
274
+ fireEvent.click(
275
+ within(commentBox('c-one-word')).getByRole('button', {
276
+ name: "More actions for Lee Ndiaye's comment",
277
+ }),
278
+ )
279
+ expect(screen.getByRole('menuitem', { name: 'Edit' })).toBeTruthy()
280
+ expect(screen.queryByRole('menuitem', { name: 'Copy link' })).toBeNull()
281
+ // Absent, not disabled — a permanently greyed row reads as broken.
282
+ expect(screen.queryByRole('menuitem', { name: 'Delete' })).toBeNull()
283
+ })
284
+
285
+ it('marks Delete destructive and reports the comment id', () => {
286
+ const onDelete = vi.fn()
287
+ const onCopyLink = vi.fn()
288
+ renderThread({ onDelete, onCopyLink })
289
+ fireEvent.click(
290
+ within(commentBox('c-one-word')).getByRole('button', {
291
+ name: "More actions for Lee Ndiaye's comment",
292
+ }),
293
+ )
294
+ expect(screen.getByRole('menuitem', { name: 'Copy link' })).toBeTruthy()
295
+ const del = screen.getByRole('menuitem', { name: 'Delete' })
296
+ expect(del.getAttribute('data-variant')).toBe('destructive')
297
+ fireEvent.click(del)
298
+ expect(onDelete).toHaveBeenCalledWith('c-one-word')
299
+ })
300
+
301
+ it('offers no overflow at all when nothing can be done', () => {
302
+ // No callbacks and no clipboard in this environment, so the trigger must
303
+ // not render — an empty menu is worse than no menu.
304
+ renderThread()
305
+ expect(
306
+ within(commentBox('c-one-word')).queryByRole('button', {
307
+ name: "More actions for Lee Ndiaye's comment",
308
+ }),
309
+ ).toBeNull()
310
+ })
311
+
312
+ it('copies the body itself and reports a clipboard REJECTION rather than assuming success', async () => {
313
+ const writeText = vi.fn().mockRejectedValue(new Error('denied'))
314
+ Object.defineProperty(navigator, 'clipboard', {
315
+ value: { writeText },
316
+ configurable: true,
317
+ })
318
+ const onCopyText = vi.fn()
319
+ try {
320
+ renderThread({ onCopyText })
321
+ fireEvent.click(
322
+ within(commentBox('c-one-word')).getByRole('button', {
323
+ name: "More actions for Lee Ndiaye's comment",
324
+ }),
325
+ )
326
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Copy text' }))
327
+ expect(writeText).toHaveBeenCalledWith('Shipped.')
328
+ await vi.waitFor(() => {
329
+ expect(onCopyText).toHaveBeenCalledWith('c-one-word', false)
330
+ })
331
+ } finally {
332
+ Reflect.deleteProperty(navigator, 'clipboard')
333
+ }
334
+ })
335
+ })
336
+
337
+ describe('Thread — the composer', () => {
338
+ it('renders no composer at all when the surface is read-only', () => {
339
+ renderThread()
340
+ expect(screen.queryByLabelText('Add a comment')).toBeNull()
341
+ })
342
+
343
+ it('submits the root draft, and on Cmd/Ctrl+Enter', () => {
344
+ const onSubmit = vi.fn()
345
+ renderThread({ onSubmit })
346
+ const field = screen.getByLabelText('Add a comment')
347
+ fireEvent.change(field, { target: { value: ' Looks right to me. ' } })
348
+ fireEvent.keyDown(field, { key: 'Enter', metaKey: true })
349
+ expect(onSubmit).toHaveBeenCalledWith('Looks right to me.')
350
+ })
351
+
352
+ it('formats the current selection for real — the toolbar is not decoration', () => {
353
+ const onSubmit = vi.fn()
354
+ renderThread({ onSubmit })
355
+ const field = textarea('Add a comment')
356
+ fireEvent.change(field, { target: { value: 'ship it' } })
357
+ field.setSelectionRange(0, 4)
358
+ fireEvent.click(screen.getByRole('button', { name: 'Bold' }))
359
+ expect(textarea('Add a comment').value).toBe('**ship** it')
360
+ })
361
+
362
+ it('replies to a specific comment with its parentId', () => {
363
+ const onSubmit = vi.fn()
364
+ renderThread({ onSubmit })
365
+ const box = commentBox('c-one-word')
366
+ fireEvent.click(within(box).getByRole('button', { name: 'Reply to Lee Ndiaye' }))
367
+ const field = within(commentBox('c-one-word')).getByRole('textbox', {
368
+ name: 'Reply to Lee Ndiaye',
369
+ })
370
+ fireEvent.change(field, { target: { value: 'Nice.' } })
371
+ fireEvent.keyDown(field, { key: 'Enter', ctrlKey: true })
372
+ expect(onSubmit).toHaveBeenCalledWith('Nice.', 'c-one-word')
373
+ })
374
+ })
375
+
376
+ describe('Thread — sort', () => {
377
+ it('ranks by top, and switches to newest', () => {
378
+ const empty: readonly ThreadComment[] = []
379
+ const { unmount } = render(<Thread comments={empty} now={FIXTURE_NOW} />)
380
+ expect(screen.getByText(/No comments yet/)).toBeTruthy()
381
+ unmount()
382
+
383
+ renderThread({ defaultSort: 'top' })
384
+ const first = document.querySelectorAll('[data-comment-id]')[0]
385
+ expect(first?.getAttribute('data-comment-id')).toBe('c-reactions')
386
+
387
+ fireEvent.click(screen.getByRole('button', { name: 'Newest' }))
388
+ const nowFirst = document.querySelectorAll('[data-comment-id]')[0]
389
+ expect(nowFirst?.getAttribute('data-comment-id')).toBe('c-pending')
390
+ })
391
+ })
392
+
393
+ describe('Thread — HIGH 1/2: the reaction picker holds its focus', () => {
394
+ function openPicker(commentId: string): HTMLElement {
395
+ const box = commentBox(commentId)
396
+ const trigger = within(box).getByRole('button', { name: 'Add a reaction' })
397
+ fireEvent.click(trigger)
398
+ expect(trigger.getAttribute('aria-expanded')).toBe('true')
399
+ return trigger
400
+ }
401
+
402
+ it('returns focus to the trigger when a reaction is chosen', () => {
403
+ const onReact = vi.fn()
404
+ renderThread({ onReact })
405
+ const trigger = openPicker('c-one-word')
406
+
407
+ fireEvent.click(within(commentBox('c-one-word')).getByRole('button', { name: 'Celebrate' }))
408
+
409
+ expect(onReact).toHaveBeenCalledWith('c-one-word', 'celebrate')
410
+ expect(trigger.getAttribute('aria-expanded')).toBe('false')
411
+ // The button that was just pressed is now inside an aria-hidden panel. If
412
+ // focus had stayed on it, it would be sitting on an element that is not in
413
+ // the accessibility tree and the next Tab would restart from <body>.
414
+ expect(document.activeElement).toBe(trigger)
415
+ const panel = document.getElementById(trigger.getAttribute('aria-controls') ?? '')
416
+ expect(panel?.getAttribute('aria-hidden')).toBe('true')
417
+ expect(panel?.contains(document.activeElement)).toBe(false)
418
+ })
419
+
420
+ it('closes on Escape and returns focus to the trigger', () => {
421
+ const onReact = vi.fn()
422
+ renderThread({ onReact })
423
+ const trigger = openPicker('c-one-word')
424
+ const celebrate = within(commentBox('c-one-word')).getByRole('button', { name: 'Celebrate' })
425
+ celebrate.focus()
426
+
427
+ fireEvent.keyDown(celebrate, { key: 'Escape' })
428
+
429
+ expect(trigger.getAttribute('aria-expanded')).toBe('false')
430
+ expect(document.activeElement).toBe(trigger)
431
+ // Escape is a dismissal, never a choice.
432
+ expect(onReact).not.toHaveBeenCalled()
433
+ })
434
+
435
+ it('closes on Escape from the trigger itself', () => {
436
+ renderThread({ onReact: vi.fn() })
437
+ const trigger = openPicker('c-one-word')
438
+ fireEvent.keyDown(trigger, { key: 'Escape' })
439
+ expect(trigger.getAttribute('aria-expanded')).toBe('false')
440
+ })
441
+
442
+ it('dismisses on a pointer press outside WITHOUT stealing focus back', () => {
443
+ renderThread({ onReact: vi.fn() })
444
+ const trigger = openPicker('c-one-word')
445
+ // Focus is not inside the panel, so the press is choosing its own target
446
+ // and pulling focus to the trigger would fight it.
447
+ fireEvent.pointerDown(document.body)
448
+ expect(trigger.getAttribute('aria-expanded')).toBe('false')
449
+ expect(document.activeElement).not.toBe(trigger)
450
+ })
451
+
452
+ it('leaves the picker alone when the press is inside the bar', () => {
453
+ renderThread({ onReact: vi.fn() })
454
+ const trigger = openPicker('c-one-word')
455
+ fireEvent.pointerDown(trigger)
456
+ expect(trigger.getAttribute('aria-expanded')).toBe('true')
457
+ })
458
+
459
+ it('unbinds the outside listener when the picker closes', () => {
460
+ const add = vi.spyOn(document, 'addEventListener')
461
+ const remove = vi.spyOn(document, 'removeEventListener')
462
+ try {
463
+ renderThread({ onReact: vi.fn() })
464
+ const before = remove.mock.calls.filter(([type]) => type === 'pointerdown').length
465
+ const trigger = openPicker('c-one-word')
466
+ expect(add.mock.calls.filter(([type]) => type === 'pointerdown').length).toBeGreaterThan(0)
467
+ fireEvent.keyDown(trigger, { key: 'Escape' })
468
+ // Bound while open, gone the moment it closes — no idle document
469
+ // listener per comment for the life of the thread.
470
+ expect(remove.mock.calls.filter(([type]) => type === 'pointerdown').length).toBe(before + 1)
471
+ } finally {
472
+ add.mockRestore()
473
+ remove.mockRestore()
474
+ }
475
+ })
476
+ })
477
+
478
+ describe('Thread — HIGH 3: the polite live region', () => {
479
+ it('is rendered EMPTY on mount and kept in the DOM', () => {
480
+ renderThread()
481
+ const region = liveRegion()
482
+ expect(region.getAttribute('aria-live')).toBe('polite')
483
+ // A live region injected at the moment it has something to say is the
484
+ // documented way to get an announcement that never arrives.
485
+ expect(spoken()).toBe('')
486
+ })
487
+
488
+ it('says nothing on first render, however many comments arrive with it', () => {
489
+ // The failure this guards: every page load reading the comment count aloud.
490
+ renderThread()
491
+ expect(spoken()).toBe('')
492
+ expect(THREAD_FIXTURE.length).toBeGreaterThan(1)
493
+ })
494
+
495
+ it('announces a comment the viewer posted landing', () => {
496
+ const { rerender } = renderThread()
497
+ expect(spoken()).toBe('')
498
+
499
+ const landed = THREAD_FIXTURE.map((comment) =>
500
+ comment.id === 'c-pending' ? { ...comment, state: 'sent' as const } : comment,
501
+ )
502
+ rerender(<Thread comments={landed} now={FIXTURE_NOW} locale="en-GB" defaultSort="new" />)
503
+
504
+ expect(spoken()).toBe('Comment posted')
505
+ })
506
+
507
+ it('announces comments that arrived without the viewer posting them', () => {
508
+ const { rerender } = renderThread()
509
+ const arrived: readonly ThreadComment[] = [
510
+ ...THREAD_FIXTURE,
511
+ {
512
+ id: 'c-polled',
513
+ author: { id: 'usr_new', name: 'Ada Cole' },
514
+ bodyMd: 'Came in on the poll.',
515
+ createdAt: FIXTURE_NOW - 1000,
516
+ },
517
+ ]
518
+ rerender(<Thread comments={arrived} now={FIXTURE_NOW} locale="en-GB" defaultSort="new" />)
519
+ expect(spoken()).toBe('1 new comment')
520
+ })
521
+
522
+ it('does not mistake the viewer’s own optimistic send for someone else’s comment', () => {
523
+ const { rerender } = renderThread()
524
+ const optimistic: readonly ThreadComment[] = [
525
+ ...THREAD_FIXTURE,
526
+ {
527
+ id: 'c-mine',
528
+ author: { id: 'usr_me', name: 'Me' },
529
+ bodyMd: 'Sending this now.',
530
+ createdAt: FIXTURE_NOW,
531
+ state: 'pending',
532
+ },
533
+ ]
534
+ rerender(<Thread comments={optimistic} now={FIXTURE_NOW} locale="en-GB" defaultSort="new" />)
535
+ // In flight, and "Sending…" is already on screen — nothing to say yet.
536
+ expect(spoken()).toBe('')
537
+
538
+ const settled = optimistic.map((comment) =>
539
+ comment.id === 'c-mine' ? { ...comment, state: 'sent' as const } : comment,
540
+ )
541
+ rerender(<Thread comments={settled} now={FIXTURE_NOW} locale="en-GB" defaultSort="new" />)
542
+ expect(spoken()).toBe('Comment posted')
543
+ })
544
+
545
+ it('announces what happened to the replies on collapse and on expand', () => {
546
+ renderThread()
547
+ fireEvent.click(
548
+ within(commentBox('c-many')).getByRole('button', {
549
+ name: 'Collapse replies to Priya Nair',
550
+ }),
551
+ )
552
+ // aria-expanded reports that a state changed. It does not report what
553
+ // happened to the eight comments that just left the page.
554
+ expect(spoken()).toBe('Collapsed 8 replies')
555
+
556
+ fireEvent.click(
557
+ within(commentBox('c-many')).getByRole('button', {
558
+ name: 'Show 8 replies to Priya Nair',
559
+ }),
560
+ )
561
+ expect(spoken()).toBe('Expanded 8 replies')
562
+ })
563
+
564
+ it('re-announces two CONSECUTIVE identical messages', () => {
565
+ // Two polls, one new comment each. Both messages read "1 new comment", and
566
+ // a live region speaks on CHANGE — so written naively the second one is
567
+ // silent and someone hears about the first reply and never the second.
568
+ const { rerender } = renderThread()
569
+ const poll = (n: number): readonly ThreadComment[] => [
570
+ ...THREAD_FIXTURE,
571
+ ...Array.from({ length: n }, (_unused, index) => ({
572
+ id: `c-polled-${String(index)}`,
573
+ author: { id: 'usr_new', name: 'Ada Cole' },
574
+ bodyMd: 'Came in on the poll.',
575
+ createdAt: FIXTURE_NOW - 1000,
576
+ })),
577
+ ]
578
+
579
+ rerender(<Thread comments={poll(1)} now={FIXTURE_NOW} locale="en-GB" defaultSort="new" />)
580
+ expect(spoken()).toBe('1 new comment')
581
+ const first = liveRegion().textContent
582
+
583
+ rerender(<Thread comments={poll(2)} now={FIXTURE_NOW} locale="en-GB" defaultSort="new" />)
584
+ expect(spoken()).toBe('1 new comment')
585
+ // Same words to a listener, a different text node to the AT.
586
+ expect(liveRegion().textContent).not.toBe(first)
587
+ })
588
+ })
589
+
590
+ describe('Thread — MEDIUM 4: the comments are a real list', () => {
591
+ /** Every `<ul>` whose nearest comment frame is this one. */
592
+ function ownLists(frame: HTMLElement): HTMLElement[] {
593
+ return Array.from(frame.querySelectorAll<HTMLElement>('ul')).filter(
594
+ (list) => list.closest('[data-comment-id]') === frame,
595
+ )
596
+ }
597
+
598
+ it('puts every root in a <ul> as an <li>', () => {
599
+ renderThread()
600
+ const root = commentBox('c-reactions')
601
+ const item = root.closest('li')
602
+ expect(item).not.toBeNull()
603
+ expect(item?.parentElement?.tagName).toBe('UL')
604
+ expect(item?.parentElement?.getAttribute('role')).toBe('list')
605
+ })
606
+
607
+ it('makes every reply an <li> inside its parent comment’s own <ul>', () => {
608
+ renderThread()
609
+ const parent = commentBox('c-many')
610
+ const reply = commentBox('c-many-1')
611
+ expect(reply.tagName).toBe('LI')
612
+ const lists = ownLists(parent)
613
+ expect(lists.length).toBe(1)
614
+ expect(lists[0]?.contains(reply)).toBe(true)
615
+ // Depth is the structure: this list is INSIDE the parent's item, so a
616
+ // reader gets the nesting from the markup and not from an indent.
617
+ expect(parent.contains(lists[0] ?? null)).toBe(true)
618
+ })
619
+
620
+ it('reports an item count that matches the replies actually shown', () => {
621
+ renderThread()
622
+ const parent = commentBox('c-many')
623
+ const list = ownLists(parent)[0]
624
+ const items = Array.from(list?.children ?? []).filter((el) => el.tagName === 'LI')
625
+ expect(items.length).toBe(3)
626
+
627
+ // "Show N more" is a control, not a reply, so it stays out of the list.
628
+ const more = within(parent).getByRole('button', { name: 'Show 5 more replies' })
629
+ expect(list?.contains(more)).toBe(false)
630
+
631
+ fireEvent.click(more)
632
+ const after = ownLists(commentBox('c-many'))[0]
633
+ expect(Array.from(after?.children ?? []).filter((el) => el.tagName === 'LI').length).toBe(8)
634
+ })
635
+
636
+ it('restates role="list" on every list, because list-style:none drops it in Safari', () => {
637
+ renderThread()
638
+ const lists = Array.from(document.querySelectorAll('ul'))
639
+ expect(lists.length).toBeGreaterThan(0)
640
+ for (const list of lists) {
641
+ expect(list.getAttribute('role')).toBe('list')
642
+ // The UA list indent is what a bare <ul> would have changed; these
643
+ // classes are the only reason the geometry is untouched.
644
+ expect(list.className).toContain('list-none')
645
+ expect(list.className).toContain('m-0')
646
+ expect(list.className).toContain('p-0')
647
+ }
648
+ })
649
+
650
+ it('keeps every comment box on the same padding it had before the list markup', () => {
651
+ renderThread()
652
+ // The connector maths reads --thread-row-gap off this padding and assumes
653
+ // sibling boxes are flush; the <li> swap must not have moved it.
654
+ for (const frame of Array.from(document.querySelectorAll<HTMLElement>('[data-comment-id]'))) {
655
+ expect(frame.style.paddingBlockStart).toBe('var(--thread-row-gap)')
656
+ expect(frame.className).toBe('relative')
657
+ }
658
+ expect(commentBox('c-many-1').style.paddingInlineStart).toBe('var(--thread-indent)')
659
+ // A root has no indent of its own.
660
+ expect(commentBox('c-many').style.paddingInlineStart).toBe('')
661
+ })
662
+ })