@remit/ui 0.0.106 → 0.0.108
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 +1 -1
- package/src/components/nav-link-surface.interaction.test.ts +117 -0
- package/src/components/nav-link-surface.render.test.ts +126 -0
- package/src/components/nav-link-surface.tsx +97 -0
- package/src/components/rich-text-editor.stories.tsx +164 -1
- package/src/components/rich-text-editor.tsx +364 -53
- package/src/components/rich-text-spellcheck-provider.test.ts +217 -0
- package/src/components/rich-text-spellcheck-provider.ts +83 -0
- package/src/components/rich-text-spellcheck-words.ts +214 -0
- package/src/components/rich-text-spellcheck-worker-provider.ts +42 -0
- package/src/components/rich-text-spellcheck-worker.ts +54 -0
- package/src/components/rich-text-spellcheck.test.ts +638 -0
- package/src/components/rich-text-spellcheck.ts +75 -0
- package/src/index.ts +1 -0
- package/src/rich-text.ts +14 -0
- package/src/tokens.css +17 -0
package/package.json
CHANGED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mounted against jsdom rather than `renderToString`, since focus and clicks
|
|
3
|
+
* need a real `document`. Enter-to-activate is the browser's own behaviour and
|
|
4
|
+
* jsdom does not implement it for anchors; `detail-surface.stories.tsx` covers
|
|
5
|
+
* that in Chromium.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
9
|
+
import type { JSDOM } from "jsdom";
|
|
10
|
+
import { act, createElement, type MouseEvent } from "react";
|
|
11
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
12
|
+
import { NavLinkSurface } from "./nav-link-surface.js";
|
|
13
|
+
|
|
14
|
+
let dom: JSDOM;
|
|
15
|
+
let container: HTMLElement;
|
|
16
|
+
let root: Root;
|
|
17
|
+
|
|
18
|
+
before(async () => {
|
|
19
|
+
const { JSDOM: JSDOMCtor } = await import("jsdom");
|
|
20
|
+
dom = new JSDOMCtor(
|
|
21
|
+
"<!doctype html><html><body><div id=root></div></body></html>",
|
|
22
|
+
{ url: "http://localhost/", pretendToBeVisual: true },
|
|
23
|
+
);
|
|
24
|
+
globalThis.window = dom.window as unknown as typeof globalThis.window;
|
|
25
|
+
globalThis.document = dom.window.document;
|
|
26
|
+
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
27
|
+
globalThis.Element = dom.window.Element;
|
|
28
|
+
globalThis.MouseEvent = dom.window.MouseEvent;
|
|
29
|
+
Object.defineProperty(globalThis, "navigator", {
|
|
30
|
+
value: dom.window.navigator,
|
|
31
|
+
configurable: true,
|
|
32
|
+
});
|
|
33
|
+
(
|
|
34
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
35
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
after(() => {
|
|
39
|
+
dom.window.close();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
container = dom.window.document.getElementById(
|
|
44
|
+
"root",
|
|
45
|
+
) as unknown as HTMLElement;
|
|
46
|
+
container.innerHTML = "";
|
|
47
|
+
root = createRoot(container);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
act(() => {
|
|
52
|
+
root.unmount();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function mount(props: Parameters<typeof NavLinkSurface>[0]) {
|
|
57
|
+
act(() => {
|
|
58
|
+
root.render(createElement(NavLinkSurface, props, "Daily brief"));
|
|
59
|
+
});
|
|
60
|
+
const link = container.querySelector("a");
|
|
61
|
+
assert.ok(link, "no anchor rendered");
|
|
62
|
+
return link;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function click(target: Element, init: { metaKey?: boolean } = {}) {
|
|
66
|
+
act(() => {
|
|
67
|
+
target.dispatchEvent(
|
|
68
|
+
new dom.window.MouseEvent("click", {
|
|
69
|
+
bubbles: true,
|
|
70
|
+
cancelable: true,
|
|
71
|
+
...init,
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("NavLinkSurface interaction", () => {
|
|
78
|
+
it("puts a link with an href in the tab order", () => {
|
|
79
|
+
const link = mount({ href: "/mail/brief" });
|
|
80
|
+
link.focus();
|
|
81
|
+
assert.equal(dom.window.document.activeElement, link);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("keeps a link with no href out of the tab order", () => {
|
|
85
|
+
const link = mount({});
|
|
86
|
+
link.focus();
|
|
87
|
+
assert.notEqual(dom.window.document.activeElement, link);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("passes a modified click through with its modifier intact", () => {
|
|
91
|
+
const seen: boolean[] = [];
|
|
92
|
+
const link = mount({
|
|
93
|
+
href: "/mail/brief",
|
|
94
|
+
onClick: (event: MouseEvent<HTMLAnchorElement>) => {
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
seen.push(event.metaKey);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
click(link);
|
|
101
|
+
click(link, { metaKey: true });
|
|
102
|
+
|
|
103
|
+
assert.deepEqual(seen, [false, true]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("adds no click handling of its own when the caller passes none", () => {
|
|
107
|
+
const link = mount({ href: "/mail/brief" });
|
|
108
|
+
const event = new dom.window.MouseEvent("click", {
|
|
109
|
+
bubbles: true,
|
|
110
|
+
cancelable: true,
|
|
111
|
+
});
|
|
112
|
+
act(() => {
|
|
113
|
+
link.dispatchEvent(event);
|
|
114
|
+
});
|
|
115
|
+
assert.equal(event.defaultPrevented, false);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { NavLinkSurface } from "./nav-link-surface.js";
|
|
6
|
+
|
|
7
|
+
describe("NavLinkSurface", () => {
|
|
8
|
+
it("renders a real anchor carrying the href", () => {
|
|
9
|
+
const html = renderToString(
|
|
10
|
+
createElement(NavLinkSurface, { href: "/mail/brief" }, "Daily brief"),
|
|
11
|
+
);
|
|
12
|
+
assert.match(html, /^<a /);
|
|
13
|
+
assert.match(html, /href="\/mail\/brief"/);
|
|
14
|
+
assert.match(html, /Daily brief/);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("renders no href attribute when given none", () => {
|
|
18
|
+
const html = renderToString(
|
|
19
|
+
createElement(NavLinkSurface, {}, "Daily brief"),
|
|
20
|
+
);
|
|
21
|
+
assert.doesNotMatch(html, /href=/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("marks the current destination and styles it", () => {
|
|
25
|
+
const html = renderToString(
|
|
26
|
+
createElement(
|
|
27
|
+
NavLinkSurface,
|
|
28
|
+
{ href: "/mail/brief", current: "page" },
|
|
29
|
+
"Daily brief",
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
assert.match(html, /aria-current="page"/);
|
|
33
|
+
assert.match(html, /bg-accent-2-soft/);
|
|
34
|
+
assert.doesNotMatch(html, /text-fg-muted/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("styles a caller-supplied aria-current the same way", () => {
|
|
38
|
+
const html = renderToString(
|
|
39
|
+
createElement(
|
|
40
|
+
NavLinkSurface,
|
|
41
|
+
{ href: "/mail/brief", "aria-current": "page" },
|
|
42
|
+
"Daily brief",
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
assert.match(html, /aria-current="page"/);
|
|
46
|
+
assert.match(html, /bg-accent-2-soft/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("treats aria-current=false as not current", () => {
|
|
50
|
+
const html = renderToString(
|
|
51
|
+
createElement(
|
|
52
|
+
NavLinkSurface,
|
|
53
|
+
{ href: "/mail/brief", "aria-current": "false" },
|
|
54
|
+
"Daily brief",
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
assert.match(html, /text-fg-muted/);
|
|
58
|
+
assert.doesNotMatch(html, /bg-accent-2-soft/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("carries a focus ring on every variant", () => {
|
|
62
|
+
for (const variant of ["nav", "row", "inline"] as const) {
|
|
63
|
+
const html = renderToString(
|
|
64
|
+
createElement(NavLinkSurface, { href: "/x", variant }, "x"),
|
|
65
|
+
);
|
|
66
|
+
assert.match(html, /focus-visible:ring-2/, variant);
|
|
67
|
+
assert.match(html, /focus-visible:ring-ring/, variant);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("rings a full-bleed row inside its own edge", () => {
|
|
72
|
+
const html = renderToString(
|
|
73
|
+
createElement(NavLinkSurface, { href: "/x", variant: "row" }, "x"),
|
|
74
|
+
);
|
|
75
|
+
assert.match(html, /focus-visible:ring-inset/);
|
|
76
|
+
assert.doesNotMatch(html, /ring-offset/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("rings a nav entry outside its own edge", () => {
|
|
80
|
+
const html = renderToString(
|
|
81
|
+
createElement(NavLinkSurface, { href: "/x", variant: "nav" }, "x"),
|
|
82
|
+
);
|
|
83
|
+
assert.match(html, /focus-visible:ring-offset-1/);
|
|
84
|
+
assert.doesNotMatch(html, /ring-inset/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("underlines the inline variant on hover", () => {
|
|
88
|
+
const html = renderToString(
|
|
89
|
+
createElement(NavLinkSurface, { href: "/x", variant: "inline" }, "x"),
|
|
90
|
+
);
|
|
91
|
+
assert.match(html, /hover:underline/);
|
|
92
|
+
assert.match(html, /text-accent/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("lets a caller's className override the variant's own colours", () => {
|
|
96
|
+
const html = renderToString(
|
|
97
|
+
createElement(
|
|
98
|
+
NavLinkSurface,
|
|
99
|
+
{ href: "/x", className: "text-danger" },
|
|
100
|
+
"x",
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
assert.match(html, /text-danger/);
|
|
104
|
+
assert.doesNotMatch(html, /text-fg-muted/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("passes anchor attributes straight through", () => {
|
|
108
|
+
const html = renderToString(
|
|
109
|
+
createElement(
|
|
110
|
+
NavLinkSurface,
|
|
111
|
+
{
|
|
112
|
+
href: "https://example.com",
|
|
113
|
+
target: "_blank",
|
|
114
|
+
rel: "noopener noreferrer",
|
|
115
|
+
"data-testid": "nav-link",
|
|
116
|
+
title: "Example",
|
|
117
|
+
},
|
|
118
|
+
"x",
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
assert.match(html, /target="_blank"/);
|
|
122
|
+
assert.match(html, /rel="noopener noreferrer"/);
|
|
123
|
+
assert.match(html, /data-testid="nav-link"/);
|
|
124
|
+
assert.match(html, /title="Example"/);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { AnchorHTMLAttributes, AriaAttributes, Ref } from "react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
|
|
4
|
+
export type NavLinkSurfaceVariant = "nav" | "row" | "inline";
|
|
5
|
+
|
|
6
|
+
/** Every `aria-current` value ARIA defines, minus the absent case. */
|
|
7
|
+
export type NavLinkCurrent = NonNullable<AriaAttributes["aria-current"]>;
|
|
8
|
+
|
|
9
|
+
export interface NavLinkSurfaceProps
|
|
10
|
+
extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
11
|
+
variant?: NavLinkSurfaceVariant;
|
|
12
|
+
/**
|
|
13
|
+
* Which kind of "current" this destination is. Sets `aria-current` and turns
|
|
14
|
+
* on the current-state styling together, so the two cannot disagree. A
|
|
15
|
+
* caller that already sets `aria-current` — the router binding does — gets
|
|
16
|
+
* the same styling without passing this.
|
|
17
|
+
*/
|
|
18
|
+
current?: NavLinkCurrent;
|
|
19
|
+
ref?: Ref<HTMLAnchorElement>;
|
|
20
|
+
/** `data-status` is what the router's link props mark an active link with. */
|
|
21
|
+
[key: `data-${string}`]: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const OFFSET_RING =
|
|
25
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-surface";
|
|
26
|
+
|
|
27
|
+
/** A full-bleed row has no margin to spend on an offset ring. */
|
|
28
|
+
const INSET_RING =
|
|
29
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset";
|
|
30
|
+
|
|
31
|
+
const variants: Record<
|
|
32
|
+
NavLinkSurfaceVariant,
|
|
33
|
+
{ base: string; rest: string; current: string }
|
|
34
|
+
> = {
|
|
35
|
+
nav: {
|
|
36
|
+
base: cn(
|
|
37
|
+
"flex w-full items-center gap-2 rounded-md px-2 py-1 text-sm transition-colors",
|
|
38
|
+
OFFSET_RING,
|
|
39
|
+
),
|
|
40
|
+
rest: "text-fg-muted hover:bg-surface hover:text-fg",
|
|
41
|
+
current: "bg-accent-2-soft font-medium text-accent-2",
|
|
42
|
+
},
|
|
43
|
+
row: {
|
|
44
|
+
base: cn(
|
|
45
|
+
"relative flex w-full items-start text-left transition-colors",
|
|
46
|
+
INSET_RING,
|
|
47
|
+
),
|
|
48
|
+
rest: "hover:bg-surface-sunken",
|
|
49
|
+
current: "bg-accent-2-soft",
|
|
50
|
+
},
|
|
51
|
+
inline: {
|
|
52
|
+
base: cn(
|
|
53
|
+
"rounded-sm underline-offset-2 transition-colors hover:underline",
|
|
54
|
+
OFFSET_RING,
|
|
55
|
+
),
|
|
56
|
+
rest: "text-accent hover:text-accent-hover",
|
|
57
|
+
current: "font-medium text-accent",
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function isCurrent(value: NavLinkCurrent | undefined): boolean {
|
|
62
|
+
return value !== undefined && value !== false && value !== "false";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The design system's navigation link: a real `<a>`, so middle-click, cmd-click,
|
|
67
|
+
* "copy link address" and the browser's own Enter handling all work without a
|
|
68
|
+
* single handler of ours. It carries appearance and accessible state only —
|
|
69
|
+
* where the link goes is the router binding's business, and this package imports
|
|
70
|
+
* no router.
|
|
71
|
+
*/
|
|
72
|
+
export function NavLinkSurface({
|
|
73
|
+
variant = "nav",
|
|
74
|
+
current,
|
|
75
|
+
"aria-current": ariaCurrent,
|
|
76
|
+
className,
|
|
77
|
+
children,
|
|
78
|
+
ref,
|
|
79
|
+
...props
|
|
80
|
+
}: NavLinkSurfaceProps) {
|
|
81
|
+
const resolved = current ?? ariaCurrent;
|
|
82
|
+
const style = variants[variant];
|
|
83
|
+
return (
|
|
84
|
+
<a
|
|
85
|
+
ref={ref}
|
|
86
|
+
aria-current={resolved}
|
|
87
|
+
className={cn(
|
|
88
|
+
style.base,
|
|
89
|
+
isCurrent(resolved) ? style.current : style.rest,
|
|
90
|
+
className,
|
|
91
|
+
)}
|
|
92
|
+
{...props}
|
|
93
|
+
>
|
|
94
|
+
{children}
|
|
95
|
+
</a>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
-
import { expect, userEvent } from "storybook/test";
|
|
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";
|
|
6
6
|
import {
|
|
@@ -8,6 +8,16 @@ import {
|
|
|
8
8
|
ComposeModeToggle,
|
|
9
9
|
} from "./compose-mode-toggle.js";
|
|
10
10
|
import { RichTextEditor } from "./rich-text-editor.js";
|
|
11
|
+
import type {
|
|
12
|
+
CheckRequest,
|
|
13
|
+
Finding,
|
|
14
|
+
SpellcheckOptions,
|
|
15
|
+
} from "./rich-text-spellcheck.js";
|
|
16
|
+
import {
|
|
17
|
+
dictionaryFor,
|
|
18
|
+
findMisspellings,
|
|
19
|
+
} from "./rich-text-spellcheck-words.js";
|
|
20
|
+
import { openSpellcheckWorker } from "./rich-text-spellcheck-worker-provider.js";
|
|
11
21
|
|
|
12
22
|
/**
|
|
13
23
|
* The frame is the compose body region at its real geometry — a column with a
|
|
@@ -189,6 +199,159 @@ export const NarrowToolbar: Story = {
|
|
|
189
199
|
},
|
|
190
200
|
};
|
|
191
201
|
|
|
202
|
+
const MISSPELT = "Ths report is redy today, and the notes are attachd.";
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A real module worker does the checking, over the same messages an engine
|
|
206
|
+
* would answer: the component is handed a provider and never learns where the
|
|
207
|
+
* words came from. What the worker holds instead of a dictionary is a short
|
|
208
|
+
* list of English words.
|
|
209
|
+
*/
|
|
210
|
+
const workerSpellcheck: SpellcheckOptions = { provider: openSpellcheckWorker };
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The same findings, answered against the revision before the one asked for —
|
|
214
|
+
* what a slow engine looks like when the text has already moved on.
|
|
215
|
+
*/
|
|
216
|
+
const staleAnswers: string[] = [];
|
|
217
|
+
|
|
218
|
+
const staleSpellcheck: SpellcheckOptions = {
|
|
219
|
+
provider: async () => ({
|
|
220
|
+
language: "en",
|
|
221
|
+
onStatus: (listener) => {
|
|
222
|
+
listener({ state: "ready", language: "en" });
|
|
223
|
+
return () => {};
|
|
224
|
+
},
|
|
225
|
+
check: (request: CheckRequest) => {
|
|
226
|
+
staleAnswers.push(request.requestId);
|
|
227
|
+
const words = dictionaryFor(request.language) ?? new Set<string>();
|
|
228
|
+
return Promise.resolve({
|
|
229
|
+
requestId: request.requestId,
|
|
230
|
+
revision: request.revision - 1,
|
|
231
|
+
findings: request.spans.flatMap((span) =>
|
|
232
|
+
findMisspellings(span.text, words).map(
|
|
233
|
+
(range): Finding => ({
|
|
234
|
+
spanId: span.spanId,
|
|
235
|
+
start: range.start,
|
|
236
|
+
end: range.end,
|
|
237
|
+
kind: "spelling",
|
|
238
|
+
suggestions: [],
|
|
239
|
+
}),
|
|
240
|
+
),
|
|
241
|
+
),
|
|
242
|
+
});
|
|
243
|
+
},
|
|
244
|
+
close: () => {},
|
|
245
|
+
}),
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Every editor on the page shares the one registry entry, so a story reads back
|
|
250
|
+
* the marks that fall inside its own writing surface — on a docs page the
|
|
251
|
+
* neighbouring stories are drawing into it at the same time.
|
|
252
|
+
*/
|
|
253
|
+
const spellMarks = (editable: HTMLElement): AbstractRange[] => {
|
|
254
|
+
const ranges: AbstractRange[] = [];
|
|
255
|
+
CSS.highlights.forEach((highlight, name) => {
|
|
256
|
+
if (name !== "spell-error") return;
|
|
257
|
+
highlight.forEach((range) => {
|
|
258
|
+
if (editable.contains(range.startContainer)) ranges.push(range);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
return ranges;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const spellMarkOffsets = (editable: HTMLElement): [number, number][] =>
|
|
265
|
+
spellMarks(editable).map((range) => [range.startOffset, range.endOffset]);
|
|
266
|
+
|
|
267
|
+
const writingSurface = (canvasElement: HTMLElement): HTMLElement => {
|
|
268
|
+
const editable = canvasElement.querySelector<HTMLElement>(
|
|
269
|
+
"[data-testid=compose-body]",
|
|
270
|
+
);
|
|
271
|
+
if (!editable) throw new Error("the editor is not mounted");
|
|
272
|
+
return editable;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The marks a provider produced, drawn through the CSS Custom Highlight
|
|
277
|
+
* registry: two misspelt words carry a squiggle, the browser's own checking is
|
|
278
|
+
* off while ours is on, and the document holds nothing that was not typed.
|
|
279
|
+
*/
|
|
280
|
+
export const SpellcheckMarks: Story = {
|
|
281
|
+
name: "Spellcheck marks (worker provider)",
|
|
282
|
+
args: {
|
|
283
|
+
initialHtml: `<p>${MISSPELT}</p>`,
|
|
284
|
+
lang: "en",
|
|
285
|
+
spellcheck: workerSpellcheck,
|
|
286
|
+
},
|
|
287
|
+
play: async ({ canvasElement }) => {
|
|
288
|
+
const editable = writingSurface(canvasElement);
|
|
289
|
+
|
|
290
|
+
await waitFor(
|
|
291
|
+
() =>
|
|
292
|
+
expect(spellMarkOffsets(editable)).toEqual([
|
|
293
|
+
[0, 3],
|
|
294
|
+
[14, 18],
|
|
295
|
+
[44, 51],
|
|
296
|
+
]),
|
|
297
|
+
{ timeout: 5000 },
|
|
298
|
+
);
|
|
299
|
+
await expect(editable.getAttribute("spellcheck")).toBe("false");
|
|
300
|
+
await expect(editable.textContent).toBe(MISSPELT);
|
|
301
|
+
await expect(editable.querySelectorAll("[data-lexical-text]")).toHaveLength(
|
|
302
|
+
1,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// The word the caret sits in is left alone until the writer moves on.
|
|
306
|
+
await userEvent.click(editable);
|
|
307
|
+
const line = editable.querySelector<HTMLElement>("[data-lexical-text]");
|
|
308
|
+
const characters = line?.firstChild ?? null;
|
|
309
|
+
canvasElement.ownerDocument.getSelection()?.setPosition(characters, 17);
|
|
310
|
+
|
|
311
|
+
await waitFor(
|
|
312
|
+
() =>
|
|
313
|
+
expect(spellMarkOffsets(editable)).toEqual([
|
|
314
|
+
[0, 3],
|
|
315
|
+
[44, 51],
|
|
316
|
+
]),
|
|
317
|
+
{ timeout: 5000 },
|
|
318
|
+
);
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
/** A language the build carries no dictionary for: the browser keeps checking. */
|
|
323
|
+
export const SpellcheckWithoutDictionary: Story = {
|
|
324
|
+
name: "Spellcheck with no dictionary for the language",
|
|
325
|
+
args: {
|
|
326
|
+
initialHtml: `<p>Vielen Dank für den Bericht.</p>`,
|
|
327
|
+
lang: "de",
|
|
328
|
+
spellcheck: workerSpellcheck,
|
|
329
|
+
},
|
|
330
|
+
play: async ({ canvasElement }) => {
|
|
331
|
+
const editable = writingSurface(canvasElement);
|
|
332
|
+
await expect(editable.getAttribute("spellcheck")).toBe("true");
|
|
333
|
+
await expect(spellMarks(editable)).toHaveLength(0);
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/** An answer against a revision the document has moved past paints nothing. */
|
|
338
|
+
export const SpellcheckStaleAnswer: Story = {
|
|
339
|
+
name: "Spellcheck drops a stale answer",
|
|
340
|
+
args: {
|
|
341
|
+
initialHtml: `<p>${MISSPELT}</p>`,
|
|
342
|
+
lang: "en",
|
|
343
|
+
spellcheck: staleSpellcheck,
|
|
344
|
+
},
|
|
345
|
+
play: async ({ canvasElement }) => {
|
|
346
|
+
const editable = writingSurface(canvasElement);
|
|
347
|
+
await waitFor(() => expect(staleAnswers.length).toBeGreaterThan(0), {
|
|
348
|
+
timeout: 5000,
|
|
349
|
+
});
|
|
350
|
+
await expect(spellMarks(editable)).toHaveLength(0);
|
|
351
|
+
await expect(editable.textContent).toBe(MISSPELT);
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
|
|
192
355
|
/**
|
|
193
356
|
* The toolbar and the body share one scroller, so twenty lines of typing would
|
|
194
357
|
* carry the toolbar off the top with them. It stays at the top of the body
|