@remit/ui 0.0.119 → 0.0.121

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.119",
3
+ "version": "0.0.121",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -51,6 +51,9 @@
51
51
  "devDependencies": {
52
52
  "@storybook/react": "^9",
53
53
  "@types/jsdom": "^28.0.3",
54
+ "dictionary-en": "4.0.0",
55
+ "dictionary-en-gb": "3.0.0",
56
+ "dictionary-nl": "2.0.0",
54
57
  "jsdom": "^29.1.1",
55
58
  "react": "^19",
56
59
  "react-dom": "^19",
@@ -348,7 +348,10 @@ describe("the composer's spellchecker", () => {
348
348
 
349
349
  assert.deepEqual(
350
350
  statuses,
351
- [{ state: "failed", language: "en", reason: "worker", detail: "boom" }],
351
+ [
352
+ { state: "opening", language: "en", bytesLoaded: 0, bytesTotal: 0 },
353
+ { state: "failed", language: "en", reason: "worker", detail: "boom" },
354
+ ],
352
355
  "the composer hears why checking is not happening, in the shape it reports",
353
356
  );
354
357
  assert.equal(
@@ -376,7 +379,7 @@ describe("the composer's spellchecker", () => {
376
379
 
377
380
  assert.deepEqual(
378
381
  statuses.map((status) => status.state),
379
- ["ready", "failed"],
382
+ ["opening", "ready", "failed"],
380
383
  "the stop is reported, not swallowed",
381
384
  );
382
385
  assert.deepEqual(marked(), [], "no mark outlives the checker that drew it");
@@ -13,10 +13,8 @@ import type {
13
13
  Finding,
14
14
  SpellcheckOptions,
15
15
  } from "./rich-text-spellcheck.js";
16
- import {
17
- dictionaryFor,
18
- findMisspellings,
19
- } from "./rich-text-spellcheck-words.js";
16
+ import { stubKnows } from "./rich-text-spellcheck-double.js";
17
+ import { findMisspellings } from "./rich-text-spellcheck-words.js";
20
18
  import { openSpellcheckWorker } from "./rich-text-spellcheck-worker-provider.js";
21
19
 
22
20
  /**
@@ -255,14 +253,78 @@ export const NarrowToolbar: Story = {
255
253
 
256
254
  const MISSPELT = "Ths report is redy today, and the notes are attachd.";
257
255
 
256
+ const DUTCH = "De vergaderingg gaat over de begrooting.";
257
+
258
258
  /**
259
- * A real module worker does the checking, over the same messages an engine
260
- * would answer: the component is handed a provider and never learns where the
261
- * words came from. What the worker holds instead of a dictionary is a short
262
- * list of English words.
259
+ * A real module worker does the checking, over Hunspell and the dictionary this
260
+ * build staged: the component is handed a provider and never learns where the
261
+ * words came from.
263
262
  */
264
263
  const workerSpellcheck: SpellcheckOptions = { provider: openSpellcheckWorker };
265
264
 
265
+ /** A download that never finishes, which on a bad link is most of a minute. */
266
+ const stalledSpellcheck = (bytesTotal: number): SpellcheckOptions => ({
267
+ provider: async (language) => ({
268
+ language,
269
+ onStatus: (listener) => {
270
+ listener({ state: "opening", language, bytesLoaded: 0, bytesTotal });
271
+ const timer = setInterval(
272
+ () =>
273
+ listener({
274
+ state: "opening",
275
+ language,
276
+ bytesLoaded: Math.round(bytesTotal / 4),
277
+ bytesTotal,
278
+ }),
279
+ 500,
280
+ );
281
+ return () => clearInterval(timer);
282
+ },
283
+ check: (request) =>
284
+ Promise.resolve({
285
+ requestId: request.requestId,
286
+ revision: request.revision,
287
+ findings: [],
288
+ }),
289
+ suggest: (request) =>
290
+ Promise.resolve({
291
+ requestId: request.requestId,
292
+ word: request.word,
293
+ suggestions: [],
294
+ }),
295
+ close: () => {},
296
+ }),
297
+ });
298
+
299
+ /** The dictionary answered, and what it answered was 503. */
300
+ const refusedSpellcheck: SpellcheckOptions = {
301
+ provider: async (language) => ({
302
+ language,
303
+ onStatus: (listener) => {
304
+ listener({
305
+ state: "failed",
306
+ language,
307
+ reason: "download",
308
+ detail: `/spellcheck/dictionaries/${language}/index.dic answered 503`,
309
+ });
310
+ return () => {};
311
+ },
312
+ check: (request) =>
313
+ Promise.resolve({
314
+ requestId: request.requestId,
315
+ revision: request.revision,
316
+ findings: [],
317
+ }),
318
+ suggest: (request) =>
319
+ Promise.resolve({
320
+ requestId: request.requestId,
321
+ word: request.word,
322
+ suggestions: [],
323
+ }),
324
+ close: () => {},
325
+ }),
326
+ };
327
+
266
328
  /**
267
329
  * The same findings, answered against the revision before the one asked for —
268
330
  * what a slow engine looks like when the text has already moved on.
@@ -278,12 +340,11 @@ const staleSpellcheck: SpellcheckOptions = {
278
340
  },
279
341
  check: (request: CheckRequest) => {
280
342
  staleAnswers.push(request.requestId);
281
- const words = dictionaryFor(request.language) ?? new Set<string>();
282
343
  return Promise.resolve({
283
344
  requestId: request.requestId,
284
345
  revision: request.revision - 1,
285
346
  findings: request.spans.flatMap((span) =>
286
- findMisspellings(span.text, words).map(
347
+ findMisspellings(span.text, stubKnows).map(
287
348
  (range): Finding => ({
288
349
  spanId: span.spanId,
289
350
  start: range.start,
@@ -377,6 +438,115 @@ export const SpellcheckMarks: Story = {
377
438
  },
378
439
  };
379
440
 
441
+ /**
442
+ * The same worker on a Dutch message, against OpenTaal's 180,745 entries. Two
443
+ * Dutch misspellings carry a squiggle and every other Dutch word does not —
444
+ * which is the whole point, since a Dutch message in an English-configured
445
+ * Chrome gets a squiggle under every word.
446
+ */
447
+ export const SpellcheckDutch: Story = {
448
+ name: "Spellcheck in Dutch (real dictionary)",
449
+ args: {
450
+ initialHtml: `<p>${DUTCH}</p>`,
451
+ lang: "nl",
452
+ spellcheck: workerSpellcheck,
453
+ },
454
+ play: async ({ canvasElement }) => {
455
+ const editable = writingSurface(canvasElement);
456
+ await waitFor(
457
+ () =>
458
+ expect(spellMarkOffsets(editable)).toEqual([
459
+ [3, 15],
460
+ [29, 39],
461
+ ]),
462
+ { timeout: 15000 },
463
+ );
464
+ await expect(editable.getAttribute("spellcheck")).toBe("false");
465
+ },
466
+ };
467
+
468
+ /**
469
+ * Fourteen seconds of a composer that looks like it has no opinion about
470
+ * spelling is the failure this must not have. The browser keeps checking, the
471
+ * banner names the language and its size once the wait is long enough to be
472
+ * worth mentioning, and cancelling leaves the writer with a way back.
473
+ */
474
+ export const SpellcheckSlowDownload: Story = {
475
+ name: "Spellcheck while the dictionary downloads",
476
+ args: {
477
+ initialHtml: `<p>${DUTCH}</p>`,
478
+ lang: "nl",
479
+ spellcheck: stalledSpellcheck(702_464),
480
+ },
481
+ play: async ({ canvasElement }) => {
482
+ const editable = writingSurface(canvasElement);
483
+ await expect(editable.getAttribute("spellcheck")).toBe("true");
484
+ await expect(spellMarks(editable)).toHaveLength(0);
485
+
486
+ const notice = await waitFor(
487
+ () => {
488
+ const row = canvasElement.querySelector<HTMLElement>(
489
+ "[data-testid=spellcheck-notice]",
490
+ );
491
+ expect(row).not.toBeNull();
492
+ return row as HTMLElement;
493
+ },
494
+ { timeout: 12000 },
495
+ );
496
+ await expect(notice.textContent).toContain("Nederlands");
497
+ await expect(notice.textContent).toContain("686 KB");
498
+
499
+ const cancel = notice.querySelector<HTMLElement>(
500
+ "[data-testid=spellcheck-cancel]",
501
+ );
502
+ if (!cancel)
503
+ throw new Error("a download nobody can stop is not a download");
504
+ await userEvent.click(cancel);
505
+
506
+ await waitFor(() =>
507
+ expect(
508
+ canvasElement.querySelector("[data-testid=spellcheck-retry]"),
509
+ ).not.toBeNull(),
510
+ );
511
+ await expect(editable.getAttribute("spellcheck")).toBe("true");
512
+ },
513
+ };
514
+
515
+ /**
516
+ * The dead squiggle-free editor is the worst outcome, so a failure says which
517
+ * file, what it answered, and where to report it — and hands the spelling back
518
+ * to the browser rather than leaving nobody checking.
519
+ */
520
+ export const SpellcheckDownloadFailed: Story = {
521
+ name: "Spellcheck when the dictionary fails to load",
522
+ args: {
523
+ initialHtml: `<p>${DUTCH}</p>`,
524
+ lang: "nl",
525
+ spellcheck: refusedSpellcheck,
526
+ },
527
+ play: async ({ canvasElement }) => {
528
+ const editable = writingSurface(canvasElement);
529
+ const detail = await waitFor(() => {
530
+ const row = canvasElement.querySelector<HTMLElement>(
531
+ "[data-testid=spellcheck-detail]",
532
+ );
533
+ expect(row).not.toBeNull();
534
+ return row as HTMLElement;
535
+ });
536
+
537
+ await expect(detail.textContent).toContain("answered 503");
538
+ await expect(
539
+ canvasElement.querySelector("[data-testid=spellcheck-retry]"),
540
+ ).not.toBeNull();
541
+ const report = canvasElement.querySelector<HTMLAnchorElement>(
542
+ "[data-testid=spellcheck-report]",
543
+ );
544
+ await expect(report?.href).toContain("issues/new?title=");
545
+ await expect(editable.getAttribute("spellcheck")).toBe("true");
546
+ await expect(spellMarks(editable)).toHaveLength(0);
547
+ },
548
+ };
549
+
380
550
  /** A language the build carries no dictionary for: the browser keeps checking. */
381
551
  export const SpellcheckWithoutDictionary: Story = {
382
552
  name: "Spellcheck with no dictionary for the language",
@@ -562,7 +732,7 @@ export const SpellcheckSuggestions: Story = {
562
732
  timeout: 5000,
563
733
  });
564
734
 
565
- clickOn(editable, "Ths");
735
+ clickOn(editable, "attachd");
566
736
  await waitFor(() =>
567
737
  expect(spellNode(canvasElement, "[data-testid=spell-menu]")).toBeTruthy(),
568
738
  );
@@ -570,22 +740,26 @@ export const SpellcheckSuggestions: Story = {
570
740
  menuRows(canvasElement, "spell-suggestion-skeleton"),
571
741
  ).toHaveLength(3);
572
742
 
743
+ // What Hunspell offers over SCOWL, in its own order. The word the writer
744
+ // meant is in it, which is the whole reason for a real dictionary.
573
745
  await waitFor(
574
746
  () =>
575
747
  expect(
576
748
  menuRows(canvasElement, "spell-suggestion").map(
577
749
  (row) => row.textContent,
578
750
  ),
579
- ).toEqual(["The", "This", "Than", "That", "Them"]),
751
+ ).toEqual(["attach", "attached", "attache", "attach d"]),
580
752
  { timeout: 5000 },
581
753
  );
582
754
 
583
- const [first] = menuRows(canvasElement, "spell-suggestion");
584
- if (!first) throw new Error("no suggestion to pick");
585
- await userEvent.click(first);
755
+ const meant = menuRows(canvasElement, "spell-suggestion").find(
756
+ (row) => row.textContent === "attached",
757
+ );
758
+ if (!meant) throw new Error("no suggestion to pick");
759
+ await userEvent.click(meant);
586
760
 
587
761
  await expect(editable.textContent).toBe(
588
- MISSPELT.replace("Ths report", "The report"),
762
+ MISSPELT.replace("attachd", "attached"),
589
763
  );
590
764
  await waitFor(() => expect(spellMarks(editable).length).toBe(2), {
591
765
  timeout: 5000,
@@ -40,6 +40,7 @@ import type {
40
40
  SpellcheckOptions,
41
41
  SpellProvider,
42
42
  } from "./rich-text-spellcheck.js";
43
+ import { RichTextSpellcheckNotice } from "./rich-text-spellcheck-notice.js";
43
44
  import {
44
45
  normaliseWord,
45
46
  SUGGESTION_LIMIT,
@@ -397,6 +398,19 @@ const SpellcheckPlugin = ({
397
398
  null,
398
399
  );
399
400
  const [failure, setFailure] = useState<string | null>(null);
401
+ // A cancelled download and a retry are the same effect run again, so the
402
+ // attempt is what the effect keys off; standing down withholds the run.
403
+ const [attempt, setAttempt] = useState(0);
404
+ const [standDown, setStandDown] = useState(false);
405
+ const [status, setStatus] = useState<ProviderStatus>({
406
+ state: "opening",
407
+ language,
408
+ bytesLoaded: 0,
409
+ bytesTotal: 0,
410
+ });
411
+ // Nothing here can draw a mark without the highlight registry, so there is
412
+ // nothing to narrate either: the browser has the text to itself.
413
+ const [drawable] = useState(marksSupported);
400
414
 
401
415
  useEffect(() => {
402
416
  settings.current = options;
@@ -404,7 +418,7 @@ const SpellcheckPlugin = ({
404
418
  }, [options, onReady]);
405
419
 
406
420
  useEffect(() => {
407
- if (!marksSupported()) return;
421
+ if (!marksSupported() || standDown) return;
408
422
  const painter = Symbol(SPELLCHECK_HIGHLIGHT);
409
423
  const findings = found.current;
410
424
  const touched = new Set<string>();
@@ -473,7 +487,14 @@ const SpellcheckPlugin = ({
473
487
  passes += 1;
474
488
  const sent = revision;
475
489
  provider
476
- .check({ requestId: `${passes}`, language, revision: sent, spans })
490
+ // The attempt is part of the id, so a retry's answers are legible
491
+ // against the ones the download it replaced never delivered.
492
+ .check({
493
+ requestId: `${attempt}:${passes}`,
494
+ language,
495
+ revision: sent,
496
+ spans,
497
+ })
477
498
  .then((response) => {
478
499
  if (!live) return;
479
500
  // The text moved while this was in flight, so the offsets are
@@ -656,16 +677,18 @@ const SpellcheckPlugin = ({
656
677
  schedule();
657
678
  });
658
679
 
680
+ const announce = (next: ProviderStatus): void => {
681
+ setStatus(next);
682
+ settings.current.onStatus?.(next);
683
+ };
684
+
659
685
  const stopped = (detail: string): void => {
660
- settings.current.onStatus?.({
661
- state: "failed",
662
- language,
663
- reason: "worker",
664
- detail,
665
- });
686
+ announce({ state: "failed", language, reason: "worker", detail });
666
687
  report.current(false);
667
688
  };
668
689
 
690
+ announce({ state: "opening", language, bytesLoaded: 0, bytesTotal: 0 });
691
+
669
692
  settings.current
670
693
  .provider(language)
671
694
  .then((opened) => {
@@ -675,19 +698,24 @@ const SpellcheckPlugin = ({
675
698
  }
676
699
  checker.current = opened;
677
700
  if (!opened) {
678
- settings.current.onStatus?.({ state: "unavailable", language });
701
+ announce({ state: "unavailable", language });
679
702
  report.current(false);
680
703
  return;
681
704
  }
682
705
  unsubscribe = opened.onStatus((status) => {
683
706
  state = status.state;
684
- settings.current.onStatus?.(status);
707
+ announce(status);
685
708
  const ready = status.state === "ready";
686
709
  report.current(ready);
687
710
  if (ready) {
688
711
  schedule();
689
712
  return;
690
713
  }
714
+ // A dictionary on its way is not checking stopped, and it arrives in
715
+ // twenty progress reports: treating each as a stop would empty the
716
+ // queue the first pass is supposed to drain, and nothing would ever
717
+ // be checked.
718
+ if (status.state === "opening") return;
691
719
  // Checking stopped, so the browser's own is back. Its squiggles are
692
720
  // the only ones on screen from here.
693
721
  findings.clear();
@@ -723,7 +751,7 @@ const SpellcheckPlugin = ({
723
751
  report.current(false);
724
752
  paintMarks(painter, []);
725
753
  };
726
- }, [editor, language, hostRef]);
754
+ }, [editor, language, hostRef, attempt, standDown]);
727
755
 
728
756
  useEffect(() => {
729
757
  if (!target) return;
@@ -780,9 +808,8 @@ const SpellcheckPlugin = ({
780
808
  [editor, dismiss],
781
809
  );
782
810
 
783
- if (!target) return null;
784
-
785
811
  const replace = (suggestion: string) => {
812
+ if (!target) return;
786
813
  // One entry, so one undo puts the misspelling back (#707, decision 9).
787
814
  editor.update(
788
815
  () => {
@@ -809,27 +836,43 @@ const SpellcheckPlugin = ({
809
836
  };
810
837
 
811
838
  const addWord = () => {
839
+ if (!target) return;
812
840
  settings.current.onAddWord?.(target.word);
813
841
  forget(target.word);
814
842
  };
815
843
 
816
844
  return (
817
- <RichTextCorrectionMenu
818
- word={target.word}
819
- suggestions={suggestions}
820
- failure={failure}
821
- anchor={{
822
- left: target.left,
823
- right: target.right,
824
- top: target.top,
825
- bottom: target.bottom,
826
- }}
827
- language={language}
828
- onReplace={replace}
829
- onIgnore={() => forget(target.word)}
830
- onAddWord={options.onAddWord ? addWord : undefined}
831
- onDismiss={dismiss}
832
- />
845
+ <>
846
+ {drawable ? (
847
+ <RichTextSpellcheckNotice
848
+ status={status}
849
+ standDown={standDown}
850
+ onCancel={() => setStandDown(true)}
851
+ onRetry={() => {
852
+ setStandDown(false);
853
+ setAttempt((run) => run + 1);
854
+ }}
855
+ />
856
+ ) : null}
857
+ {target ? (
858
+ <RichTextCorrectionMenu
859
+ word={target.word}
860
+ suggestions={suggestions}
861
+ failure={failure}
862
+ anchor={{
863
+ left: target.left,
864
+ right: target.right,
865
+ top: target.top,
866
+ bottom: target.bottom,
867
+ }}
868
+ language={language}
869
+ onReplace={replace}
870
+ onIgnore={() => forget(target.word)}
871
+ onAddWord={options.onAddWord ? addWord : undefined}
872
+ onDismiss={dismiss}
873
+ />
874
+ ) : null}
875
+ </>
833
876
  );
834
877
  };
835
878
 
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The stand-in the marks, the menu and the stale-answer stories drive the
3
+ * editor with. It is not a spellchecker and never was: those tests are about
4
+ * what the editor does with an answer, so the answer has to be a fixed one.
5
+ * What a real dictionary says is proved against a real dictionary, in
6
+ * `rich-text-spellcheck-worker.test.ts`.
7
+ */
8
+ import { normaliseWord } from "./rich-text-spellcheck-words.js";
9
+
10
+ const KNOWN = new Set([
11
+ "a",
12
+ "again",
13
+ "agenda",
14
+ "and",
15
+ "are",
16
+ "attached",
17
+ "budget",
18
+ "confirm",
19
+ "figures",
20
+ "for",
21
+ "is",
22
+ "i",
23
+ "meeting",
24
+ "notes",
25
+ "report",
26
+ "schedule",
27
+ "separately",
28
+ "the",
29
+ "this",
30
+ "today",
31
+ "tomorrow",
32
+ "well",
33
+ "will",
34
+ ]);
35
+
36
+ const CORRECTIONS = new Map<string, readonly string[]>([
37
+ ["ths", ["the", "this", "than", "that", "them"]],
38
+ ["redy", ["ready", "read", "very"]],
39
+ ["attachd", ["attached"]],
40
+ ["tomorow", ["tomorrow"]],
41
+ ["budgt", ["budget"]],
42
+ ["meetign", ["meeting"]],
43
+ ["confrm", ["confirm"]],
44
+ ["schedual", ["schedule"]],
45
+ ["seperately", ["separately"]],
46
+ ]);
47
+
48
+ /** A suggestion arrives dressed the way the word it replaces was written. */
49
+ const wearingTheCaseOf = (word: string, suggestion: string): string => {
50
+ const upper = word.toUpperCase();
51
+ if (word === upper && word !== word.toLowerCase())
52
+ return suggestion.toUpperCase();
53
+ if (word[0] === upper[0] && word[0] !== word.toLowerCase()[0]) {
54
+ return suggestion[0].toUpperCase() + suggestion.slice(1);
55
+ }
56
+ return suggestion;
57
+ };
58
+
59
+ export const stubKnows = (word: string): boolean =>
60
+ KNOWN.has(normaliseWord(word));
61
+
62
+ export const stubSuggestionsFor = (word: string): readonly string[] =>
63
+ (CORRECTIONS.get(normaliseWord(word)) ?? []).map((suggestion) =>
64
+ wearingTheCaseOf(word, suggestion),
65
+ );