@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,648 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE suggestion popup, and the two triggers that open it (ADR-149).
|
|
3
|
+
*
|
|
4
|
+
* WHAT JSDOM CANNOT DO, said plainly, because the ADR's acceptance criteria
|
|
5
|
+
* are split across this file and a browser pass:
|
|
6
|
+
*
|
|
7
|
+
* - PLACEMENT. jsdom performs no layout, so floating-ui's anchoring, the
|
|
8
|
+
* flip when the popup would run off the bottom, and AC#8's
|
|
9
|
+
* "`window.scrollY` unchanged" probe are all meaningless here. Every
|
|
10
|
+
* assertion below is about STATE — open or closed, which option is active,
|
|
11
|
+
* what landed in the document, where focus is — never a pixel. AC#8 and
|
|
12
|
+
* AC#12 are the owner's browser pass.
|
|
13
|
+
* - The CONTAINMENT that makes AC#8 true is nonetheless testable in the one
|
|
14
|
+
* place a bug would live: `listScrollTop` is the arithmetic that replaces
|
|
15
|
+
* `scrollIntoView`, and it is exercised directly.
|
|
16
|
+
* - FOCUS, HALF of it. jsdom implements neither Tab's focus navigation nor
|
|
17
|
+
* focus-on-mousedown, so the two assertions that `document.activeElement`
|
|
18
|
+
* is still the editor after a Tab-insert and after a click would also pass
|
|
19
|
+
* with the `preventDefault` guards deleted. They are kept because they DO
|
|
20
|
+
* catch a regression that moves focus deliberately (a `.focus()` on an
|
|
21
|
+
* option, a portal that autofocuses), but the guards themselves are AC#12.
|
|
22
|
+
* The rest of AC#6 — that the arrows and Escape do not move focus, and
|
|
23
|
+
* that `aria-activedescendant` tracks the active option — is real here.
|
|
24
|
+
* - A real IME session (D11) is out of reach here as it was in ADR-148's
|
|
25
|
+
* suite.
|
|
26
|
+
*
|
|
27
|
+
* The editor is driven the way ADR-148's suite drives it — through `onEditor`
|
|
28
|
+
* and `insertContent`, because jsdom does not synthesize `beforeinput`. The
|
|
29
|
+
* Suggestion plugin reads the document after each transaction, so an inserted
|
|
30
|
+
* `@a` opens the popup exactly as a typed one would. Its item lookup is async
|
|
31
|
+
* (the plugin awaits an abortable fetch even for a synchronous `items`), which
|
|
32
|
+
* is why every open is awaited.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
|
36
|
+
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
37
|
+
import { useState } from 'react'
|
|
38
|
+
import type { Editor } from '@tiptap/core'
|
|
39
|
+
import RichThreadComposerImpl, {
|
|
40
|
+
type RichThreadComposerImplProps,
|
|
41
|
+
} from '../rich-composer-impl'
|
|
42
|
+
import { Thread } from '../thread'
|
|
43
|
+
import { serializeRichMarkdown } from '../markdown-spec'
|
|
44
|
+
import { listScrollTop, wrapIndex } from '../suggestion-list'
|
|
45
|
+
import type { MentionSuggestionItem } from '../types'
|
|
46
|
+
|
|
47
|
+
/* ── jsdom shims (same reasoning as the ADR-148 suite) ────────────────────── */
|
|
48
|
+
|
|
49
|
+
beforeAll(() => {
|
|
50
|
+
const zeroRect = (): DOMRect => ({
|
|
51
|
+
x: 0,
|
|
52
|
+
y: 0,
|
|
53
|
+
width: 0,
|
|
54
|
+
height: 0,
|
|
55
|
+
top: 0,
|
|
56
|
+
left: 0,
|
|
57
|
+
right: 0,
|
|
58
|
+
bottom: 0,
|
|
59
|
+
toJSON: () => ({}),
|
|
60
|
+
})
|
|
61
|
+
const emptyRects = (): DOMRectList =>
|
|
62
|
+
({ length: 0, item: () => null, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList
|
|
63
|
+
if (typeof Range.prototype.getClientRects !== 'function') {
|
|
64
|
+
Range.prototype.getClientRects = emptyRects
|
|
65
|
+
}
|
|
66
|
+
if (typeof Range.prototype.getBoundingClientRect !== 'function') {
|
|
67
|
+
Range.prototype.getBoundingClientRect = zeroRect
|
|
68
|
+
}
|
|
69
|
+
if (typeof Element.prototype.getClientRects !== 'function') {
|
|
70
|
+
Element.prototype.getClientRects = emptyRects
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
/* ── Harness ──────────────────────────────────────────────────────────────── */
|
|
75
|
+
|
|
76
|
+
const PEOPLE: readonly MentionSuggestionItem[] = [
|
|
77
|
+
{ id: 'usr_1', label: 'Ann Baptiste', type: 'user' },
|
|
78
|
+
{ id: 'usr_2', label: 'Arun Mehta', type: 'user' },
|
|
79
|
+
{ id: 'usr_3', label: 'Ada Okoye', type: 'user' },
|
|
80
|
+
{ id: 'tit_9', label: 'Audit the flighting', type: 'task_item' },
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
function findPeople(query: string): readonly MentionSuggestionItem[] {
|
|
84
|
+
const needle = query.trim().toLowerCase()
|
|
85
|
+
if (needle === '') return PEOPLE
|
|
86
|
+
return PEOPLE.filter((item) => item.label.toLowerCase().startsWith(needle))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type HarnessProps = Partial<Omit<RichThreadComposerImplProps, 'value' | 'onChange' | 'onSubmit'>>
|
|
90
|
+
|
|
91
|
+
function Harness({ ...rest }: HarnessProps) {
|
|
92
|
+
const [value, setValue] = useState('')
|
|
93
|
+
return (
|
|
94
|
+
<RichThreadComposerImpl
|
|
95
|
+
label="Add a comment"
|
|
96
|
+
value={value}
|
|
97
|
+
onChange={setValue}
|
|
98
|
+
onSubmit={() => undefined}
|
|
99
|
+
{...rest}
|
|
100
|
+
/>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface Mounted {
|
|
105
|
+
readonly editor: Editor
|
|
106
|
+
readonly surface: HTMLElement
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function mount(props: HarnessProps = {}): Mounted {
|
|
110
|
+
let captured: Editor | null = null
|
|
111
|
+
render(
|
|
112
|
+
<Harness
|
|
113
|
+
{...props}
|
|
114
|
+
onEditor={(editor) => {
|
|
115
|
+
if (editor !== null) captured = editor
|
|
116
|
+
}}
|
|
117
|
+
/>,
|
|
118
|
+
)
|
|
119
|
+
if (captured === null) throw new Error('the editor did not mount')
|
|
120
|
+
return { editor: captured, surface: screen.getByRole('textbox', { name: 'Add a comment' }) }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Let everything the editor defers actually happen.
|
|
125
|
+
*
|
|
126
|
+
* Two deferrals, both real rather than test-only: the Suggestion plugin awaits
|
|
127
|
+
* an abortable item fetch even when `items` is synchronous, and TipTap's
|
|
128
|
+
* `focus` command lands inside a `requestAnimationFrame` (it has to — focusing
|
|
129
|
+
* mid-transaction fights the browser's own selection handling). A test that
|
|
130
|
+
* asserts on either without flushing both is asserting on a half-applied
|
|
131
|
+
* state.
|
|
132
|
+
*/
|
|
133
|
+
async function settle(): Promise<void> {
|
|
134
|
+
await act(async () => {
|
|
135
|
+
await new Promise((resolve) => {
|
|
136
|
+
setTimeout(resolve, 0)
|
|
137
|
+
})
|
|
138
|
+
await new Promise((resolve) => {
|
|
139
|
+
requestAnimationFrame(() => {
|
|
140
|
+
resolve(null)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
await new Promise((resolve) => {
|
|
144
|
+
setTimeout(resolve, 0)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Insert text and let the Suggestion plugin's async item pass settle. */
|
|
150
|
+
async function typeText(editor: Editor, text: string): Promise<void> {
|
|
151
|
+
act(() => {
|
|
152
|
+
editor.commands.insertContent(text)
|
|
153
|
+
})
|
|
154
|
+
await settle()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function popup(): HTMLElement | null {
|
|
158
|
+
return screen.queryByRole('listbox')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function optionLabels(): string[] {
|
|
162
|
+
const list = popup()
|
|
163
|
+
if (list === null) return []
|
|
164
|
+
return within(list)
|
|
165
|
+
.getAllByRole('option')
|
|
166
|
+
.map((option) => option.textContent ?? '')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function activeOption(): HTMLElement | undefined {
|
|
170
|
+
const list = popup()
|
|
171
|
+
if (list === null) return undefined
|
|
172
|
+
return within(list)
|
|
173
|
+
.getAllByRole('option')
|
|
174
|
+
.find((option) => option.getAttribute('aria-selected') === 'true')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Text into an editor we have no handle on.
|
|
179
|
+
*
|
|
180
|
+
* `<Thread>` owns its composer and exposes no `onEditor`, which is the whole
|
|
181
|
+
* point of the two tests below — a seam that only works when a test reaches
|
|
182
|
+
* past the component is not wired. A paste is a real DOM input path: it runs
|
|
183
|
+
* ProseMirror's clipboard pipeline, lands a transaction, and the Suggestion
|
|
184
|
+
* plugin reads the document exactly as it would for a keystroke.
|
|
185
|
+
*/
|
|
186
|
+
async function pasteText(box: HTMLElement, text: string): Promise<void> {
|
|
187
|
+
act(() => {
|
|
188
|
+
fireEvent.paste(box, {
|
|
189
|
+
clipboardData: {
|
|
190
|
+
getData: (type: string) =>
|
|
191
|
+
type === 'text/plain' || type === 'Text' ? text : '',
|
|
192
|
+
types: ['text/plain'],
|
|
193
|
+
files: [],
|
|
194
|
+
},
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
await settle()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function markdownOf(editor: Editor): string {
|
|
201
|
+
return serializeRichMarkdown(editor.getJSON())
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* ── The pure parts (the ones jsdom can actually prove) ───────────────────── */
|
|
205
|
+
|
|
206
|
+
describe('the popup arithmetic', () => {
|
|
207
|
+
it('wraps at both ends and never leaves the list', () => {
|
|
208
|
+
expect(wrapIndex(0, 3, 1)).toBe(1)
|
|
209
|
+
expect(wrapIndex(2, 3, 1)).toBe(0)
|
|
210
|
+
expect(wrapIndex(0, 3, -1)).toBe(2)
|
|
211
|
+
expect(wrapIndex(1, 3, -1)).toBe(0)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('answers 0 for an empty list rather than a negative index', () => {
|
|
215
|
+
expect(wrapIndex(0, 0, 1)).toBe(0)
|
|
216
|
+
expect(wrapIndex(0, 0, -1)).toBe(0)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('scrolls the popup only as far as it must, and not at all when it need not', () => {
|
|
220
|
+
const view = { scrollTop: 0, clientHeight: 96 }
|
|
221
|
+
// Already visible.
|
|
222
|
+
expect(listScrollTop(view, { offsetTop: 0, offsetHeight: 32 })).toBe(0)
|
|
223
|
+
expect(listScrollTop(view, { offsetTop: 64, offsetHeight: 32 })).toBe(0)
|
|
224
|
+
// Below the fold: the smallest move that shows it whole.
|
|
225
|
+
expect(listScrollTop(view, { offsetTop: 96, offsetHeight: 32 })).toBe(32)
|
|
226
|
+
// Above it: back to the option's own top.
|
|
227
|
+
expect(listScrollTop({ scrollTop: 64, clientHeight: 96 }, { offsetTop: 32, offsetHeight: 32 })).toBe(32)
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
/* ── D4 — when the popup opens, and when it stays out of the way ──────────── */
|
|
232
|
+
|
|
233
|
+
describe('D4 — the thresholds, and the four things that mean "no"', () => {
|
|
234
|
+
it('AC#4: a bare colon is punctuation — it inserts a colon and opens nothing', async () => {
|
|
235
|
+
const { editor } = mount()
|
|
236
|
+
await typeText(editor, 'ship it:')
|
|
237
|
+
expect(popup()).toBeNull()
|
|
238
|
+
expect(markdownOf(editor)).toBe('ship it:')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('AC#4: one character after the colon is still prose, and a space ends it', async () => {
|
|
242
|
+
const { editor } = mount()
|
|
243
|
+
await typeText(editor, ':x')
|
|
244
|
+
expect(popup()).toBeNull()
|
|
245
|
+
await typeText(editor, ' ')
|
|
246
|
+
expect(popup()).toBeNull()
|
|
247
|
+
expect(markdownOf(editor)).toBe(':x')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('leaves the colons prose is full of alone: a time, a URL, a label', async () => {
|
|
251
|
+
const { editor } = mount()
|
|
252
|
+
// A colon only triggers after whitespace or the start of a block, so none
|
|
253
|
+
// of these is even a candidate — the popup never so much as flickers.
|
|
254
|
+
for (const prose of ['standup at 12:30', ' see https:', ' Note: the drop']) {
|
|
255
|
+
await typeText(editor, prose)
|
|
256
|
+
expect(popup()).toBeNull()
|
|
257
|
+
}
|
|
258
|
+
expect(markdownOf(editor)).toBe('standup at 12:30 see https: Note: the drop')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('opens on the second character after the colon', async () => {
|
|
262
|
+
const { editor } = mount()
|
|
263
|
+
await typeText(editor, ':ta')
|
|
264
|
+
expect(popup()).not.toBeNull()
|
|
265
|
+
expect(optionLabels().some((label) => label.includes(':tada:'))).toBe(true)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('closes again when the query matches nothing', async () => {
|
|
269
|
+
const { editor } = mount()
|
|
270
|
+
await typeText(editor, ':ta')
|
|
271
|
+
expect(popup()).not.toBeNull()
|
|
272
|
+
await typeText(editor, 'zzzz')
|
|
273
|
+
expect(popup()).toBeNull()
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('a bare `@` opens nothing; one character after it does', async () => {
|
|
277
|
+
const { editor } = mount({ mentionItems: findPeople })
|
|
278
|
+
await typeText(editor, '@')
|
|
279
|
+
expect(popup()).toBeNull()
|
|
280
|
+
await typeText(editor, 'a')
|
|
281
|
+
expect(popup()).not.toBeNull()
|
|
282
|
+
expect(optionLabels()).toHaveLength(4)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('a shortcode inside code stays literal — the trigger does not fire there', async () => {
|
|
286
|
+
const { editor } = mount()
|
|
287
|
+
act(() => {
|
|
288
|
+
editor.commands.toggleCodeBlock()
|
|
289
|
+
})
|
|
290
|
+
await typeText(editor, ':tad')
|
|
291
|
+
expect(popup()).toBeNull()
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('closes on blur without inserting anything', async () => {
|
|
295
|
+
const { editor, surface } = mount()
|
|
296
|
+
await typeText(editor, ':tad')
|
|
297
|
+
expect(popup()).not.toBeNull()
|
|
298
|
+
await act(async () => {
|
|
299
|
+
fireEvent.blur(surface)
|
|
300
|
+
await Promise.resolve()
|
|
301
|
+
})
|
|
302
|
+
await waitFor(() => {
|
|
303
|
+
expect(popup()).toBeNull()
|
|
304
|
+
})
|
|
305
|
+
expect(markdownOf(editor)).toBe(':tad')
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
/* ── D2 / AC#2 — an emoji is inserted as unicode ──────────────────────────── */
|
|
310
|
+
|
|
311
|
+
describe('D2 — emoji insert as unicode, never as a shortcode', () => {
|
|
312
|
+
it('AC#2: `:tad` then Enter puts the character in the document and in body_md', async () => {
|
|
313
|
+
const { editor, surface } = mount()
|
|
314
|
+
await typeText(editor, ':tad')
|
|
315
|
+
expect(activeOption()?.textContent).toContain(':tada:')
|
|
316
|
+
|
|
317
|
+
fireEvent.keyDown(surface, { key: 'Enter' })
|
|
318
|
+
await waitFor(() => {
|
|
319
|
+
expect(popup()).toBeNull()
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
const body = markdownOf(editor)
|
|
323
|
+
expect(body).toContain('\u{1F389}')
|
|
324
|
+
expect(body).not.toContain(':tada:')
|
|
325
|
+
expect(body).not.toContain(':tad')
|
|
326
|
+
expect(editor.getText()).toBe('\u{1F389} ')
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it("AC#3: the owner's `:side-eye:` resolves to the rolling-eyes glyph", async () => {
|
|
330
|
+
const { editor, surface } = mount()
|
|
331
|
+
await typeText(editor, ':side-eye')
|
|
332
|
+
expect(activeOption()?.textContent).toContain(':roll_eyes:')
|
|
333
|
+
fireEvent.keyDown(surface, { key: 'Enter' })
|
|
334
|
+
await waitFor(() => {
|
|
335
|
+
expect(popup()).toBeNull()
|
|
336
|
+
})
|
|
337
|
+
expect(markdownOf(editor)).toContain('\u{1F644}')
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('inserts mid-sentence without disturbing the text around it', async () => {
|
|
341
|
+
const { editor, surface } = mount()
|
|
342
|
+
await typeText(editor, 'shipped :tad')
|
|
343
|
+
fireEvent.keyDown(surface, { key: 'Enter' })
|
|
344
|
+
await waitFor(() => {
|
|
345
|
+
expect(popup()).toBeNull()
|
|
346
|
+
})
|
|
347
|
+
expect(markdownOf(editor)).toBe('shipped \u{1F389}')
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('Tab inserts too, and does not move focus out of the editor', async () => {
|
|
351
|
+
const { editor, surface } = mount()
|
|
352
|
+
await typeText(editor, ':roc')
|
|
353
|
+
fireEvent.keyDown(surface, { key: 'Tab' })
|
|
354
|
+
await settle()
|
|
355
|
+
expect(popup()).toBeNull()
|
|
356
|
+
expect(markdownOf(editor)).toContain('\u{1F680}')
|
|
357
|
+
expect(document.activeElement).toBe(surface)
|
|
358
|
+
})
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
/* ── D4 / AC#5 — the mention picker inserts a NODE ────────────────────────── */
|
|
362
|
+
|
|
363
|
+
describe('D4 / D6 — the mention picker', () => {
|
|
364
|
+
it('AC#5: inserts a mention node that serializes to `@[Name](user:id)`', async () => {
|
|
365
|
+
const { editor, surface } = mount({ mentionItems: findPeople })
|
|
366
|
+
await typeText(editor, 'ping @ar')
|
|
367
|
+
expect(optionLabels()).toHaveLength(1)
|
|
368
|
+
expect(optionLabels()[0]).toContain('Arun Mehta')
|
|
369
|
+
|
|
370
|
+
fireEvent.keyDown(surface, { key: 'Enter' })
|
|
371
|
+
await waitFor(() => {
|
|
372
|
+
expect(popup()).toBeNull()
|
|
373
|
+
})
|
|
374
|
+
expect(markdownOf(editor)).toBe('ping @[Arun Mehta](user:usr_2)')
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('carries the scheme through, so a task mention is not a user mention', async () => {
|
|
378
|
+
const { editor, surface } = mount({ mentionItems: findPeople })
|
|
379
|
+
await typeText(editor, '@au')
|
|
380
|
+
fireEvent.keyDown(surface, { key: 'Enter' })
|
|
381
|
+
await waitFor(() => {
|
|
382
|
+
expect(popup()).toBeNull()
|
|
383
|
+
})
|
|
384
|
+
expect(markdownOf(editor)).toBe('@[Audit the flighting](task_item:tit_9)')
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('never opens when the host supplies no list — the picker is not a data layer', async () => {
|
|
388
|
+
const { editor } = mount()
|
|
389
|
+
await typeText(editor, '@ar')
|
|
390
|
+
expect(popup()).toBeNull()
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('names the kind of each row, so a person and a task are distinguishable', async () => {
|
|
394
|
+
const { editor } = mount({ mentionItems: findPeople })
|
|
395
|
+
await typeText(editor, '@a')
|
|
396
|
+
expect(optionLabels().at(-1)).toContain('Task')
|
|
397
|
+
expect(optionLabels()[0]).toContain('Person')
|
|
398
|
+
})
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
/* ── D5 / AC#6 — the listbox that never takes focus ───────────────────────── */
|
|
402
|
+
|
|
403
|
+
describe('D5 — a listbox the editor owns and never gives focus to', () => {
|
|
404
|
+
it('AC#6: the editor keeps DOM focus through open, arrow, insert and escape', async () => {
|
|
405
|
+
const { editor, surface } = mount({ mentionItems: findPeople, onCancel: () => undefined })
|
|
406
|
+
act(() => {
|
|
407
|
+
editor.commands.focus()
|
|
408
|
+
})
|
|
409
|
+
await settle()
|
|
410
|
+
expect(document.activeElement).toBe(surface)
|
|
411
|
+
|
|
412
|
+
await typeText(editor, '@a')
|
|
413
|
+
expect(popup()).not.toBeNull()
|
|
414
|
+
expect(document.activeElement).toBe(surface)
|
|
415
|
+
|
|
416
|
+
fireEvent.keyDown(surface, { key: 'ArrowDown' })
|
|
417
|
+
expect(document.activeElement).toBe(surface)
|
|
418
|
+
|
|
419
|
+
fireEvent.keyDown(surface, { key: 'Escape' })
|
|
420
|
+
await waitFor(() => {
|
|
421
|
+
expect(popup()).toBeNull()
|
|
422
|
+
})
|
|
423
|
+
expect(document.activeElement).toBe(surface)
|
|
424
|
+
|
|
425
|
+
await typeText(editor, ' @ar')
|
|
426
|
+
fireEvent.keyDown(surface, { key: 'Enter' })
|
|
427
|
+
await settle()
|
|
428
|
+
expect(popup()).toBeNull()
|
|
429
|
+
expect(document.activeElement).toBe(surface)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('AC#6: `aria-activedescendant` on the EDITOR tracks the active option', async () => {
|
|
433
|
+
const { editor, surface } = mount({ mentionItems: findPeople })
|
|
434
|
+
expect(surface.getAttribute('aria-activedescendant')).toBeNull()
|
|
435
|
+
|
|
436
|
+
await typeText(editor, '@a')
|
|
437
|
+
const list = popup()
|
|
438
|
+
expect(list).not.toBeNull()
|
|
439
|
+
expect(surface.getAttribute('aria-controls')).toBe(list?.id)
|
|
440
|
+
|
|
441
|
+
const options = within(list as HTMLElement).getAllByRole('option')
|
|
442
|
+
expect(surface.getAttribute('aria-activedescendant')).toBe(options[0]?.id)
|
|
443
|
+
|
|
444
|
+
fireEvent.keyDown(surface, { key: 'ArrowDown' })
|
|
445
|
+
await waitFor(() => {
|
|
446
|
+
expect(surface.getAttribute('aria-activedescendant')).toBe(options[1]?.id)
|
|
447
|
+
})
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('the arrows move the active option and wrap at both ends', async () => {
|
|
451
|
+
const { editor, surface } = mount({ mentionItems: findPeople })
|
|
452
|
+
await typeText(editor, '@a')
|
|
453
|
+
expect(activeOption()?.textContent).toContain('Ann Baptiste')
|
|
454
|
+
|
|
455
|
+
fireEvent.keyDown(surface, { key: 'ArrowUp' })
|
|
456
|
+
await waitFor(() => {
|
|
457
|
+
expect(activeOption()?.textContent).toContain('Audit the flighting')
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
fireEvent.keyDown(surface, { key: 'ArrowDown' })
|
|
461
|
+
await waitFor(() => {
|
|
462
|
+
expect(activeOption()?.textContent).toContain('Ann Baptiste')
|
|
463
|
+
})
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('no option is ever focusable, and a click inserts without stealing focus', async () => {
|
|
467
|
+
const { editor, surface } = mount({ mentionItems: findPeople })
|
|
468
|
+
act(() => {
|
|
469
|
+
editor.commands.focus()
|
|
470
|
+
})
|
|
471
|
+
await typeText(editor, '@a')
|
|
472
|
+
const options = within(popup() as HTMLElement).getAllByRole('option')
|
|
473
|
+
for (const option of options) expect(option.getAttribute('tabindex')).toBe('-1')
|
|
474
|
+
|
|
475
|
+
const third = options[2]
|
|
476
|
+
if (third === undefined) throw new Error('expected a third option')
|
|
477
|
+
// A real pointer fires mousedown first; the option prevents it precisely so
|
|
478
|
+
// the contenteditable keeps the caret AND the focus.
|
|
479
|
+
fireEvent.mouseDown(third)
|
|
480
|
+
fireEvent.click(third)
|
|
481
|
+
await settle()
|
|
482
|
+
expect(popup()).toBeNull()
|
|
483
|
+
expect(markdownOf(editor)).toBe('@[Ada Okoye](user:usr_3)')
|
|
484
|
+
expect(document.activeElement).toBe(surface)
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
it('a hover makes an option active without inserting it', async () => {
|
|
488
|
+
const { editor } = mount({ mentionItems: findPeople })
|
|
489
|
+
await typeText(editor, '@a')
|
|
490
|
+
const options = within(popup() as HTMLElement).getAllByRole('option')
|
|
491
|
+
const second = options[1]
|
|
492
|
+
if (second === undefined) throw new Error('expected a second option')
|
|
493
|
+
fireEvent.mouseEnter(second)
|
|
494
|
+
await waitFor(() => {
|
|
495
|
+
expect(activeOption()).toBe(second)
|
|
496
|
+
})
|
|
497
|
+
expect(markdownOf(editor)).toBe('@a')
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it('announces the emoji itself, and hides a mention initial that only repeats the name', async () => {
|
|
501
|
+
const emoji = mount()
|
|
502
|
+
await typeText(emoji.editor, ':tad')
|
|
503
|
+
const emojiOption = within(popup() as HTMLElement).getAllByRole('option')[0]
|
|
504
|
+
const emojiGlyph = emojiOption?.firstElementChild
|
|
505
|
+
// The glyph IS the thing being chosen: hiding it would leave a
|
|
506
|
+
// screen-reader user picking between shortcodes blind.
|
|
507
|
+
expect(emojiGlyph?.getAttribute('aria-hidden')).toBeNull()
|
|
508
|
+
expect(emojiGlyph?.textContent).toBe('\u{1F389}')
|
|
509
|
+
|
|
510
|
+
cleanup()
|
|
511
|
+
|
|
512
|
+
const mention = mount({ mentionItems: findPeople })
|
|
513
|
+
await typeText(mention.editor, '@a')
|
|
514
|
+
const mentionOption = within(popup() as HTMLElement).getAllByRole('option')[0]
|
|
515
|
+
const mentionGlyph = mentionOption?.firstElementChild
|
|
516
|
+
// The initial is the label's own first letter — decoration.
|
|
517
|
+
expect(mentionGlyph?.getAttribute('aria-hidden')).toBe('true')
|
|
518
|
+
expect(mentionOption?.textContent).toContain('Ann Baptiste')
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it('says the popup opened, once — a textbox cannot carry aria-expanded', async () => {
|
|
522
|
+
const announce = vi.fn()
|
|
523
|
+
const { editor } = mount({ mentionItems: findPeople, announce })
|
|
524
|
+
await typeText(editor, '@a')
|
|
525
|
+
expect(announce).toHaveBeenCalledTimes(1)
|
|
526
|
+
expect(announce.mock.calls[0]?.[0]).toContain('4 mention suggestions')
|
|
527
|
+
|
|
528
|
+
// Filtering is not a second announcement: a count that re-speaks on every
|
|
529
|
+
// letter is worse than no count at all.
|
|
530
|
+
await typeText(editor, 'r')
|
|
531
|
+
expect(announce).toHaveBeenCalledTimes(1)
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it('drops the aria wiring the moment the popup closes', async () => {
|
|
535
|
+
const { editor, surface } = mount({ mentionItems: findPeople })
|
|
536
|
+
await typeText(editor, '@a')
|
|
537
|
+
expect(surface.getAttribute('aria-activedescendant')).not.toBeNull()
|
|
538
|
+
fireEvent.keyDown(surface, { key: 'Escape' })
|
|
539
|
+
await waitFor(() => {
|
|
540
|
+
expect(surface.getAttribute('aria-activedescendant')).toBeNull()
|
|
541
|
+
})
|
|
542
|
+
expect(surface.getAttribute('aria-controls')).toBeNull()
|
|
543
|
+
})
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
/* ── D9 / AC#7 — Escape's second slot ─────────────────────────────────────── */
|
|
547
|
+
|
|
548
|
+
describe('AC#7 — Escape closes the popup and does not cancel the reply', () => {
|
|
549
|
+
it('takes the second slot: popup first, reply composer only once nothing is open', async () => {
|
|
550
|
+
const onCancel = vi.fn()
|
|
551
|
+
const { editor, surface } = mount({ mentionItems: findPeople, onCancel })
|
|
552
|
+
|
|
553
|
+
await typeText(editor, '@a')
|
|
554
|
+
expect(popup()).not.toBeNull()
|
|
555
|
+
|
|
556
|
+
fireEvent.keyDown(surface, { key: 'Escape' })
|
|
557
|
+
await waitFor(() => {
|
|
558
|
+
expect(popup()).toBeNull()
|
|
559
|
+
})
|
|
560
|
+
expect(onCancel).not.toHaveBeenCalled()
|
|
561
|
+
// Nothing was inserted, and the text the user typed is untouched.
|
|
562
|
+
expect(markdownOf(editor)).toBe('@a')
|
|
563
|
+
|
|
564
|
+
fireEvent.keyDown(surface, { key: 'Escape' })
|
|
565
|
+
expect(onCancel).toHaveBeenCalledTimes(1)
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
it('stays closed after Escape rather than springing back on the next keystroke', async () => {
|
|
569
|
+
const { editor, surface } = mount({ mentionItems: findPeople })
|
|
570
|
+
await typeText(editor, '@a')
|
|
571
|
+
fireEvent.keyDown(surface, { key: 'Escape' })
|
|
572
|
+
await waitFor(() => {
|
|
573
|
+
expect(popup()).toBeNull()
|
|
574
|
+
})
|
|
575
|
+
await typeText(editor, 'n')
|
|
576
|
+
expect(popup()).toBeNull()
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
it('the emoji popup takes the same slot', async () => {
|
|
580
|
+
const onCancel = vi.fn()
|
|
581
|
+
const { editor, surface } = mount({ onCancel })
|
|
582
|
+
await typeText(editor, ':tad')
|
|
583
|
+
expect(popup()).not.toBeNull()
|
|
584
|
+
fireEvent.keyDown(surface, { key: 'Escape' })
|
|
585
|
+
await waitFor(() => {
|
|
586
|
+
expect(popup()).toBeNull()
|
|
587
|
+
})
|
|
588
|
+
expect(onCancel).not.toHaveBeenCalled()
|
|
589
|
+
expect(markdownOf(editor)).toBe(':tad')
|
|
590
|
+
})
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
/* ── D1 — one popup, two configurations ───────────────────────────────────── */
|
|
594
|
+
|
|
595
|
+
describe('D1 — one popup, two configurations', () => {
|
|
596
|
+
it('never shows two listboxes, whichever trigger is live', async () => {
|
|
597
|
+
const { editor } = mount({ mentionItems: findPeople })
|
|
598
|
+
await typeText(editor, '@a')
|
|
599
|
+
expect(screen.getAllByRole('listbox')).toHaveLength(1)
|
|
600
|
+
await typeText(editor, ' :tad')
|
|
601
|
+
expect(screen.getAllByRole('listbox')).toHaveLength(1)
|
|
602
|
+
expect(popup()?.getAttribute('aria-label')).toBe('Emoji')
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it('labels itself for each trigger', async () => {
|
|
606
|
+
const { editor } = mount({ mentionItems: findPeople })
|
|
607
|
+
await typeText(editor, '@a')
|
|
608
|
+
expect(popup()?.getAttribute('aria-label')).toBe('Mention someone')
|
|
609
|
+
})
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
/* ── D6 — the seam a host actually reaches ────────────────────────────────── */
|
|
613
|
+
|
|
614
|
+
describe('D6 — the host seam, on the surface a host renders', () => {
|
|
615
|
+
const NOW = Date.UTC(2026, 8, 7, 12, 0, 0)
|
|
616
|
+
|
|
617
|
+
/** The composer <Thread> renders, once the lazy rich one has replaced the plain one. */
|
|
618
|
+
async function richSurface(): Promise<HTMLElement> {
|
|
619
|
+
await waitFor(() => {
|
|
620
|
+
expect(
|
|
621
|
+
screen.getByRole('textbox', { name: 'Add a comment' }).getAttribute('contenteditable'),
|
|
622
|
+
).toBe('true')
|
|
623
|
+
})
|
|
624
|
+
return screen.getByRole('textbox', { name: 'Add a comment' })
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
it('reaches the rich composer inside <Thread>, not only the impl the tests mount', async () => {
|
|
628
|
+
// The picker is only worth having where people write. Every test above
|
|
629
|
+
// drives `RichThreadComposerImpl` directly, which would happily pass with
|
|
630
|
+
// the prop unwired between `<Thread>` and the composer it renders.
|
|
631
|
+
render(
|
|
632
|
+
<Thread comments={[]} now={NOW} onSubmit={() => undefined} composer="rich" mentionItems={findPeople} />,
|
|
633
|
+
)
|
|
634
|
+
// The composer mounts plain and swaps to the lazy rich one, so the element
|
|
635
|
+
// has to be re-queried after the swap rather than captured before it.
|
|
636
|
+
const box = await richSurface()
|
|
637
|
+
fireEvent.focus(box)
|
|
638
|
+
await pasteText(box, '@a')
|
|
639
|
+
expect(popup()).not.toBeNull()
|
|
640
|
+
expect(optionLabels()).toHaveLength(4)
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
it('opens nothing when the host supplies no directory', async () => {
|
|
644
|
+
render(<Thread comments={[]} now={NOW} onSubmit={() => undefined} composer="rich" />)
|
|
645
|
+
await pasteText(await richSurface(), '@a')
|
|
646
|
+
expect(popup()).toBeNull()
|
|
647
|
+
})
|
|
648
|
+
})
|