@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.
- package/package.json +3 -3
- package/src/components/app-shell-slotted.render.test.ts +83 -0
- package/src/components/app-shell-slotted.tsx +31 -3
- package/src/components/attendee-row.render.test.ts +73 -0
- package/src/components/attendee-row.stories.tsx +68 -0
- package/src/components/attendee-row.tsx +96 -0
- package/src/components/calendar-event-chip.render.test.ts +67 -0
- package/src/components/calendar-event-chip.stories.tsx +128 -0
- package/src/components/calendar-event-chip.tsx +116 -0
- package/src/components/calendar-list.render.test.ts +77 -0
- package/src/components/calendar-list.stories.tsx +130 -0
- package/src/components/calendar-list.tsx +225 -0
- package/src/components/calendar-toolbar.render.test.ts +69 -0
- package/src/components/calendar-toolbar.stories.tsx +55 -0
- package/src/components/calendar-toolbar.tsx +178 -0
- package/src/components/calendar-types.ts +135 -0
- package/src/components/custom-recurrence.stories.tsx +106 -0
- package/src/components/custom-recurrence.tsx +411 -0
- package/src/components/event-detail.render.test.ts +104 -0
- package/src/components/event-detail.stories.tsx +178 -0
- package/src/components/event-detail.tsx +188 -0
- package/src/components/event-editor-pane.tsx +54 -0
- package/src/components/event-editor.render.test.ts +83 -0
- package/src/components/event-editor.stories.tsx +99 -0
- package/src/components/event-editor.tsx +480 -0
- package/src/components/event-quick-entry.render.test.ts +40 -0
- package/src/components/event-quick-entry.stories.tsx +56 -0
- package/src/components/event-quick-entry.tsx +159 -0
- package/src/components/event-suggestion-card.render.test.ts +65 -0
- package/src/components/event-suggestion-card.stories.tsx +93 -0
- package/src/components/event-suggestion-card.tsx +116 -0
- package/src/components/flow-screen.tsx +169 -0
- package/src/components/intelligence-panel.stories.tsx +5 -1
- package/src/components/intelligence-panel.tsx +4 -0
- package/src/components/isolated-email-frame.tsx +1 -18
- package/src/components/message-list-pane.render.test.ts +2 -2
- package/src/components/message-list-pane.stories.tsx +1 -1
- package/src/components/nav-sidebar.tsx +20 -0
- package/src/components/popover-menu.tsx +230 -27
- package/src/components/pull-to-refresh.tsx +1 -19
- package/src/components/recurrence-scope-prompt.render.test.ts +36 -0
- package/src/components/recurrence-scope-prompt.stories.tsx +46 -0
- package/src/components/recurrence-scope-prompt.tsx +84 -0
- package/src/components/rich-text-correction-menu.tsx +220 -0
- package/src/components/rich-text-editor.stories.tsx +507 -5
- package/src/components/rich-text-editor.tsx +482 -83
- package/src/components/rich-text-spellcheck-menu.test.ts +865 -0
- package/src/components/rich-text-spellcheck-provider.test.ts +114 -1
- package/src/components/rich-text-spellcheck-provider.ts +68 -3
- package/src/components/rich-text-spellcheck-words.ts +168 -4
- package/src/components/rich-text-spellcheck-worker.ts +11 -0
- package/src/components/rich-text-spellcheck.test.ts +7 -0
- package/src/components/rich-text-spellcheck.ts +28 -2
- package/src/components/selection-wizard.tsx +19 -69
- package/src/index.ts +116 -0
- package/src/lib/calendar-color.ts +71 -0
- package/src/lib/event-phrase.test.ts +89 -0
- package/src/lib/event-phrase.ts +228 -0
- package/src/lib/recurrence.test.ts +141 -0
- package/src/lib/recurrence.ts +266 -0
- package/src/lib/use-match-media.ts +25 -0
- package/src/rich-text.ts +12 -0
- package/src/tokens.css +63 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import { useState } from "react";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
3
|
import { expect, userEvent, waitFor } from "storybook/test";
|
|
4
4
|
import { sanitizeAdoptedHtml } from "../lib/adopted-html.js";
|
|
5
5
|
import { ComposeLanguageChip } from "./compose-language-chip.js";
|
|
@@ -295,6 +295,12 @@ const staleSpellcheck: SpellcheckOptions = {
|
|
|
295
295
|
),
|
|
296
296
|
});
|
|
297
297
|
},
|
|
298
|
+
suggest: (request) =>
|
|
299
|
+
Promise.resolve({
|
|
300
|
+
requestId: request.requestId,
|
|
301
|
+
word: request.word,
|
|
302
|
+
suggestions: [],
|
|
303
|
+
}),
|
|
298
304
|
close: () => {},
|
|
299
305
|
}),
|
|
300
306
|
};
|
|
@@ -357,10 +363,8 @@ export const SpellcheckMarks: Story = {
|
|
|
357
363
|
);
|
|
358
364
|
|
|
359
365
|
// The word the caret sits in is left alone until the writer moves on.
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
const characters = line?.firstChild ?? null;
|
|
363
|
-
canvasElement.ownerDocument.getSelection()?.setPosition(characters, 17);
|
|
366
|
+
editable.focus();
|
|
367
|
+
caretTo(editable, 17);
|
|
364
368
|
|
|
365
369
|
await waitFor(
|
|
366
370
|
() =>
|
|
@@ -406,6 +410,504 @@ export const SpellcheckStaleAnswer: Story = {
|
|
|
406
410
|
},
|
|
407
411
|
};
|
|
408
412
|
|
|
413
|
+
const MISSPELT_MESSAGE = [
|
|
414
|
+
"Ths is the agenda for tomorow.",
|
|
415
|
+
"I attachd the budgt figures and the meetign notes,",
|
|
416
|
+
"and will confrm the schedual seperately.",
|
|
417
|
+
].join(" ");
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The worker again, with the suggestions held back long enough to see the menu
|
|
421
|
+
* open without them. A slow engine is the normal case, not the exception: the
|
|
422
|
+
* menu is up on the click and the rows arrive after it.
|
|
423
|
+
*/
|
|
424
|
+
const slowSuggestions = (
|
|
425
|
+
delayMs: number,
|
|
426
|
+
onAddWord?: (word: string) => void,
|
|
427
|
+
): SpellcheckOptions => ({
|
|
428
|
+
onAddWord,
|
|
429
|
+
provider: async (language) => {
|
|
430
|
+
const provider = await openSpellcheckWorker(language);
|
|
431
|
+
if (!provider) return null;
|
|
432
|
+
return {
|
|
433
|
+
...provider,
|
|
434
|
+
suggest: (request) =>
|
|
435
|
+
new Promise((resolve) => {
|
|
436
|
+
setTimeout(() => resolve(provider.suggest(request)), delayMs);
|
|
437
|
+
}),
|
|
438
|
+
};
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const markRect = (editable: HTMLElement, word: string): DOMRect => {
|
|
443
|
+
for (const range of spellMarks(editable)) {
|
|
444
|
+
const text = range.startContainer.textContent ?? "";
|
|
445
|
+
if (text.slice(range.startOffset, range.endOffset) !== word) continue;
|
|
446
|
+
return (range as Range).getBoundingClientRect();
|
|
447
|
+
}
|
|
448
|
+
throw new Error(`"${word}" carries no mark`);
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const centreOf = (box: DOMRect) => ({
|
|
452
|
+
clientX: box.left + box.width / 2,
|
|
453
|
+
clientY: box.top + box.height / 2,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
interface Press {
|
|
457
|
+
readonly pointerType?: string;
|
|
458
|
+
readonly travel?: number;
|
|
459
|
+
readonly button?: number;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const pressDownOn = (
|
|
463
|
+
editable: HTMLElement,
|
|
464
|
+
word: string,
|
|
465
|
+
{ pointerType = "mouse", button = 0 }: Press = {},
|
|
466
|
+
): void => {
|
|
467
|
+
editable.dispatchEvent(
|
|
468
|
+
new PointerEvent("pointerdown", {
|
|
469
|
+
bubbles: true,
|
|
470
|
+
pointerType,
|
|
471
|
+
button,
|
|
472
|
+
...centreOf(markRect(editable, word)),
|
|
473
|
+
}),
|
|
474
|
+
);
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const pressUpOn = (
|
|
478
|
+
editable: HTMLElement,
|
|
479
|
+
word: string,
|
|
480
|
+
{ pointerType = "mouse", travel = 0, button = 0 }: Press = {},
|
|
481
|
+
): void => {
|
|
482
|
+
const at = centreOf(markRect(editable, word));
|
|
483
|
+
editable.dispatchEvent(
|
|
484
|
+
new PointerEvent("pointerup", {
|
|
485
|
+
bubbles: true,
|
|
486
|
+
pointerType,
|
|
487
|
+
button,
|
|
488
|
+
...at,
|
|
489
|
+
clientX: at.clientX + travel,
|
|
490
|
+
}),
|
|
491
|
+
);
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const pressOn = (editable: HTMLElement, word: string, press: Press = {}) => {
|
|
495
|
+
pressDownOn(editable, word, press);
|
|
496
|
+
pressUpOn(editable, word, press);
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
const tapOn = (editable: HTMLElement, word: string): void =>
|
|
500
|
+
pressOn(editable, word, { pointerType: "touch" });
|
|
501
|
+
|
|
502
|
+
const clickOn = (editable: HTMLElement, word: string): void =>
|
|
503
|
+
pressOn(editable, word);
|
|
504
|
+
|
|
505
|
+
const caretTo = (editable: HTMLElement, offset: number): void => {
|
|
506
|
+
const line = editable.querySelector<HTMLElement>("[data-lexical-text]");
|
|
507
|
+
editable.ownerDocument
|
|
508
|
+
.getSelection()
|
|
509
|
+
?.setPosition(line?.firstChild ?? null, offset);
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const textOf = (editable: HTMLElement): string =>
|
|
513
|
+
editable.querySelector<HTMLElement>("[data-lexical-text]")?.textContent ?? "";
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* The caret the browser would have put down under the press. Storybook's events
|
|
517
|
+
* are dispatched rather than trusted, so none of them move the caret on their
|
|
518
|
+
* own.
|
|
519
|
+
*/
|
|
520
|
+
const caretInto = (editable: HTMLElement, word: string): void =>
|
|
521
|
+
caretTo(editable, textOf(editable).indexOf(word) + 1);
|
|
522
|
+
|
|
523
|
+
const caretToEnd = (editable: HTMLElement): void =>
|
|
524
|
+
caretTo(editable, textOf(editable).length);
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* The desktop popover portals to the document body, clear of whatever
|
|
528
|
+
* ancestor would otherwise clip it, so the menu and its rows are found
|
|
529
|
+
* against the iframe document rather than `canvasElement` — the sheet below
|
|
530
|
+
* the desktop gate stays inside `canvasElement`, and this still reaches it
|
|
531
|
+
* there too.
|
|
532
|
+
*/
|
|
533
|
+
const menuRows = (
|
|
534
|
+
canvasElement: HTMLElement,
|
|
535
|
+
testId: string,
|
|
536
|
+
): HTMLElement[] => [
|
|
537
|
+
...canvasElement.ownerDocument.body.querySelectorAll<HTMLElement>(
|
|
538
|
+
`[data-testid=${testId}]`,
|
|
539
|
+
),
|
|
540
|
+
];
|
|
541
|
+
|
|
542
|
+
const spellNode = (
|
|
543
|
+
canvasElement: HTMLElement,
|
|
544
|
+
selector: string,
|
|
545
|
+
): Element | null => canvasElement.ownerDocument.body.querySelector(selector);
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* The correction menu over a marked word: up on the click with rows standing in
|
|
549
|
+
* for suggestions that have not arrived, filled when they do, and one undo away
|
|
550
|
+
* from the word that was there before.
|
|
551
|
+
*/
|
|
552
|
+
export const SpellcheckSuggestions: Story = {
|
|
553
|
+
name: "Spellcheck suggestions",
|
|
554
|
+
args: {
|
|
555
|
+
initialHtml: `<p>${MISSPELT}</p>`,
|
|
556
|
+
lang: "en",
|
|
557
|
+
spellcheck: slowSuggestions(600),
|
|
558
|
+
},
|
|
559
|
+
play: async ({ canvasElement }) => {
|
|
560
|
+
const editable = writingSurface(canvasElement);
|
|
561
|
+
await waitFor(() => expect(spellMarks(editable).length).toBe(3), {
|
|
562
|
+
timeout: 5000,
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
clickOn(editable, "Ths");
|
|
566
|
+
await waitFor(() =>
|
|
567
|
+
expect(spellNode(canvasElement, "[data-testid=spell-menu]")).toBeTruthy(),
|
|
568
|
+
);
|
|
569
|
+
await expect(
|
|
570
|
+
menuRows(canvasElement, "spell-suggestion-skeleton"),
|
|
571
|
+
).toHaveLength(3);
|
|
572
|
+
|
|
573
|
+
await waitFor(
|
|
574
|
+
() =>
|
|
575
|
+
expect(
|
|
576
|
+
menuRows(canvasElement, "spell-suggestion").map(
|
|
577
|
+
(row) => row.textContent,
|
|
578
|
+
),
|
|
579
|
+
).toEqual(["The", "This", "Than", "That", "Them"]),
|
|
580
|
+
{ timeout: 5000 },
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
const [first] = menuRows(canvasElement, "spell-suggestion");
|
|
584
|
+
if (!first) throw new Error("no suggestion to pick");
|
|
585
|
+
await userEvent.click(first);
|
|
586
|
+
|
|
587
|
+
await expect(editable.textContent).toBe(
|
|
588
|
+
MISSPELT.replace("Ths report", "The report"),
|
|
589
|
+
);
|
|
590
|
+
await waitFor(() => expect(spellMarks(editable).length).toBe(2), {
|
|
591
|
+
timeout: 5000,
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
editable.focus();
|
|
595
|
+
await userEvent.keyboard("{Control>}z{/Control}");
|
|
596
|
+
await waitFor(() => expect(editable.textContent).toBe(MISSPELT), {
|
|
597
|
+
timeout: 5000,
|
|
598
|
+
});
|
|
599
|
+
},
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* A misspelt word at the right edge of a narrow, `overflow-hidden` editor —
|
|
604
|
+
* the compose body's own shape (#731). The menu portals clear of that
|
|
605
|
+
* ancestor rather than being clipped by it, and it stays inside the viewport
|
|
606
|
+
* rather than running past the container's right edge and taking the page's
|
|
607
|
+
* own scrollbar with it.
|
|
608
|
+
*/
|
|
609
|
+
export const SpellcheckNearRightEdge: Story = {
|
|
610
|
+
name: "Spellcheck menu at the right edge of a narrow editor",
|
|
611
|
+
args: {
|
|
612
|
+
initialHtml: `<p>${MISSPELT_MESSAGE}</p>`,
|
|
613
|
+
lang: "en",
|
|
614
|
+
spellcheck: workerSpellcheck,
|
|
615
|
+
},
|
|
616
|
+
decorators: [
|
|
617
|
+
(Story) => (
|
|
618
|
+
<div
|
|
619
|
+
data-testid="body-area"
|
|
620
|
+
className="flex h-[260px] w-[320px] flex-col overflow-hidden rounded-md border border-line bg-canvas"
|
|
621
|
+
>
|
|
622
|
+
<Story />
|
|
623
|
+
</div>
|
|
624
|
+
),
|
|
625
|
+
],
|
|
626
|
+
play: async ({ canvasElement }) => {
|
|
627
|
+
const editable = writingSurface(canvasElement);
|
|
628
|
+
const frame = canvasElement.querySelector<HTMLElement>(
|
|
629
|
+
"[data-testid=body-area]",
|
|
630
|
+
);
|
|
631
|
+
if (!frame) throw new Error("the narrow frame is not mounted");
|
|
632
|
+
|
|
633
|
+
await waitFor(
|
|
634
|
+
() => expect(spellMarks(editable).length).toBeGreaterThan(0),
|
|
635
|
+
{
|
|
636
|
+
timeout: 5000,
|
|
637
|
+
},
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
clickOn(editable, "confrm");
|
|
641
|
+
const menu = await waitFor(() => {
|
|
642
|
+
const found = spellNode(canvasElement, "[data-testid=spell-menu]");
|
|
643
|
+
if (!found) throw new Error("the menu has not opened yet");
|
|
644
|
+
return found;
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
// Portalled clear of the clipping ancestor rather than cut by it.
|
|
648
|
+
await expect(frame.contains(menu)).toBe(false);
|
|
649
|
+
|
|
650
|
+
const box = menu.getBoundingClientRect();
|
|
651
|
+
await expect(box.left).toBeGreaterThanOrEqual(0);
|
|
652
|
+
await expect(box.right).toBeLessThanOrEqual(window.innerWidth);
|
|
653
|
+
|
|
654
|
+
await expect(
|
|
655
|
+
spellNode(canvasElement, "[data-testid=spell-ignore]")?.textContent,
|
|
656
|
+
).toBe("Ignore for now");
|
|
657
|
+
},
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Ignoring a word takes its marks off for as long as the composer is open, and
|
|
662
|
+
* later passes over the same text leave it alone. Nothing is written down: the
|
|
663
|
+
* next composer marks it again.
|
|
664
|
+
*/
|
|
665
|
+
export const SpellcheckSessionIgnore: Story = {
|
|
666
|
+
name: "Spellcheck ignores a word for the session",
|
|
667
|
+
args: {
|
|
668
|
+
initialHtml: `<p>${MISSPELT}</p>`,
|
|
669
|
+
lang: "en",
|
|
670
|
+
spellcheck: workerSpellcheck,
|
|
671
|
+
},
|
|
672
|
+
play: async ({ canvasElement }) => {
|
|
673
|
+
const editable = writingSurface(canvasElement);
|
|
674
|
+
await waitFor(() => expect(spellMarks(editable).length).toBe(3), {
|
|
675
|
+
timeout: 5000,
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
clickOn(editable, "attachd");
|
|
679
|
+
const [ignore] = await waitFor(() => {
|
|
680
|
+
const rows = menuRows(canvasElement, "spell-ignore");
|
|
681
|
+
expect(rows).toHaveLength(1);
|
|
682
|
+
return rows;
|
|
683
|
+
});
|
|
684
|
+
if (!ignore) throw new Error("the menu offers no way to ignore");
|
|
685
|
+
await userEvent.click(ignore);
|
|
686
|
+
|
|
687
|
+
await waitFor(() => expect(spellMarks(editable).length).toBe(2));
|
|
688
|
+
|
|
689
|
+
// Typed onto the end rather than clicked into: a click on this surface is
|
|
690
|
+
// now how the corrections open, and it would take the keyboard with it.
|
|
691
|
+
editable.focus();
|
|
692
|
+
caretToEnd(editable);
|
|
693
|
+
await userEvent.keyboard(" Attachd again.");
|
|
694
|
+
await waitFor(
|
|
695
|
+
() =>
|
|
696
|
+
expect(
|
|
697
|
+
spellMarks(editable).filter((range) => {
|
|
698
|
+
const text = range.startContainer.textContent ?? "";
|
|
699
|
+
return (
|
|
700
|
+
text.slice(range.startOffset, range.endOffset).toLowerCase() ===
|
|
701
|
+
"attachd"
|
|
702
|
+
);
|
|
703
|
+
}),
|
|
704
|
+
).toHaveLength(0),
|
|
705
|
+
{ timeout: 5000 },
|
|
706
|
+
);
|
|
707
|
+
},
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* A plain left click is what opens the corrections: a tablet has no right button
|
|
712
|
+
* and nothing should be hidden behind one. The caret the click puts down is
|
|
713
|
+
* reading rather than writing, so the squiggle it landed on stays where it is —
|
|
714
|
+
* clicking a marked word must not be what unmarks it. The next key is the writer
|
|
715
|
+
* back at the text, and the word under the caret goes quiet again.
|
|
716
|
+
*/
|
|
717
|
+
export const SpellcheckClickOpensCorrections: Story = {
|
|
718
|
+
name: "Spellcheck opens on a click",
|
|
719
|
+
args: {
|
|
720
|
+
initialHtml: `<p>${MISSPELT}</p>`,
|
|
721
|
+
lang: "en",
|
|
722
|
+
spellcheck: workerSpellcheck,
|
|
723
|
+
},
|
|
724
|
+
play: async ({ canvasElement }) => {
|
|
725
|
+
const editable = writingSurface(canvasElement);
|
|
726
|
+
const marked: [number, number][] = [
|
|
727
|
+
[0, 3],
|
|
728
|
+
[14, 18],
|
|
729
|
+
[44, 51],
|
|
730
|
+
];
|
|
731
|
+
await waitFor(() => expect(spellMarkOffsets(editable)).toEqual(marked), {
|
|
732
|
+
timeout: 5000,
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
editable.focus();
|
|
736
|
+
pressDownOn(editable, "redy");
|
|
737
|
+
caretInto(editable, "redy");
|
|
738
|
+
pressUpOn(editable, "redy");
|
|
739
|
+
|
|
740
|
+
await waitFor(() =>
|
|
741
|
+
expect(
|
|
742
|
+
spellNode(canvasElement, "[data-testid=spell-word]")?.textContent,
|
|
743
|
+
).toBe("redy"),
|
|
744
|
+
);
|
|
745
|
+
// Long enough for the caret the click put down to have been read and
|
|
746
|
+
// another checking pass to have gone by. It is still not what unmarks the
|
|
747
|
+
// word: this is the writer looking at the corrections for it.
|
|
748
|
+
await new Promise((rested) => setTimeout(rested, 600));
|
|
749
|
+
await expect(spellMarkOffsets(editable)).toEqual(marked);
|
|
750
|
+
|
|
751
|
+
await userEvent.keyboard("{Escape}");
|
|
752
|
+
await waitFor(() =>
|
|
753
|
+
expect(spellNode(canvasElement, "[data-testid=spell-menu]")).toBeNull(),
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
caretInto(editable, "redy");
|
|
757
|
+
editable.dispatchEvent(
|
|
758
|
+
new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }),
|
|
759
|
+
);
|
|
760
|
+
await waitFor(
|
|
761
|
+
() =>
|
|
762
|
+
expect(spellMarkOffsets(editable)).toEqual([
|
|
763
|
+
[0, 3],
|
|
764
|
+
[44, 51],
|
|
765
|
+
]),
|
|
766
|
+
{ timeout: 5000 },
|
|
767
|
+
);
|
|
768
|
+
},
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* The same rows below the desktop gate: a tap in a marked word raises the sheet
|
|
773
|
+
* rather than a popover under the thumb that opened it.
|
|
774
|
+
*/
|
|
775
|
+
export const SpellcheckOnTouch: Story = {
|
|
776
|
+
name: "Spellcheck corrections on a phone",
|
|
777
|
+
globals: { viewport: { value: "mobile" } },
|
|
778
|
+
args: {
|
|
779
|
+
initialHtml: `<p>${MISSPELT}</p>`,
|
|
780
|
+
lang: "en",
|
|
781
|
+
spellcheck: workerSpellcheck,
|
|
782
|
+
},
|
|
783
|
+
decorators: [
|
|
784
|
+
(Story) => (
|
|
785
|
+
<div
|
|
786
|
+
data-testid="body-area"
|
|
787
|
+
className="relative flex h-[560px] w-[360px] flex-col overflow-hidden rounded-md border border-line bg-canvas"
|
|
788
|
+
>
|
|
789
|
+
<Story />
|
|
790
|
+
</div>
|
|
791
|
+
),
|
|
792
|
+
],
|
|
793
|
+
play: async ({ canvasElement }) => {
|
|
794
|
+
const editable = writingSurface(canvasElement);
|
|
795
|
+
await waitFor(() => expect(spellMarks(editable).length).toBe(3), {
|
|
796
|
+
timeout: 5000,
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
tapOn(editable, "redy");
|
|
800
|
+
await waitFor(() =>
|
|
801
|
+
expect(spellNode(canvasElement, "[data-testid=spell-menu]")).toBeTruthy(),
|
|
802
|
+
);
|
|
803
|
+
await expect(
|
|
804
|
+
spellNode(canvasElement, "[aria-label='Close corrections']"),
|
|
805
|
+
).toBeTruthy();
|
|
806
|
+
},
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* The other things a pointer does over a marked word: dragging out a selection
|
|
811
|
+
* with either a finger or a mouse, and the right button, which belongs to the
|
|
812
|
+
* browser's own menu. A sheet over the top of any of them is the one thing they
|
|
813
|
+
* must not get. Closing the sheet hands the caret back.
|
|
814
|
+
*/
|
|
815
|
+
export const SpellcheckLeavesAPointerAlone: Story = {
|
|
816
|
+
...SpellcheckOnTouch,
|
|
817
|
+
name: "Spellcheck leaves a drag and the right button alone",
|
|
818
|
+
play: async ({ canvasElement }) => {
|
|
819
|
+
const editable = writingSurface(canvasElement);
|
|
820
|
+
await waitFor(() => expect(spellMarks(editable).length).toBe(3), {
|
|
821
|
+
timeout: 5000,
|
|
822
|
+
});
|
|
823
|
+
const menu = () => spellNode(canvasElement, "[data-testid=spell-menu]");
|
|
824
|
+
|
|
825
|
+
pressOn(editable, "redy", { pointerType: "touch", travel: 40 });
|
|
826
|
+
await expect(menu()).toBeNull();
|
|
827
|
+
|
|
828
|
+
pressOn(editable, "redy", { travel: 40 });
|
|
829
|
+
await expect(menu()).toBeNull();
|
|
830
|
+
|
|
831
|
+
pressOn(editable, "redy", { button: 2 });
|
|
832
|
+
await expect(menu()).toBeNull();
|
|
833
|
+
|
|
834
|
+
const contextMenu = new MouseEvent("contextmenu", {
|
|
835
|
+
bubbles: true,
|
|
836
|
+
cancelable: true,
|
|
837
|
+
...centreOf(markRect(editable, "redy")),
|
|
838
|
+
});
|
|
839
|
+
editable.dispatchEvent(contextMenu);
|
|
840
|
+
await expect(contextMenu.defaultPrevented).toBe(false);
|
|
841
|
+
await expect(menu()).toBeNull();
|
|
842
|
+
|
|
843
|
+
tapOn(editable, "redy");
|
|
844
|
+
await waitFor(() => expect(menu()).toBeTruthy());
|
|
845
|
+
|
|
846
|
+
await userEvent.click(
|
|
847
|
+
spellNode(
|
|
848
|
+
canvasElement,
|
|
849
|
+
"[aria-label='Close corrections']",
|
|
850
|
+
) as HTMLElement,
|
|
851
|
+
);
|
|
852
|
+
await waitFor(() => expect(menu()).toBeNull());
|
|
853
|
+
await waitFor(() => expect(document.activeElement).toBe(editable));
|
|
854
|
+
},
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* The whole experience, hands on: type into a message full of misspellings,
|
|
859
|
+
* click a squiggle (or put the caret in one and press Ctrl+.), pick a
|
|
860
|
+
* correction, ignore a word, or add it to the list this mount keeps. The
|
|
861
|
+
* theme toolbar switches both surfaces.
|
|
862
|
+
*/
|
|
863
|
+
const SpellcheckWorkbench = () => {
|
|
864
|
+
const [added, setAdded] = useState<string[]>([]);
|
|
865
|
+
const spellcheck = useMemo(
|
|
866
|
+
() => slowSuggestions(450, (word) => setAdded((words) => [...words, word])),
|
|
867
|
+
[],
|
|
868
|
+
);
|
|
869
|
+
|
|
870
|
+
return (
|
|
871
|
+
<>
|
|
872
|
+
<RichTextEditor
|
|
873
|
+
initialHtml={`<p>${MISSPELT_MESSAGE}</p><p>Anything you type here is checked the same way.</p>`}
|
|
874
|
+
lang="en"
|
|
875
|
+
spellcheck={spellcheck}
|
|
876
|
+
trailing={pinnedControls}
|
|
877
|
+
/>
|
|
878
|
+
<p
|
|
879
|
+
data-testid="spell-added-words"
|
|
880
|
+
className="shrink-0 border-t border-line px-3 py-2 text-xs text-fg-subtle"
|
|
881
|
+
>
|
|
882
|
+
{added.length === 0
|
|
883
|
+
? "Added to the dictionary: nothing yet."
|
|
884
|
+
: `Added to the dictionary: ${added.join(", ")}.`}
|
|
885
|
+
</p>
|
|
886
|
+
</>
|
|
887
|
+
);
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
export const SpellcheckPlayground: Story = {
|
|
891
|
+
name: "Spellcheck playground",
|
|
892
|
+
render: () => <SpellcheckWorkbench />,
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
export const SpellcheckPlaygroundOnTouch: Story = {
|
|
896
|
+
name: "Spellcheck playground on a phone",
|
|
897
|
+
globals: { viewport: { value: "mobile" } },
|
|
898
|
+
render: () => <SpellcheckWorkbench />,
|
|
899
|
+
decorators: [
|
|
900
|
+
(Story) => (
|
|
901
|
+
<div
|
|
902
|
+
data-testid="body-area"
|
|
903
|
+
className="relative flex h-[640px] w-[360px] flex-col overflow-hidden rounded-md border border-line bg-canvas"
|
|
904
|
+
>
|
|
905
|
+
<Story />
|
|
906
|
+
</div>
|
|
907
|
+
),
|
|
908
|
+
],
|
|
909
|
+
};
|
|
910
|
+
|
|
409
911
|
/**
|
|
410
912
|
* The toolbar and the body share one scroller, so twenty lines of typing would
|
|
411
913
|
* carry the toolbar off the top with them. It stays at the top of the body
|