@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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- 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
|
+
})
|