@remit/ui 0.0.113 → 0.0.115

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 (63) hide show
  1. package/package.json +3 -3
  2. package/src/components/app-shell-slotted.render.test.ts +83 -0
  3. package/src/components/app-shell-slotted.tsx +31 -3
  4. package/src/components/attendee-row.render.test.ts +73 -0
  5. package/src/components/attendee-row.stories.tsx +68 -0
  6. package/src/components/attendee-row.tsx +96 -0
  7. package/src/components/calendar-event-chip.render.test.ts +67 -0
  8. package/src/components/calendar-event-chip.stories.tsx +128 -0
  9. package/src/components/calendar-event-chip.tsx +116 -0
  10. package/src/components/calendar-list.render.test.ts +77 -0
  11. package/src/components/calendar-list.stories.tsx +130 -0
  12. package/src/components/calendar-list.tsx +225 -0
  13. package/src/components/calendar-toolbar.render.test.ts +69 -0
  14. package/src/components/calendar-toolbar.stories.tsx +55 -0
  15. package/src/components/calendar-toolbar.tsx +178 -0
  16. package/src/components/calendar-types.ts +135 -0
  17. package/src/components/custom-recurrence.stories.tsx +106 -0
  18. package/src/components/custom-recurrence.tsx +411 -0
  19. package/src/components/event-detail.render.test.ts +104 -0
  20. package/src/components/event-detail.stories.tsx +178 -0
  21. package/src/components/event-detail.tsx +188 -0
  22. package/src/components/event-editor-pane.tsx +54 -0
  23. package/src/components/event-editor.render.test.ts +83 -0
  24. package/src/components/event-editor.stories.tsx +99 -0
  25. package/src/components/event-editor.tsx +480 -0
  26. package/src/components/event-quick-entry.render.test.ts +40 -0
  27. package/src/components/event-quick-entry.stories.tsx +56 -0
  28. package/src/components/event-quick-entry.tsx +159 -0
  29. package/src/components/event-suggestion-card.render.test.ts +65 -0
  30. package/src/components/event-suggestion-card.stories.tsx +93 -0
  31. package/src/components/event-suggestion-card.tsx +116 -0
  32. package/src/components/flow-screen.tsx +169 -0
  33. package/src/components/intelligence-panel.stories.tsx +5 -1
  34. package/src/components/intelligence-panel.tsx +4 -0
  35. package/src/components/isolated-email-frame.tsx +1 -18
  36. package/src/components/message-list-pane.render.test.ts +2 -2
  37. package/src/components/message-list-pane.stories.tsx +1 -1
  38. package/src/components/nav-sidebar.tsx +20 -0
  39. package/src/components/popover-menu.tsx +230 -27
  40. package/src/components/pull-to-refresh.tsx +1 -19
  41. package/src/components/recurrence-scope-prompt.render.test.ts +36 -0
  42. package/src/components/recurrence-scope-prompt.stories.tsx +46 -0
  43. package/src/components/recurrence-scope-prompt.tsx +84 -0
  44. package/src/components/rich-text-correction-menu.tsx +220 -0
  45. package/src/components/rich-text-editor.stories.tsx +507 -5
  46. package/src/components/rich-text-editor.tsx +482 -83
  47. package/src/components/rich-text-spellcheck-menu.test.ts +865 -0
  48. package/src/components/rich-text-spellcheck-provider.test.ts +114 -1
  49. package/src/components/rich-text-spellcheck-provider.ts +68 -3
  50. package/src/components/rich-text-spellcheck-words.ts +168 -4
  51. package/src/components/rich-text-spellcheck-worker.ts +11 -0
  52. package/src/components/rich-text-spellcheck.test.ts +7 -0
  53. package/src/components/rich-text-spellcheck.ts +28 -2
  54. package/src/components/selection-wizard.tsx +19 -69
  55. package/src/index.ts +116 -0
  56. package/src/lib/calendar-color.ts +71 -0
  57. package/src/lib/event-phrase.test.ts +89 -0
  58. package/src/lib/event-phrase.ts +228 -0
  59. package/src/lib/recurrence.test.ts +141 -0
  60. package/src/lib/recurrence.ts +266 -0
  61. package/src/lib/use-match-media.ts +25 -0
  62. package/src/rich-text.ts +12 -0
  63. package/src/tokens.css +63 -0
@@ -0,0 +1,865 @@
1
+ /**
2
+ * Correcting a word, driven through a mounted editor: the menu opens over the
3
+ * findings the marks already hold, the suggestions arrive after it, and what
4
+ * the writer picks lands in the document as one undoable step.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
8
+ import type { JSDOM } from "jsdom";
9
+ import type {
10
+ $getRoot as getRootType,
11
+ LexicalEditor,
12
+ UNDO_COMMAND as undoCommandType,
13
+ } from "lexical";
14
+ import type {
15
+ act as reactAct,
16
+ createElement as reactCreateElement,
17
+ useEffect as reactUseEffect,
18
+ } from "react";
19
+ import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
20
+ import type { RichTextEditor as RichTextEditorType } from "./rich-text-editor.js";
21
+ import type {
22
+ CheckRequest,
23
+ CheckResponse,
24
+ Finding,
25
+ SpellcheckOptions,
26
+ SpellProvider,
27
+ SuggestRequest,
28
+ } from "./rich-text-spellcheck.js";
29
+ import {
30
+ dictionaryFor,
31
+ findMisspellings,
32
+ suggestionsFor,
33
+ } from "./rich-text-spellcheck-words.js";
34
+
35
+ const SENTENCE = "Ths report is redy today";
36
+ const IDLE_MS = 400;
37
+
38
+ class StubMarks {
39
+ readonly ranges: Range[] = [];
40
+ add(range: Range): void {
41
+ this.ranges.push(range);
42
+ }
43
+ }
44
+
45
+ interface MarkHost {
46
+ CSS: { highlights: Map<string, StubMarks> };
47
+ Highlight: typeof StubMarks;
48
+ }
49
+
50
+ let dom: JSDOM;
51
+ let container: HTMLElement;
52
+ const roots: Root[] = [];
53
+ let act: typeof reactAct;
54
+ let createElement: typeof reactCreateElement;
55
+ let useEffect: typeof reactUseEffect;
56
+ let createRoot: typeof reactCreateRoot;
57
+ let RichTextEditor: typeof RichTextEditorType;
58
+ let useLexicalComposerContext: () => [LexicalEditor];
59
+ let $getRoot: typeof getRootType;
60
+ let UNDO_COMMAND: typeof undoCommandType;
61
+ let desktopLayout = true;
62
+
63
+ const offsets = (): [number, number][] =>
64
+ (
65
+ (globalThis as unknown as MarkHost).CSS.highlights.get("spell-error")
66
+ ?.ranges ?? []
67
+ ).map((range) => [range.startOffset, range.endOffset]);
68
+
69
+ interface Stub extends SpellcheckOptions {
70
+ readonly wordsAsked: SuggestRequest[];
71
+ /** Settles the suggestions a held provider is sitting on. */
72
+ release(): void;
73
+ }
74
+
75
+ const stubSpellcheck = (
76
+ tune: { holdSuggestions?: boolean; failSuggestions?: string } = {},
77
+ ): Stub => {
78
+ const wordsAsked: SuggestRequest[] = [];
79
+ const held: (() => void)[] = [];
80
+ const words = dictionaryFor("en") ?? new Set<string>();
81
+
82
+ const answer = (request: CheckRequest): CheckResponse => ({
83
+ requestId: request.requestId,
84
+ revision: request.revision,
85
+ findings: request.spans.flatMap((span) =>
86
+ findMisspellings(span.text, words).map(
87
+ (range): Finding => ({
88
+ spanId: span.spanId,
89
+ start: range.start,
90
+ end: range.end,
91
+ kind: "spelling",
92
+ suggestions: [],
93
+ }),
94
+ ),
95
+ ),
96
+ });
97
+
98
+ const provider: SpellProvider = {
99
+ language: "en",
100
+ onStatus: (listener) => {
101
+ listener({ state: "ready", language: "en" });
102
+ return () => {};
103
+ },
104
+ check: (request) => Promise.resolve(answer(request)),
105
+ suggest: (request) => {
106
+ wordsAsked.push(request);
107
+ if (tune.failSuggestions)
108
+ return Promise.reject(new Error(tune.failSuggestions));
109
+ const settled = {
110
+ requestId: request.requestId,
111
+ word: request.word,
112
+ suggestions: suggestionsFor(request.word, words),
113
+ };
114
+ if (!tune.holdSuggestions) return Promise.resolve(settled);
115
+ return new Promise((resolve) => {
116
+ held.push(() => resolve(settled));
117
+ });
118
+ },
119
+ close: () => {},
120
+ };
121
+
122
+ return {
123
+ wordsAsked,
124
+ release: () => {
125
+ for (const settle of held.splice(0)) settle();
126
+ },
127
+ provider: (language) =>
128
+ Promise.resolve(language === "en" ? provider : null),
129
+ };
130
+ };
131
+
132
+ const mount = async (
133
+ props: Record<string, unknown>,
134
+ ): Promise<LexicalEditor> => {
135
+ let editor: LexicalEditor | undefined;
136
+ const Probe = () => {
137
+ const [found] = useLexicalComposerContext();
138
+ useEffect(() => {
139
+ editor = found;
140
+ }, [found]);
141
+ return null;
142
+ };
143
+
144
+ container = dom.window.document.createElement("div");
145
+ dom.window.document.body.append(container);
146
+ await act(async () => {
147
+ const root = createRoot(container);
148
+ roots.push(root);
149
+ root.render(
150
+ createElement(RichTextEditor, {
151
+ ...props,
152
+ trailing: createElement(Probe),
153
+ }),
154
+ );
155
+ });
156
+ if (!editor) throw new Error("the editor never reached the probe");
157
+ return editor;
158
+ };
159
+
160
+ const settle = async (): Promise<void> => {
161
+ await act(async () => {
162
+ await new Promise((resolve) => setTimeout(resolve, IDLE_MS));
163
+ });
164
+ };
165
+
166
+ const editable = (): HTMLElement => {
167
+ const element = container.querySelector<HTMLElement>(
168
+ "[data-testid=compose-body]",
169
+ );
170
+ if (!element) throw new Error("the editable surface is not mounted");
171
+ return element;
172
+ };
173
+
174
+ const characters = (): Node => {
175
+ const leaf = editable().querySelector("[data-lexical-text]")?.firstChild;
176
+ if (!leaf) throw new Error("the document has no characters");
177
+ return leaf;
178
+ };
179
+
180
+ /**
181
+ * The desktop popover portals to the document body, clear of whatever
182
+ * ancestor would otherwise clip it, so the menu and its rows are found
183
+ * against the document rather than the mounted `container` — the sheet below
184
+ * the desktop gate stays in `container`, and this still reaches it there.
185
+ */
186
+ const menu = (): HTMLElement | null =>
187
+ dom.window.document.body.querySelector<HTMLElement>(
188
+ "[data-testid=spell-menu]",
189
+ );
190
+
191
+ const rows = (testId: string): HTMLElement[] => [
192
+ ...dom.window.document.body.querySelectorAll<HTMLElement>(
193
+ `[data-testid=${testId}]`,
194
+ ),
195
+ ];
196
+
197
+ /** Any element inside the menu, wherever it landed — see {@link menu}. */
198
+ const spellNode = (selector: string): HTMLElement | null =>
199
+ dom.window.document.body.querySelector<HTMLElement>(selector);
200
+
201
+ /**
202
+ * jsdom carries no `PointerEvent`, and what the editor reads off one is the
203
+ * kind of pointer and where it went.
204
+ */
205
+ const pointerEvent = (
206
+ type: string,
207
+ pointerType: string,
208
+ at: { clientX: number; clientY: number; button?: number },
209
+ ): MouseEvent => {
210
+ const event = new dom.window.MouseEvent(type, { bubbles: true, ...at });
211
+ Object.defineProperty(event, "pointerType", { value: pointerType });
212
+ return event;
213
+ };
214
+
215
+ const pressDownAt = async (
216
+ offset: number,
217
+ pointerType: string,
218
+ button = 0,
219
+ ): Promise<void> => {
220
+ dom.window.document.getSelection()?.setPosition(characters(), offset);
221
+ await act(async () => {
222
+ editable().dispatchEvent(
223
+ pointerEvent("pointerdown", pointerType, {
224
+ clientX: 40,
225
+ clientY: 20,
226
+ button,
227
+ }),
228
+ );
229
+ });
230
+ };
231
+
232
+ const pressUp = async (
233
+ pointerType: string,
234
+ { travel = 0, button = 0 }: { travel?: number; button?: number } = {},
235
+ ): Promise<void> => {
236
+ await act(async () => {
237
+ editable().dispatchEvent(
238
+ pointerEvent("pointerup", pointerType, {
239
+ clientX: 40 + travel,
240
+ clientY: 20,
241
+ button,
242
+ }),
243
+ );
244
+ });
245
+ };
246
+
247
+ const pressAt = async (
248
+ offset: number,
249
+ pointerType: string,
250
+ { travel = 0, button = 0 }: { travel?: number; button?: number } = {},
251
+ ): Promise<void> => {
252
+ await pressDownAt(offset, pointerType, button);
253
+ await pressUp(pointerType, { travel, button });
254
+ };
255
+
256
+ const tapAt = (offset: number): Promise<void> => pressAt(offset, "touch");
257
+
258
+ const clickAt = (offset: number): Promise<void> => pressAt(offset, "mouse");
259
+
260
+ /** The caret the browser puts down where the click landed. */
261
+ const caretAt = async (
262
+ editor: LexicalEditor,
263
+ offset: number,
264
+ ): Promise<void> => {
265
+ await act(async () => {
266
+ editor.update(() => {
267
+ const [text] = $getRoot().getAllTextNodes();
268
+ text?.select(offset, offset);
269
+ });
270
+ });
271
+ };
272
+
273
+ /** A key on the writing surface, whatever it turns out to do. */
274
+ const keyDownOn = async (key: string): Promise<void> => {
275
+ await act(async () => {
276
+ editable().dispatchEvent(
277
+ new dom.window.KeyboardEvent("keydown", {
278
+ key,
279
+ bubbles: true,
280
+ cancelable: true,
281
+ }),
282
+ );
283
+ });
284
+ };
285
+
286
+ const chordAt = async (
287
+ editor: LexicalEditor,
288
+ offset: number,
289
+ ): Promise<void> => {
290
+ await caretAt(editor, offset);
291
+ await act(async () => {
292
+ editable().dispatchEvent(
293
+ new dom.window.KeyboardEvent("keydown", {
294
+ key: ".",
295
+ ctrlKey: true,
296
+ bubbles: true,
297
+ cancelable: true,
298
+ }),
299
+ );
300
+ });
301
+ };
302
+
303
+ const click = async (element: HTMLElement): Promise<void> => {
304
+ await act(async () => {
305
+ element.click();
306
+ });
307
+ };
308
+
309
+ before(async () => {
310
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
311
+ dom = new JSDOMCtor(
312
+ "<!doctype html><html><body><div id=root></div></body></html>",
313
+ { url: "http://localhost/", pretendToBeVisual: true },
314
+ );
315
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
316
+ globalThis.document = dom.window.document;
317
+ globalThis.HTMLElement = dom.window.HTMLElement;
318
+ globalThis.Element = dom.window.Element;
319
+ globalThis.Node = dom.window.Node;
320
+ globalThis.Event = dom.window.Event;
321
+ globalThis.MouseEvent = dom.window.MouseEvent;
322
+ globalThis.KeyboardEvent = dom.window.KeyboardEvent;
323
+ globalThis.DOMParser = dom.window.DOMParser;
324
+ globalThis.MutationObserver = dom.window.MutationObserver;
325
+ globalThis.Range = dom.window.Range;
326
+ Object.defineProperty(dom.window.Range.prototype, "getBoundingClientRect", {
327
+ value: () => ({ top: 12, bottom: 24, left: 8, right: 40 }),
328
+ configurable: true,
329
+ });
330
+ Object.defineProperty(dom.window.Element.prototype, "getBoundingClientRect", {
331
+ value: () => ({ top: 0, bottom: 400, left: 0, right: 600 }),
332
+ configurable: true,
333
+ });
334
+ // jsdom lays nothing out, and the sheet reads its own height to know how far
335
+ // down it has been dragged.
336
+ Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
337
+ value: 360,
338
+ configurable: true,
339
+ });
340
+ // The layout question every surface here answers: a popover on the word, or
341
+ // the same rows in a sheet.
342
+ Object.defineProperty(dom.window, "matchMedia", {
343
+ value: () => ({
344
+ matches: desktopLayout,
345
+ addEventListener: () => {},
346
+ removeEventListener: () => {},
347
+ }),
348
+ configurable: true,
349
+ });
350
+ globalThis.ResizeObserver = class {
351
+ observe(): void {}
352
+ unobserve(): void {}
353
+ disconnect(): void {}
354
+ } as unknown as typeof ResizeObserver;
355
+ globalThis.AbortController = dom.window.AbortController;
356
+ globalThis.AbortSignal = dom.window.AbortSignal;
357
+ globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
358
+ globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(
359
+ dom.window,
360
+ );
361
+ globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(
362
+ dom.window,
363
+ );
364
+ Object.defineProperty(globalThis, "navigator", {
365
+ value: dom.window.navigator,
366
+ configurable: true,
367
+ });
368
+ Object.defineProperty(globalThis, "CSS", {
369
+ value: { highlights: new Map<string, StubMarks>() },
370
+ configurable: true,
371
+ });
372
+ Object.defineProperty(globalThis, "Highlight", {
373
+ value: StubMarks,
374
+ configurable: true,
375
+ });
376
+ (
377
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
378
+ ).IS_REACT_ACT_ENVIRONMENT = true;
379
+
380
+ ({ act, createElement, useEffect } = await import("react"));
381
+ ({ createRoot } = await import("react-dom/client"));
382
+ ({ $getRoot, UNDO_COMMAND } = await import("lexical"));
383
+ ({ useLexicalComposerContext } = (await import(
384
+ "@lexical/react/LexicalComposerContext"
385
+ )) as unknown as { useLexicalComposerContext: () => [LexicalEditor] });
386
+ ({ RichTextEditor } = await import("./rich-text-editor.js"));
387
+ });
388
+
389
+ beforeEach(() => {
390
+ desktopLayout = true;
391
+ (globalThis as unknown as MarkHost).CSS.highlights.clear();
392
+ });
393
+
394
+ afterEach(async () => {
395
+ const live = [...roots];
396
+ roots.length = 0;
397
+ await act(async () => {
398
+ for (const mounted of live) mounted.unmount();
399
+ });
400
+ container.remove();
401
+ });
402
+
403
+ after(() => {
404
+ dom.window.close();
405
+ });
406
+
407
+ describe("the correction menu", () => {
408
+ it("opens on the word under the pointer and stands in for the suggestions until they land", async () => {
409
+ const spellcheck = stubSpellcheck({ holdSuggestions: true });
410
+ await mount({
411
+ initialHtml: `<p>${SENTENCE}</p>`,
412
+ lang: "en",
413
+ spellcheck,
414
+ });
415
+ await settle();
416
+
417
+ await clickAt(1);
418
+
419
+ assert.ok(menu(), "the menu is up before anything was asked for");
420
+ assert.equal(
421
+ rows("spell-suggestion-skeleton").length,
422
+ 3,
423
+ "rows stand where the suggestions will be",
424
+ );
425
+ assert.equal(rows("spell-suggestion").length, 0);
426
+ assert.equal(
427
+ spellNode("[data-testid=spell-word]")?.textContent,
428
+ "Ths",
429
+ "the menu names the word it is about",
430
+ );
431
+ assert.deepEqual(
432
+ spellcheck.wordsAsked.map((request) => request.word),
433
+ ["Ths"],
434
+ "one word at a time, never the paragraph",
435
+ );
436
+
437
+ await act(async () => {
438
+ spellcheck.release();
439
+ });
440
+
441
+ assert.equal(rows("spell-suggestion-skeleton").length, 0);
442
+ assert.deepEqual(
443
+ rows("spell-suggestion").map((row) => row.textContent),
444
+ ["The", "This", "Than", "That", "Them"],
445
+ "five at most, dressed the way the word was written",
446
+ );
447
+ assert.equal(
448
+ rows("spell-add-word").length,
449
+ 0,
450
+ "no dictionary row without somewhere for the word to go",
451
+ );
452
+ });
453
+
454
+ it("puts the caret's own word within reach of the keyboard", async () => {
455
+ const editor = await mount({
456
+ initialHtml: `<p>${SENTENCE}</p>`,
457
+ lang: "en",
458
+ spellcheck: stubSpellcheck(),
459
+ });
460
+ await settle();
461
+
462
+ await chordAt(editor, 16);
463
+ assert.deepEqual(
464
+ offsets(),
465
+ [[0, 3]],
466
+ "the word being written carries no mark",
467
+ );
468
+ assert.equal(
469
+ spellNode("[data-testid=spell-word]")?.textContent,
470
+ "redy",
471
+ "and is still what the menu opens on",
472
+ );
473
+ });
474
+
475
+ it("replaces the word as one step, so one undo puts the misspelling back", async () => {
476
+ const editor = await mount({
477
+ initialHtml: `<p>${SENTENCE}</p>`,
478
+ lang: "en",
479
+ spellcheck: stubSpellcheck(),
480
+ });
481
+ await settle();
482
+
483
+ await clickAt(1);
484
+ const first = rows("spell-suggestion")[0];
485
+ assert.ok(first, "a suggestion is offered");
486
+ await click(first);
487
+
488
+ assert.equal(editable().textContent, "The report is redy today");
489
+ assert.equal(menu(), null, "the menu closed on the choice");
490
+
491
+ await settle();
492
+ assert.deepEqual(
493
+ offsets(),
494
+ [[14, 18]],
495
+ "the word that was corrected lost its squiggle",
496
+ );
497
+
498
+ await act(async () => {
499
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
500
+ });
501
+
502
+ assert.equal(
503
+ editable().textContent,
504
+ SENTENCE,
505
+ "one undo, not one per character",
506
+ );
507
+ });
508
+
509
+ it("ignores a word for as long as the composer is open", async () => {
510
+ const spellcheck = stubSpellcheck();
511
+ const editor = await mount({
512
+ initialHtml: `<p>${SENTENCE}</p>`,
513
+ lang: "en",
514
+ spellcheck,
515
+ });
516
+ await settle();
517
+ assert.equal(offsets().length, 2);
518
+
519
+ await clickAt(1);
520
+ const ignore = rows("spell-ignore")[0];
521
+ assert.ok(ignore);
522
+ await click(ignore);
523
+
524
+ assert.deepEqual(offsets(), [[14, 18]], "the squiggle went with the word");
525
+ assert.equal(menu(), null, "and the menu closed behind it");
526
+
527
+ await act(async () => {
528
+ editor.update(() => {
529
+ $getRoot().getAllTextNodes()[0]?.setTextContent(`${SENTENCE}.`);
530
+ });
531
+ });
532
+ await settle();
533
+
534
+ assert.deepEqual(
535
+ offsets(),
536
+ [[14, 18]],
537
+ "a later pass does not bring it back",
538
+ );
539
+ });
540
+
541
+ it("offers the dictionary when the mount has somewhere to put the word", async () => {
542
+ const added: string[] = [];
543
+ await mount({
544
+ initialHtml: `<p>${SENTENCE}</p>`,
545
+ lang: "en",
546
+ spellcheck: {
547
+ ...stubSpellcheck(),
548
+ onAddWord: (word: string) => added.push(word),
549
+ },
550
+ });
551
+ await settle();
552
+
553
+ await clickAt(1);
554
+ const add = rows("spell-add-word")[0];
555
+ assert.ok(add, "the row is offered");
556
+ await click(add);
557
+
558
+ assert.deepEqual(added, ["Ths"], "the word went where the mount takes it");
559
+ assert.deepEqual(offsets(), [[14, 18]], "and stopped being marked here");
560
+ });
561
+
562
+ it("says so when the suggestions could not be fetched", async () => {
563
+ await mount({
564
+ initialHtml: `<p>${SENTENCE}</p>`,
565
+ lang: "en",
566
+ spellcheck: stubSpellcheck({ failSuggestions: "the worker stopped" }),
567
+ });
568
+ await settle();
569
+
570
+ await clickAt(1);
571
+
572
+ assert.equal(rows("spell-suggestion-skeleton").length, 0);
573
+ assert.match(
574
+ spellNode("[data-testid=spell-suggestions-failed]")?.textContent ?? "",
575
+ /the worker stopped/,
576
+ "the menu says what happened rather than sitting empty",
577
+ );
578
+ assert.equal(
579
+ rows("spell-ignore").length,
580
+ 1,
581
+ "and what the writer can still do stays reachable",
582
+ );
583
+ });
584
+
585
+ it("opens the sheet instead of the popover below the desktop gate", async () => {
586
+ desktopLayout = false;
587
+ await mount({
588
+ initialHtml: `<p>${SENTENCE}</p>`,
589
+ lang: "en",
590
+ spellcheck: stubSpellcheck(),
591
+ });
592
+ await settle();
593
+
594
+ await tapAt(1);
595
+
596
+ assert.ok(menu(), "a tap in a marked word opens the corrections");
597
+ assert.ok(
598
+ spellNode("[aria-label='Close corrections']"),
599
+ "and they arrive in a sheet, with its scrim",
600
+ );
601
+ });
602
+
603
+ it("leaves a drag and a selection alone", async () => {
604
+ desktopLayout = false;
605
+ await mount({
606
+ initialHtml: `<p>${SENTENCE}</p>`,
607
+ lang: "en",
608
+ spellcheck: stubSpellcheck(),
609
+ });
610
+ await settle();
611
+
612
+ await pressAt(1, "touch", { travel: 40 });
613
+ assert.equal(menu(), null, "a finger that travelled was selecting text");
614
+
615
+ await pressAt(1, "mouse", { travel: 40 });
616
+ assert.equal(menu(), null, "and so was a mouse that was dragged");
617
+
618
+ dom.window.document
619
+ .getSelection()
620
+ ?.setBaseAndExtent(characters(), 0, characters(), 3);
621
+ await act(async () => {
622
+ editable().dispatchEvent(
623
+ pointerEvent("pointerdown", "touch", { clientX: 40, clientY: 20 }),
624
+ );
625
+ editable().dispatchEvent(
626
+ pointerEvent("pointerup", "touch", { clientX: 40, clientY: 20 }),
627
+ );
628
+ });
629
+ assert.equal(
630
+ menu(),
631
+ null,
632
+ "lifting a finger off a selection does not cover it with a sheet",
633
+ );
634
+ });
635
+
636
+ it("opens on a plain click and leaves the squiggle under it", async () => {
637
+ const marked: [number, number][] = [
638
+ [0, 3],
639
+ [14, 18],
640
+ ];
641
+ const editor = await mount({
642
+ initialHtml: `<p>${SENTENCE}</p>`,
643
+ lang: "en",
644
+ spellcheck: stubSpellcheck(),
645
+ });
646
+ await settle();
647
+ assert.deepEqual(offsets(), marked);
648
+
649
+ // The browser's own order: the press puts the caret in the word, and the
650
+ // menu follows when the pointer comes up where it landed.
651
+ await pressDownAt(1, "mouse");
652
+ await caretAt(editor, 1);
653
+ assert.deepEqual(
654
+ offsets(),
655
+ marked,
656
+ "the word the caret was put down in keeps its mark",
657
+ );
658
+
659
+ await pressUp("mouse");
660
+ assert.equal(
661
+ spellNode("[data-testid=spell-word]")?.textContent,
662
+ "Ths",
663
+ "and the click is what opened the corrections",
664
+ );
665
+
666
+ await settle();
667
+ assert.ok(menu(), "the menu is still up a pass later");
668
+ assert.deepEqual(offsets(), marked, "and so are the marks");
669
+ });
670
+
671
+ it("leaves the right button to the browser", async () => {
672
+ await mount({
673
+ initialHtml: `<p>${SENTENCE}</p>`,
674
+ lang: "en",
675
+ spellcheck: stubSpellcheck(),
676
+ });
677
+ await settle();
678
+
679
+ await pressAt(1, "mouse", { button: 2 });
680
+ assert.equal(menu(), null, "the right button raises nothing of ours");
681
+
682
+ const contextMenu = new dom.window.MouseEvent("contextmenu", {
683
+ bubbles: true,
684
+ cancelable: true,
685
+ });
686
+ await act(async () => {
687
+ editable().dispatchEvent(contextMenu);
688
+ });
689
+ assert.equal(
690
+ contextMenu.defaultPrevented,
691
+ false,
692
+ "and nothing stands between the writer and the browser's own menu",
693
+ );
694
+ assert.equal(menu(), null);
695
+ });
696
+
697
+ it("gives the word back to the writer on the next key", async () => {
698
+ const editor = await mount({
699
+ initialHtml: `<p>${SENTENCE}</p>`,
700
+ lang: "en",
701
+ spellcheck: stubSpellcheck(),
702
+ });
703
+ await settle();
704
+
705
+ await pressDownAt(1, "mouse");
706
+ await caretAt(editor, 1);
707
+ await pressUp("mouse");
708
+ assert.deepEqual(
709
+ offsets(),
710
+ [
711
+ [0, 3],
712
+ [14, 18],
713
+ ],
714
+ "the click left both marks standing",
715
+ );
716
+
717
+ // The caret walks out of the word it was clicked into and into the other
718
+ // one. Nothing is edited on the way, so the key is the only thing that
719
+ // says the writer is back.
720
+ await keyDownOn("ArrowRight");
721
+ await caretAt(editor, 16);
722
+ await settle();
723
+ assert.deepEqual(
724
+ offsets(),
725
+ [[0, 3]],
726
+ "the word the caret walked into is being written again",
727
+ );
728
+ });
729
+
730
+ it("still withholds the mark of a word an edit lands in", async () => {
731
+ const editor = await mount({
732
+ initialHtml: `<p>${SENTENCE}</p>`,
733
+ lang: "en",
734
+ spellcheck: stubSpellcheck(),
735
+ });
736
+ await settle();
737
+
738
+ await clickAt(1);
739
+ assert.equal(offsets().length, 2, "the click left both marks standing");
740
+
741
+ // An edit with no key behind it, the way a paste or a correction arrives.
742
+ await act(async () => {
743
+ editor.update(() => {
744
+ const [text] = $getRoot().getAllTextNodes();
745
+ text?.spliceText(18, 0, "y", true);
746
+ });
747
+ });
748
+ await settle();
749
+
750
+ assert.equal(editable().textContent, "Ths report is redyy today");
751
+ assert.deepEqual(
752
+ offsets(),
753
+ [[0, 3]],
754
+ "the word the edit landed in is not called wrong while it is written",
755
+ );
756
+ });
757
+
758
+ it("closes on Escape and on a click somewhere else", async () => {
759
+ await mount({
760
+ initialHtml: `<p>${SENTENCE}</p>`,
761
+ lang: "en",
762
+ spellcheck: stubSpellcheck(),
763
+ });
764
+ await settle();
765
+
766
+ await clickAt(1);
767
+ assert.ok(menu());
768
+ await act(async () => {
769
+ menu()?.dispatchEvent(
770
+ new dom.window.KeyboardEvent("keydown", {
771
+ key: "Escape",
772
+ bubbles: true,
773
+ }),
774
+ );
775
+ });
776
+ assert.equal(menu(), null, "Escape closes it");
777
+ assert.equal(
778
+ dom.window.document.activeElement,
779
+ editable(),
780
+ "and hands the message back its caret",
781
+ );
782
+
783
+ await clickAt(1);
784
+ assert.ok(menu());
785
+ await act(async () => {
786
+ dom.window.document.body.dispatchEvent(
787
+ new dom.window.MouseEvent("pointerdown", { bubbles: true }),
788
+ );
789
+ });
790
+ assert.equal(menu(), null, "so does a press outside it");
791
+ });
792
+
793
+ it("goes when the document moves under it, and never writes where it was", async () => {
794
+ const spellcheck = stubSpellcheck({ holdSuggestions: true });
795
+ const editor = await mount({
796
+ initialHtml: `<p>${SENTENCE}</p>`,
797
+ lang: "en",
798
+ spellcheck,
799
+ });
800
+ await settle();
801
+
802
+ await clickAt(1);
803
+ assert.ok(menu(), "the menu is up while the suggestions are in flight");
804
+
805
+ await act(async () => {
806
+ spellcheck.release();
807
+ });
808
+ const [first] = rows("spell-suggestion");
809
+ assert.ok(first);
810
+
811
+ // The edit and the click land in the same turn, which is the race a
812
+ // writer's own hands can produce.
813
+ await act(async () => {
814
+ editor.update(() => {
815
+ $getRoot().getAllTextNodes()[0]?.setTextContent(`Well, ${SENTENCE}`);
816
+ });
817
+ first.click();
818
+ });
819
+
820
+ assert.equal(
821
+ editable().textContent,
822
+ `Well, ${SENTENCE}`,
823
+ "the correction went nowhere rather than through the wrong letters",
824
+ );
825
+ assert.equal(menu(), null, "and the menu left with the offsets it held");
826
+ });
827
+
828
+ it("opens on a character, not on the gap before one", async () => {
829
+ const editor = await mount({
830
+ initialHtml: `<p>${SENTENCE}</p>`,
831
+ lang: "en",
832
+ spellcheck: stubSpellcheck(),
833
+ });
834
+ await settle();
835
+
836
+ await clickAt(3);
837
+ assert.equal(
838
+ menu(),
839
+ null,
840
+ "the position after the last letter is already the space that follows",
841
+ );
842
+
843
+ await clickAt(0);
844
+ assert.equal(
845
+ spellNode("[data-testid=spell-word]")?.textContent,
846
+ "Ths",
847
+ "the first letter belongs to the word",
848
+ );
849
+
850
+ await act(async () => {
851
+ menu()?.dispatchEvent(
852
+ new dom.window.KeyboardEvent("keydown", {
853
+ key: "Escape",
854
+ bubbles: true,
855
+ }),
856
+ );
857
+ });
858
+ await chordAt(editor, 3);
859
+ assert.equal(
860
+ spellNode("[data-testid=spell-word]")?.textContent,
861
+ "Ths",
862
+ "a caret resting at the end of a word is still in it",
863
+ );
864
+ });
865
+ });