@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,799 @@
1
+ /**
2
+ * RichThreadComposer — the behaviours ADR-148 promises, under jsdom.
3
+ *
4
+ * WHAT JSDOM CANNOT DO, said plainly: it has no layout, so the bubble menu's
5
+ * placement (floating-ui) and a real IME session are not verifiable here.
6
+ * Every bubble-menu assertion below is about STATE — open or closed, which
7
+ * controls it holds, where focus is — never a pixel. Composition is exercised
8
+ * through the DOM events a real IME fires, which proves the suppression
9
+ * wiring, not the keyboard. Both go on the owner's eyeball + IME pass (AC#13).
10
+ *
11
+ * The editor is driven through `onEditor` — jsdom does not synthesize
12
+ * `beforeinput`, so "typing" is `insertContent` and pasting is ProseMirror's
13
+ * own `pasteHTML` / `pasteText`, which run the full clipboard pipeline
14
+ * (parsers, paste rules, plugins) the real event would.
15
+ *
16
+ * Deterministic on purpose (CLAUDE.md §7): the clock is faked where timing is
17
+ * asserted and frozen (`now`) where a thread is rendered.
18
+ */
19
+
20
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
21
+ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
22
+ import { useState } from 'react'
23
+ import type { Editor } from '@tiptap/core'
24
+ import RichThreadComposerImpl, {
25
+ DRAFT_DEBOUNCE_MS,
26
+ FAIL_CLOSED_NOTICE,
27
+ type RichThreadComposerImplProps,
28
+ } from '../rich-composer-impl'
29
+ import { RICH_FALLBACK_NOTICE, RichThreadComposer } from '../rich-composer'
30
+ import { serializeRichMarkdown } from '../markdown-spec'
31
+ import { Thread } from '../thread'
32
+ import {
33
+ PASTE_ATTACHMENT_THRESHOLD,
34
+ type AttachmentTicketLike,
35
+ } from '../use-attachments'
36
+
37
+ /* ── jsdom shims ──────────────────────────────────────────────────────────── */
38
+
39
+ beforeAll(() => {
40
+ // ProseMirror's coordsAtPos and floating-ui both read client rects, which
41
+ // jsdom does not implement on Range. Zero-size rects keep them from
42
+ // throwing; nothing here asserts a position.
43
+ const zeroRect = (): DOMRect => ({
44
+ x: 0,
45
+ y: 0,
46
+ width: 0,
47
+ height: 0,
48
+ top: 0,
49
+ left: 0,
50
+ right: 0,
51
+ bottom: 0,
52
+ toJSON: () => ({}),
53
+ })
54
+ const emptyRects = (): DOMRectList =>
55
+ // jsdom has no DOMRectList constructor; this literal is the structural
56
+ // stand-in ProseMirror reads (`length`, `item`, iteration). The cast is the
57
+ // only way to hand it to the DOM typings.
58
+ ({ length: 0, item: () => null, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList
59
+ if (typeof Range.prototype.getClientRects !== 'function') {
60
+ Range.prototype.getClientRects = emptyRects
61
+ }
62
+ if (typeof Range.prototype.getBoundingClientRect !== 'function') {
63
+ Range.prototype.getBoundingClientRect = zeroRect
64
+ }
65
+ if (typeof Element.prototype.getClientRects !== 'function') {
66
+ Element.prototype.getClientRects = emptyRects
67
+ }
68
+ })
69
+
70
+ afterEach(() => {
71
+ vi.useRealTimers()
72
+ })
73
+
74
+ /* ── Harness ──────────────────────────────────────────────────────────────── */
75
+
76
+ function attachmentTicket(): AttachmentTicketLike {
77
+ return {
78
+ id: 'tup_1',
79
+ kind: 'file',
80
+ name: 'pasted-text-1.txt',
81
+ sizeBytes: PASTE_ATTACHMENT_THRESHOLD,
82
+ mime: 'text/plain',
83
+ aspectRatio: null,
84
+ }
85
+ }
86
+
87
+ type HarnessProps = Partial<Omit<RichThreadComposerImplProps, 'value' | 'onChange' | 'onSubmit'>> & {
88
+ readonly initial?: string
89
+ readonly onValue?: (value: string) => void
90
+ readonly onSend?: (value: string, attachmentIds?: readonly string[]) => void
91
+ }
92
+
93
+ /**
94
+ * A controlled parent, the way `useThread` is one: `onSubmit` reads the draft
95
+ * it was RENDERED with, so a stale draft would be sent as-is. That is the
96
+ * behaviour the pending-submit effect exists to make impossible.
97
+ */
98
+ function Harness({ initial = '', onValue, onSend, ...rest }: HarnessProps) {
99
+ const [value, setValue] = useState(initial)
100
+ return (
101
+ <RichThreadComposerImpl
102
+ label="Add a comment"
103
+ value={value}
104
+ onChange={(next) => {
105
+ setValue(next)
106
+ onValue?.(next)
107
+ }}
108
+ onSubmit={(attachmentIds) => {
109
+ if (attachmentIds === undefined) onSend?.(value)
110
+ else onSend?.(value, attachmentIds)
111
+ }}
112
+ {...rest}
113
+ />
114
+ )
115
+ }
116
+
117
+ interface Mounted {
118
+ readonly editor: Editor
119
+ readonly surface: HTMLElement
120
+ }
121
+
122
+ function mount(props: HarnessProps = {}): Mounted {
123
+ let captured: Editor | null = null
124
+ render(
125
+ <Harness
126
+ {...props}
127
+ onEditor={(editor) => {
128
+ if (editor !== null) captured = editor
129
+ }}
130
+ />,
131
+ )
132
+ if (captured === null) throw new Error('the editor did not mount')
133
+ return { editor: captured, surface: surface() }
134
+ }
135
+
136
+ function surface(): HTMLElement {
137
+ return screen.getByRole('textbox', { name: 'Add a comment' })
138
+ }
139
+
140
+ function select(editor: Editor, from: number, to: number): void {
141
+ act(() => {
142
+ editor.commands.setTextSelection({ from, to })
143
+ })
144
+ }
145
+
146
+ function type(editor: Editor, markdown: string): void {
147
+ act(() => {
148
+ editor.commands.insertContent(markdown, { contentType: 'markdown' })
149
+ })
150
+ }
151
+
152
+ function markdownOf(editor: Editor): string {
153
+ return serializeRichMarkdown(editor.getJSON())
154
+ }
155
+
156
+ /**
157
+ * A paste the way a browser delivers it. ProseMirror's own `pasteText()` is
158
+ * the Shift-paste path (`preferPlain` is always true there), so the ordinary
159
+ * path needs the event itself, with a clipboardData the handler can read.
160
+ */
161
+ function paste(box: HTMLElement, data: { readonly text?: string; readonly html?: string }): void {
162
+ fireEvent.paste(box, {
163
+ clipboardData: {
164
+ getData: (type: string) => {
165
+ if (type === 'text/html') return data.html ?? ''
166
+ if (type === 'text/plain' || type === 'Text') return data.text ?? ''
167
+ return ''
168
+ },
169
+ types: [...(data.html === undefined ? [] : ['text/html']), ...(data.text === undefined ? [] : ['text/plain'])],
170
+ files: [],
171
+ },
172
+ })
173
+ }
174
+
175
+ /* ── D10 — the surface ────────────────────────────────────────────────────── */
176
+
177
+ describe('RichThreadComposer — the surface (D10)', () => {
178
+ it('renders a labelled multiline textbox — a contenteditable, not a textarea', () => {
179
+ const { surface: box } = mount()
180
+ expect(box.getAttribute('contenteditable')).toBe('true')
181
+ expect(box.getAttribute('aria-multiline')).toBe('true')
182
+ expect(box.getAttribute('aria-describedby')).not.toBeNull()
183
+ expect(document.querySelector('textarea')).toBeNull()
184
+ })
185
+
186
+ it('loads the initial markdown and serializes it back unchanged', () => {
187
+ const { editor } = mount({ initial: '**ship** it' })
188
+ const first = editor.getJSON().content?.[0]?.content?.[0]
189
+ expect(first?.marks?.map((mark) => mark.type)).toEqual(['bold'])
190
+ expect(markdownOf(editor)).toBe('**ship** it')
191
+ expect(editor.getText()).toBe('ship it')
192
+ })
193
+
194
+ it('is not editable while disabled', () => {
195
+ const { surface: box } = mount({ disabled: true })
196
+ expect(box.getAttribute('contenteditable')).toBe('false')
197
+ })
198
+
199
+ it('reserves the plain composer height from `rows`', () => {
200
+ mount({ rows: 2 })
201
+ const shell = document.querySelector<HTMLElement>('.ds-thread-rich-composer')
202
+ expect(shell?.style.getPropertyValue('--composer-rows')).toBe('2')
203
+ })
204
+
205
+ it('keeps the send button disabled on an empty document and enables it once there is content', () => {
206
+ const { editor } = mount()
207
+ const send = screen.getByRole('button', { name: 'Comment' })
208
+ expect(send).toBeDisabled()
209
+ type(editor, 'hello')
210
+ expect(screen.getByRole('button', { name: 'Comment' })).toBeEnabled()
211
+ })
212
+ })
213
+
214
+ /* ── D12 — draft timing ───────────────────────────────────────────────────── */
215
+
216
+ describe('D12 — the draft leaves on a debounce and never sends stale', () => {
217
+ it('emits serialized markdown after 300 ms, not on every transaction', () => {
218
+ vi.useFakeTimers()
219
+ const onValue = vi.fn()
220
+ const { editor } = mount({ onValue })
221
+ type(editor, '**hi**')
222
+ expect(onValue).not.toHaveBeenCalled()
223
+ act(() => {
224
+ vi.advanceTimersByTime(DRAFT_DEBOUNCE_MS - 1)
225
+ })
226
+ expect(onValue).not.toHaveBeenCalled()
227
+ act(() => {
228
+ vi.advanceTimersByTime(1)
229
+ })
230
+ expect(onValue).toHaveBeenCalledTimes(1)
231
+ expect(onValue).toHaveBeenCalledWith('**hi**')
232
+ })
233
+
234
+ it('flushes on blur', () => {
235
+ vi.useFakeTimers()
236
+ const onValue = vi.fn()
237
+ const { editor, surface: box } = mount({ onValue })
238
+ type(editor, 'draft')
239
+ fireEvent.blur(box)
240
+ expect(onValue).toHaveBeenCalledWith('draft')
241
+ })
242
+
243
+ it('Cmd/Ctrl+Enter sends the latest keystrokes, not the draft the parent last saw', async () => {
244
+ const onSend = vi.fn()
245
+ const { editor, surface: box } = mount({ onSend })
246
+ type(editor, 'Looks right to me.')
247
+ // No debounce has fired: the parent still holds ''. Sending now must
248
+ // still deliver the text.
249
+ fireEvent.keyDown(box, { key: 'Enter', metaKey: true })
250
+ await waitFor(() => {
251
+ expect(onSend).toHaveBeenCalledWith('Looks right to me.')
252
+ })
253
+ expect(onSend).toHaveBeenCalledTimes(1)
254
+ })
255
+
256
+ it('the send button does the same', async () => {
257
+ const onSend = vi.fn()
258
+ const { editor } = mount({ onSend })
259
+ type(editor, 'Via the button')
260
+ fireEvent.click(screen.getByRole('button', { name: 'Comment' }))
261
+ await waitFor(() => {
262
+ expect(onSend).toHaveBeenCalledWith('Via the button')
263
+ })
264
+ })
265
+
266
+ it('an external value replaces the document and drops a pending emission', () => {
267
+ vi.useFakeTimers()
268
+ const onChange = vi.fn()
269
+ let captured: Editor | null = null
270
+ const view = render(
271
+ <RichThreadComposerImpl
272
+ label="Add a comment"
273
+ value="first"
274
+ onChange={onChange}
275
+ onSubmit={() => {}}
276
+ onEditor={(editor) => {
277
+ if (editor !== null) captured = editor
278
+ }}
279
+ />,
280
+ )
281
+ if (captured === null) throw new Error('no editor')
282
+ const editor: Editor = captured
283
+ type(editor, ' typed')
284
+ view.rerender(
285
+ <RichThreadComposerImpl
286
+ label="Add a comment"
287
+ value=""
288
+ onChange={onChange}
289
+ onSubmit={() => {}}
290
+ />,
291
+ )
292
+ expect(editor.getText()).toBe('')
293
+ act(() => {
294
+ vi.advanceTimersByTime(DRAFT_DEBOUNCE_MS)
295
+ })
296
+ expect(onChange).not.toHaveBeenCalledWith('first typed')
297
+ })
298
+ })
299
+
300
+ /* ── D7 — the bubble menu (state, not pixels) ─────────────────────────────── */
301
+
302
+ describe('D7 — the bubble menu appears on real selections only', () => {
303
+ it('opens on a non-empty selection with the six actions, and closes when it collapses', async () => {
304
+ const { editor } = mount({ initial: 'hello world' })
305
+ select(editor, 1, 6)
306
+ const toolbar = await screen.findByRole('toolbar', { name: 'Format selection' })
307
+ expect(
308
+ within(toolbar)
309
+ .getAllByRole('button')
310
+ .map((button) => button.getAttribute('aria-label')),
311
+ ).toEqual(['Bold', 'Italic', 'Underline', 'Strikethrough', 'Code', 'Link'])
312
+ select(editor, 3, 3)
313
+ await waitFor(() => {
314
+ expect(screen.queryByRole('toolbar', { name: 'Format selection' })).toBeNull()
315
+ })
316
+ })
317
+
318
+ it('does not open on a whitespace-only selection', async () => {
319
+ const { editor } = mount({ initial: 'a b' })
320
+ select(editor, 2, 5)
321
+ await act(async () => {
322
+ await Promise.resolve()
323
+ })
324
+ expect(screen.queryByRole('toolbar', { name: 'Format selection' })).toBeNull()
325
+ })
326
+
327
+ it('never opens inside a code block', async () => {
328
+ const { editor } = mount({ initial: '```\ncode here\n```' })
329
+ select(editor, 1, 5)
330
+ await act(async () => {
331
+ await Promise.resolve()
332
+ })
333
+ expect(editor.isActive('codeBlock')).toBe(true)
334
+ expect(screen.queryByRole('toolbar', { name: 'Format selection' })).toBeNull()
335
+ })
336
+
337
+ it('is suppressed during IME composition and returns after it (D11)', async () => {
338
+ const { editor, surface: box } = mount({ initial: 'hello world' })
339
+ fireEvent.compositionStart(box)
340
+ select(editor, 1, 6)
341
+ await act(async () => {
342
+ await Promise.resolve()
343
+ })
344
+ expect(screen.queryByRole('toolbar', { name: 'Format selection' })).toBeNull()
345
+ fireEvent.compositionEnd(box)
346
+ select(editor, 1, 7)
347
+ expect(await screen.findByRole('toolbar', { name: 'Format selection' })).toBeTruthy()
348
+ })
349
+
350
+ it('applies a format from the bubble and reports it as pressed', async () => {
351
+ const { editor } = mount({ initial: 'hello world' })
352
+ select(editor, 1, 6)
353
+ const toolbar = await screen.findByRole('toolbar', { name: 'Format selection' })
354
+ fireEvent.click(within(toolbar).getByRole('button', { name: 'Bold' }))
355
+ expect(editor.isActive('bold')).toBe(true)
356
+ expect(markdownOf(editor)).toBe('**hello** world')
357
+ await waitFor(() => {
358
+ expect(
359
+ within(screen.getByRole('toolbar', { name: 'Format selection' })).getByRole('button', {
360
+ name: 'Bold',
361
+ }),
362
+ ).toHaveAttribute('aria-pressed', 'true')
363
+ })
364
+ })
365
+
366
+ it('Cmd/Ctrl+. opens it from the keyboard with focus on the first action; arrows move; Escape returns to the editor', async () => {
367
+ const { editor, surface: box } = mount({ initial: 'hello world' })
368
+ act(() => {
369
+ editor.commands.focus()
370
+ })
371
+ // TipTap defers the DOM focus to requestAnimationFrame; let it land before
372
+ // the menu takes focus, as it would have for a person already typing.
373
+ await waitFor(() => {
374
+ expect(document.activeElement).toBe(box)
375
+ })
376
+ select(editor, 1, 6)
377
+ fireEvent.keyDown(box, { key: '.', metaKey: true })
378
+ const toolbar = await screen.findByRole('toolbar', { name: 'Format selection' })
379
+ const bold = within(toolbar).getByRole('button', { name: 'Bold' })
380
+ await waitFor(() => {
381
+ expect(document.activeElement).toBe(bold)
382
+ })
383
+ fireEvent.keyDown(toolbar, { key: 'ArrowRight' })
384
+ expect(document.activeElement).toBe(within(toolbar).getByRole('button', { name: 'Italic' }))
385
+ fireEvent.keyDown(toolbar, { key: 'End' })
386
+ expect(document.activeElement).toBe(within(toolbar).getByRole('button', { name: 'Link' }))
387
+ fireEvent.keyDown(toolbar, { key: 'Escape' })
388
+ await waitFor(() => {
389
+ expect(screen.queryByRole('toolbar', { name: 'Format selection' })).toBeNull()
390
+ })
391
+ await waitFor(() => {
392
+ expect(document.activeElement).toBe(box)
393
+ })
394
+ })
395
+
396
+ it('Cmd/Ctrl+K opens the link field; a javascript: URL is refused; a bare domain is applied as https', async () => {
397
+ const { editor, surface: box } = mount({ initial: 'hello world' })
398
+ select(editor, 1, 6)
399
+ fireEvent.keyDown(box, { key: 'k', ctrlKey: true })
400
+ const field = await screen.findByRole('textbox', { name: 'Link URL' })
401
+ await waitFor(() => {
402
+ expect(document.activeElement).toBe(field)
403
+ })
404
+ fireEvent.change(field, { target: { value: 'javascript:alert(1)' } })
405
+ fireEvent.keyDown(field, { key: 'Enter' })
406
+ expect(field).toHaveAttribute('aria-invalid', 'true')
407
+ expect(editor.isActive('link')).toBe(false)
408
+ fireEvent.change(field, { target: { value: 'example.com/plan' } })
409
+ fireEvent.keyDown(field, { key: 'Enter' })
410
+ expect(editor.isActive('link')).toBe(true)
411
+ expect(markdownOf(editor)).toBe('[hello](https://example.com/plan) world')
412
+ expect(screen.queryByRole('textbox', { name: 'Link URL' })).toBeNull()
413
+ })
414
+ })
415
+
416
+ /* ── D9 — the keyboard contract ───────────────────────────────────────────── */
417
+
418
+ describe('D9 — the keyboard contract', () => {
419
+ it('Escape: closes the bubble menu first, a Suggestion popup second, cancels a reply third', async () => {
420
+ const onCancel = vi.fn()
421
+ const { editor, surface: box } = mount({
422
+ initial: 'hello world',
423
+ onCancel,
424
+ mentionItems: () => [{ id: 'usr_1', label: 'Ann', type: 'user' }],
425
+ })
426
+
427
+ // 1 — the bubble menu.
428
+ select(editor, 1, 6)
429
+ await screen.findByRole('toolbar', { name: 'Format selection' })
430
+ fireEvent.keyDown(box, { key: 'Escape' })
431
+ await waitFor(() => {
432
+ expect(screen.queryByRole('toolbar', { name: 'Format selection' })).toBeNull()
433
+ })
434
+ expect(onCancel).not.toHaveBeenCalled()
435
+
436
+ // 2 — a Suggestion popup with items to show. The `@` needs one character
437
+ // after it before the popup opens (ADR-149 D4 gave the trigger a
438
+ // threshold); a bare `@` shows nothing and so never claims Escape, which
439
+ // is the case immediately below.
440
+ select(editor, 12, 12)
441
+ act(() => {
442
+ editor.commands.insertContent(' @a')
443
+ })
444
+ await act(async () => {
445
+ await new Promise((resolve) => setTimeout(resolve, 0))
446
+ })
447
+ fireEvent.keyDown(box, { key: 'Escape' })
448
+ expect(onCancel).not.toHaveBeenCalled()
449
+
450
+ // 3 — nothing open: the reply is cancelled.
451
+ fireEvent.keyDown(box, { key: 'Escape' })
452
+ expect(onCancel).toHaveBeenCalledTimes(1)
453
+ })
454
+
455
+ it('a Suggestion with nothing to show never claims Escape', async () => {
456
+ const onCancel = vi.fn()
457
+ const { editor, surface: box } = mount({ onCancel })
458
+ act(() => {
459
+ editor.commands.insertContent('@')
460
+ })
461
+ await act(async () => {
462
+ await new Promise((resolve) => setTimeout(resolve, 0))
463
+ })
464
+ fireEvent.keyDown(box, { key: 'Escape' })
465
+ expect(onCancel).toHaveBeenCalledTimes(1)
466
+ })
467
+
468
+ it('Tab leaves the editor: not prevented, and not turned into list indentation', () => {
469
+ const { editor, surface: box } = mount({ initial: '- one\n- two' })
470
+ select(editor, 9, 9)
471
+ const notPrevented = fireEvent.keyDown(box, { key: 'Tab' })
472
+ expect(notPrevented).toBe(true)
473
+ expect(markdownOf(editor)).toBe('- one\n- two')
474
+ const shiftNotPrevented = fireEvent.keyDown(box, { key: 'Tab', shiftKey: true })
475
+ expect(shiftNotPrevented).toBe(true)
476
+ })
477
+
478
+ it.each([
479
+ ['Cmd/Ctrl+B', 'bold', { key: 'b', keyCode: 66, ctrlKey: true }],
480
+ ['Cmd/Ctrl+I', 'italic', { key: 'i', keyCode: 73, ctrlKey: true }],
481
+ ['Cmd/Ctrl+U', 'underline', { key: 'u', keyCode: 85, ctrlKey: true }],
482
+ ['Cmd/Ctrl+E', 'code', { key: 'e', keyCode: 69, ctrlKey: true }],
483
+ ['Cmd/Ctrl+Shift+X', 'strike', { key: 'X', keyCode: 88, ctrlKey: true, shiftKey: true }],
484
+ ])('%s toggles %s on the selection', (_label, mark, keys) => {
485
+ const { editor, surface: box } = mount({ initial: 'hello world' })
486
+ select(editor, 1, 6)
487
+ fireEvent.keyDown(box, keys)
488
+ expect(editor.isActive(mark)).toBe(true)
489
+ })
490
+
491
+ it('Cmd/Ctrl+Alt+1..3 set headings 1–3', () => {
492
+ const { editor, surface: box } = mount({ initial: 'title' })
493
+ select(editor, 1, 1)
494
+ fireEvent.keyDown(box, { key: '2', keyCode: 50, ctrlKey: true, altKey: true })
495
+ expect(editor.isActive('heading', { level: 2 })).toBe(true)
496
+ expect(markdownOf(editor)).toBe('## title')
497
+ })
498
+
499
+ it('--- and Enter becomes a divider (input rule)', () => {
500
+ const { editor } = mount()
501
+ act(() => {
502
+ editor.commands.insertContent('--- ')
503
+ })
504
+ // Input rules watch typed text; `insertContent` bypasses them, so the
505
+ // block toolbar's divider is the path asserted here — the same command.
506
+ fireEvent.click(screen.getByRole('button', { name: 'Divider' }))
507
+ expect(editor.getJSON().content?.some((node) => node.type === 'horizontalRule')).toBe(true)
508
+ })
509
+ })
510
+
511
+ /* ── D8 — paste ───────────────────────────────────────────────────────────── */
512
+
513
+ describe('D8 — paste, and AC#7 hostile paste is inert', () => {
514
+ it('drops script, image and javascript: links to their text content', () => {
515
+ const { editor, surface: box } = mount()
516
+ act(() => {
517
+ paste(box, {
518
+ html: '<p>safe <script>alert(1)</script><img src="x" onerror="alert(1)"><a href="javascript:alert(1)">click</a><iframe src="https://evil.example"></iframe></p>',
519
+ text: 'safe click',
520
+ })
521
+ })
522
+ expect(editor.getText()).toBe('safe click')
523
+ expect(editor.isActive('link')).toBe(false)
524
+ const markdown = markdownOf(editor)
525
+ expect(markdown).not.toMatch(/script|img|iframe|javascript:|onerror/)
526
+ expect(box.innerHTML).not.toMatch(/<script|<img|<iframe|javascript:|onerror/)
527
+ })
528
+
529
+ it('keeps an https link from pasted HTML, and only that', () => {
530
+ const { editor, surface: box } = mount()
531
+ act(() => {
532
+ paste(box, { html: '<p><a href="https://example.com/x" onclick="alert(1)">x</a></p>', text: 'x' })
533
+ })
534
+ expect(markdownOf(editor)).toBe('[x](https://example.com/x)')
535
+ })
536
+
537
+ it('a bare URL becomes a link whose text is the URL', () => {
538
+ const { editor, surface: box } = mount()
539
+ act(() => {
540
+ paste(box, { text: 'https://example.com/plan' })
541
+ })
542
+ expect(markdownOf(editor)).toBe('[https://example.com/plan](https://example.com/plan)')
543
+ })
544
+
545
+ it('plain text carrying markdown is parsed into the subset', () => {
546
+ const { editor, surface: box } = mount()
547
+ act(() => {
548
+ paste(box, { text: '**bold** and *italic*\n\n- one\n- two' })
549
+ })
550
+ expect(markdownOf(editor)).toBe('**bold** and *italic*\n\n- one\n- two')
551
+ })
552
+
553
+ it('text with out-of-subset syntax lands as literal text; nothing is dropped', () => {
554
+ const { editor, surface: box } = mount()
555
+ const table = '| a | b |\n|---|---|\n| 1 | 2 |'
556
+ act(() => {
557
+ paste(box, { text: table })
558
+ })
559
+ expect(editor.getText({ blockSeparator: '\n' })).toBe(table)
560
+ })
561
+
562
+ it('a Shift-paste keeps markdown syntax literal', () => {
563
+ const { editor, surface: box } = mount()
564
+ // ProseMirror reads Shift from the last keydown it saw.
565
+ fireEvent.keyDown(box, { key: 'Shift', keyCode: 16, shiftKey: true })
566
+ act(() => {
567
+ paste(box, { text: '**not bold**' })
568
+ })
569
+ fireEvent.keyUp(box, { key: 'Shift', keyCode: 16 })
570
+ expect(editor.isActive('bold')).toBe(false)
571
+ expect(editor.getText()).toBe('**not bold**')
572
+ })
573
+
574
+ it('ADR-151 AC#7 leaves a paste below 2,500 characters on the existing parser path', () => {
575
+ const uploadAttachment = vi.fn(() => Promise.resolve(attachmentTicket()))
576
+ const { editor, surface: box } = mount({ uploadAttachment })
577
+ const text = 'x'.repeat(PASTE_ATTACHMENT_THRESHOLD - 1)
578
+
579
+ act(() => {
580
+ paste(box, { text })
581
+ })
582
+
583
+ expect(editor.getText()).toBe(text)
584
+ expect(uploadAttachment).not.toHaveBeenCalled()
585
+ expect(screen.queryByRole('button', { name: 'Insert as text instead' })).toBeNull()
586
+ })
587
+
588
+ it('ADR-151 AC#6 turns a long paste into a mounted attachment and restores it', async () => {
589
+ const text = 'y'.repeat(PASTE_ATTACHMENT_THRESHOLD)
590
+ const uploadAttachment = vi.fn(() => Promise.resolve(attachmentTicket()))
591
+ const { editor, surface: box } = mount({
592
+ uploadAttachment,
593
+ mode: 'rich',
594
+ onModeChange: vi.fn(),
595
+ })
596
+
597
+ act(() => {
598
+ paste(box, { text })
599
+ })
600
+
601
+ expect(editor.getText()).toBe('')
602
+ const restore = await screen.findByRole('button', { name: 'Insert as text instead' })
603
+ expect(screen.getByText('2,500 characters')).toBeTruthy()
604
+ expect(screen.getByRole('button', { name: 'Switch to the plain editor' })).toBeDisabled()
605
+ fireEvent.click(restore)
606
+
607
+ expect(editor.getText()).toBe(text)
608
+ expect(screen.queryByRole('button', { name: 'Insert as text instead' })).toBeNull()
609
+ expect(screen.getByRole('button', { name: 'Switch to the plain editor' })).toBeEnabled()
610
+ })
611
+
612
+ it('lets Send be pressed during an upload, then posts the verified ticket', async () => {
613
+ let finishUpload: (ticket: AttachmentTicketLike) => void = () => {}
614
+ const uploadAttachment = vi.fn(
615
+ () =>
616
+ new Promise<AttachmentTicketLike>((resolve) => {
617
+ finishUpload = resolve
618
+ }),
619
+ )
620
+ const onSend = vi.fn()
621
+ const { editor, surface: box } = mount({ uploadAttachment, onSend })
622
+ type(editor, 'Ship with context')
623
+ act(() => {
624
+ paste(box, { text: 'z'.repeat(PASTE_ATTACHMENT_THRESHOLD) })
625
+ })
626
+
627
+ const send = screen.getByRole('button', { name: 'Comment' })
628
+ expect(send).toBeEnabled()
629
+ fireEvent.click(send)
630
+ expect(onSend).not.toHaveBeenCalled()
631
+
632
+ await act(async () => {
633
+ finishUpload(attachmentTicket())
634
+ })
635
+ await waitFor(() => {
636
+ expect(onSend).toHaveBeenCalledWith('Ship with context', ['tup_1'])
637
+ })
638
+ })
639
+ })
640
+
641
+ /* ── D3 rule 2 / D6 — fail closed, fall back ──────────────────────────────── */
642
+
643
+ describe('D3 rule 2 — unsupported markdown opens the plain composer', () => {
644
+ const TABLE = '| a | b |\n|---|---|\n| 1 | 2 |'
645
+
646
+ it('renders the plain composer with the source untouched and the notice', () => {
647
+ render(
648
+ <RichThreadComposerImpl label="Add a comment" value={TABLE} onChange={() => {}} onSubmit={() => {}} />,
649
+ )
650
+ const field = screen.getByRole('textbox', { name: 'Add a comment' })
651
+ expect(field.tagName).toBe('TEXTAREA')
652
+ expect(field).toHaveValue(TABLE)
653
+ expect(screen.getByText(FAIL_CLOSED_NOTICE)).toBeTruthy()
654
+ expect(document.querySelector('[contenteditable]')).toBeNull()
655
+ })
656
+
657
+ it.each([['#### h4'], ['<u>raw</u> html'], ['- [ ] task']])('%s does the same', (markdown) => {
658
+ render(
659
+ <RichThreadComposerImpl label="Add a comment" value={markdown} onChange={() => {}} onSubmit={() => {}} />,
660
+ )
661
+ expect(screen.getByRole('textbox', { name: 'Add a comment' }).tagName).toBe('TEXTAREA')
662
+ })
663
+
664
+ it('announces the notice once through the shell live region', async () => {
665
+ render(<RichThreadComposer label="Add a comment" value={TABLE} onChange={() => {}} onSubmit={() => {}} />)
666
+ await waitFor(() => {
667
+ expect(screen.getByRole('status').textContent).toContain(FAIL_CLOSED_NOTICE)
668
+ })
669
+ })
670
+
671
+ it('returns to the rich editor when an external reset arrives', () => {
672
+ const view = render(
673
+ <RichThreadComposerImpl label="Add a comment" value={TABLE} onChange={() => {}} onSubmit={() => {}} />,
674
+ )
675
+ expect(screen.getByRole('textbox', { name: 'Add a comment' }).tagName).toBe('TEXTAREA')
676
+ view.rerender(
677
+ <RichThreadComposerImpl label="Add a comment" value="" onChange={() => {}} onSubmit={() => {}} />,
678
+ )
679
+ expect(screen.getByRole('textbox', { name: 'Add a comment' }).getAttribute('contenteditable')).toBe('true')
680
+ })
681
+ })
682
+
683
+ describe('D5 / D6 — the shell', () => {
684
+ it('shows the plain composer, disabled, holding the draft while the chunk loads, then the editor', async () => {
685
+ // The import is held open by hand so the loading state is observable;
686
+ // in the suite it would otherwise resolve inside `render`'s own act().
687
+ let release: ((module: object) => void) | null = null
688
+ vi.resetModules()
689
+ vi.doMock(
690
+ '../rich-composer-impl',
691
+ () =>
692
+ new Promise<object>((resolve) => {
693
+ release = resolve
694
+ }),
695
+ )
696
+ try {
697
+ const { RichThreadComposer: Shell } = await import('../rich-composer')
698
+ render(<Shell label="Add a comment" value="kept draft" onChange={() => {}} onSubmit={() => {}} />)
699
+ const loading = screen.getByRole('textbox', { name: 'Add a comment' })
700
+ expect(loading.tagName).toBe('TEXTAREA')
701
+ expect(loading).toBeDisabled()
702
+ expect(loading).toHaveValue('kept draft')
703
+ const actual = await vi.importActual<object>('../rich-composer-impl')
704
+ await act(async () => {
705
+ release?.(actual)
706
+ })
707
+ await waitFor(() => {
708
+ expect(screen.getByRole('textbox', { name: 'Add a comment' }).getAttribute('contenteditable')).toBe('true')
709
+ })
710
+ expect(document.querySelector('textarea')).toBeNull()
711
+ } finally {
712
+ vi.doUnmock('../rich-composer-impl')
713
+ vi.resetModules()
714
+ }
715
+ })
716
+
717
+ it('AC#9 — with the import rejected, the plain composer renders with the draft and sends', async () => {
718
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
719
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {})
720
+ try {
721
+ vi.resetModules()
722
+ vi.doMock('../rich-composer-impl', () => {
723
+ throw new Error('Failed to fetch dynamically imported module')
724
+ })
725
+ const { RichThreadComposer: Shell } = await import('../rich-composer')
726
+ const onSubmit = vi.fn()
727
+ const onChange = vi.fn()
728
+ render(<Shell label="Add a comment" value="kept draft" onChange={onChange} onSubmit={onSubmit} />)
729
+ // The notice is both shown and spoken, so it appears twice: the visible
730
+ // line and the live region.
731
+ await waitFor(() => {
732
+ expect(screen.getByText(RICH_FALLBACK_NOTICE, { selector: 'span' })).toBeTruthy()
733
+ })
734
+ const field = screen.getByRole('textbox', { name: 'Add a comment' })
735
+ expect(field.tagName).toBe('TEXTAREA')
736
+ expect(field).toBeEnabled()
737
+ expect(field).toHaveValue('kept draft')
738
+ expect(screen.getByRole('status').textContent).toContain(RICH_FALLBACK_NOTICE)
739
+ fireEvent.change(field, { target: { value: 'kept draft, sent' } })
740
+ expect(onChange).toHaveBeenCalledWith('kept draft, sent')
741
+ fireEvent.keyDown(field, { key: 'Enter', metaKey: true })
742
+ expect(onSubmit).toHaveBeenCalledTimes(1)
743
+ expect(warn).toHaveBeenCalled()
744
+ } finally {
745
+ vi.doUnmock('../rich-composer-impl')
746
+ vi.resetModules()
747
+ warn.mockRestore()
748
+ error.mockRestore()
749
+ }
750
+ })
751
+
752
+ it('D10 — announces the character counter politely at 90 %', async () => {
753
+ render(
754
+ <RichThreadComposer
755
+ label="Add a comment"
756
+ value={'a'.repeat(19)}
757
+ maxLength={20}
758
+ onChange={() => {}}
759
+ onSubmit={() => {}}
760
+ />,
761
+ )
762
+ await waitFor(() => {
763
+ expect(screen.getByRole('status').textContent).toContain('Approaching the comment length limit: 19 of 20')
764
+ })
765
+ expect(screen.getByText('19/20')).toBeTruthy()
766
+ })
767
+ })
768
+
769
+ /* ── D5 — the Thread prop ─────────────────────────────────────────────────── */
770
+
771
+ describe('Thread composer prop (D5)', () => {
772
+ const NOW = Date.UTC(2026, 8, 7, 12, 0, 0)
773
+
774
+ it('mounts the rich editor as the root composer when asked', async () => {
775
+ render(
776
+ <Thread
777
+ comments={[]}
778
+ now={NOW}
779
+ onSubmit={() => {}}
780
+ composer="rich"
781
+ uploadAttachment={() => Promise.resolve(attachmentTicket())}
782
+ attachGif={() => Promise.resolve(attachmentTicket())}
783
+ searchGifs={() => Promise.resolve([])}
784
+ />,
785
+ )
786
+ await waitFor(() => {
787
+ expect(
788
+ screen.getByRole('textbox', { name: 'Add a comment' }).getAttribute('contenteditable'),
789
+ ).toBe('true')
790
+ })
791
+ expect(screen.getByRole('button', { name: 'Attach a file' })).toBeTruthy()
792
+ expect(screen.getByRole('button', { name: 'Choose a GIF' })).toBeTruthy()
793
+ })
794
+
795
+ it('keeps the plain textarea by default', () => {
796
+ render(<Thread comments={[]} now={NOW} onSubmit={() => {}} />)
797
+ expect(screen.getByRole('textbox', { name: 'Add a comment' }).tagName).toBe('TEXTAREA')
798
+ })
799
+ })