@remit/ui 0.0.116 → 0.0.118
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.
|
|
3
|
+
"version": "0.0.118",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src"
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./src/index.ts",
|
|
10
10
|
"./rich-text": "./src/rich-text.ts",
|
|
11
|
+
"./spellcheck-worker": "./src/components/rich-text-spellcheck-worker-provider.ts",
|
|
11
12
|
"./tokens.css": "./src/tokens.css"
|
|
12
13
|
},
|
|
13
14
|
"scripts": {
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composer's checker, mounted through the surface the app mounts (#707).
|
|
3
|
+
* The editor took a `spellcheck` option from the day the marks existed; what is
|
|
4
|
+
* asserted here is that the composer carries one down to it, and that it is
|
|
5
|
+
* opened for the language the composer is actually in — the one the chip and
|
|
6
|
+
* detection settle on, not a second copy of that value.
|
|
7
|
+
*
|
|
8
|
+
* A language the build has no dictionary for is not a failure: the provider
|
|
9
|
+
* answers null and the browser's own checking is switched back on, so the
|
|
10
|
+
* writer never faces a surface that has silently stopped marking anything.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
15
|
+
import type { JSDOM } from "jsdom";
|
|
16
|
+
import type {
|
|
17
|
+
act as reactAct,
|
|
18
|
+
createElement as reactCreateElement,
|
|
19
|
+
} from "react";
|
|
20
|
+
import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
|
|
21
|
+
import type { ComposeBody as ComposeBodyType } from "./compose-body.js";
|
|
22
|
+
import type {
|
|
23
|
+
CheckRequest,
|
|
24
|
+
SpellcheckOptions,
|
|
25
|
+
SpellProvider,
|
|
26
|
+
SuggestRequest,
|
|
27
|
+
} from "./rich-text-spellcheck.js";
|
|
28
|
+
|
|
29
|
+
let dom: JSDOM;
|
|
30
|
+
let container: HTMLElement;
|
|
31
|
+
let root: Root;
|
|
32
|
+
let act: typeof reactAct;
|
|
33
|
+
let createElement: typeof reactCreateElement;
|
|
34
|
+
let createRoot: typeof reactCreateRoot;
|
|
35
|
+
let ComposeBody: typeof ComposeBodyType;
|
|
36
|
+
|
|
37
|
+
/** Short enough that detection declines and the chip stays on the account default. */
|
|
38
|
+
const DOCUMENT = "<p>Ths is redy.</p>";
|
|
39
|
+
|
|
40
|
+
class Marks {
|
|
41
|
+
readonly ranges: Range[] = [];
|
|
42
|
+
add(range: Range): void {
|
|
43
|
+
this.ranges.push(range);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Stands in for the bundled word list: English has a dictionary and nothing
|
|
49
|
+
* else does, which is the shape the real one has today.
|
|
50
|
+
*/
|
|
51
|
+
const recordingSpellcheck = (): {
|
|
52
|
+
options: SpellcheckOptions;
|
|
53
|
+
asked: string[];
|
|
54
|
+
closed: string[];
|
|
55
|
+
} => {
|
|
56
|
+
const asked: string[] = [];
|
|
57
|
+
const closed: string[] = [];
|
|
58
|
+
const options: SpellcheckOptions = {
|
|
59
|
+
provider: (language) => {
|
|
60
|
+
asked.push(language);
|
|
61
|
+
if (language !== "en") return Promise.resolve(null);
|
|
62
|
+
const provider: SpellProvider = {
|
|
63
|
+
language,
|
|
64
|
+
onStatus: (listener) => {
|
|
65
|
+
listener({ state: "ready", language });
|
|
66
|
+
return () => {};
|
|
67
|
+
},
|
|
68
|
+
check: (request: CheckRequest) =>
|
|
69
|
+
Promise.resolve({
|
|
70
|
+
requestId: request.requestId,
|
|
71
|
+
revision: request.revision,
|
|
72
|
+
findings: [],
|
|
73
|
+
}),
|
|
74
|
+
suggest: (request: SuggestRequest) =>
|
|
75
|
+
Promise.resolve({
|
|
76
|
+
requestId: request.requestId,
|
|
77
|
+
word: request.word,
|
|
78
|
+
suggestions: [],
|
|
79
|
+
}),
|
|
80
|
+
close: () => {
|
|
81
|
+
closed.push(language);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
return Promise.resolve(provider);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
return { options, asked, closed };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const settle = async (): Promise<void> => {
|
|
91
|
+
await act(async () => {
|
|
92
|
+
await Promise.resolve();
|
|
93
|
+
});
|
|
94
|
+
await act(async () => {
|
|
95
|
+
await Promise.resolve();
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const editable = (): HTMLElement => {
|
|
100
|
+
const surface = container.querySelector<HTMLElement>(
|
|
101
|
+
"[data-testid=compose-body]",
|
|
102
|
+
);
|
|
103
|
+
if (!surface) throw new Error("the writing surface is not mounted");
|
|
104
|
+
return surface;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const chooseLanguage = async (tag: string): Promise<void> => {
|
|
108
|
+
const chip = container.querySelector<HTMLElement>(
|
|
109
|
+
"[data-testid=compose-language-chip]",
|
|
110
|
+
);
|
|
111
|
+
if (!chip) throw new Error("the language chip is not mounted");
|
|
112
|
+
await act(async () => {
|
|
113
|
+
chip.click();
|
|
114
|
+
});
|
|
115
|
+
const row = container.querySelector<HTMLElement>(
|
|
116
|
+
`[role="menuitemradio"][lang="${tag}"]`,
|
|
117
|
+
);
|
|
118
|
+
if (!row) throw new Error(`the language menu offers no ${tag}`);
|
|
119
|
+
await act(async () => {
|
|
120
|
+
row.click();
|
|
121
|
+
});
|
|
122
|
+
await settle();
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
before(async () => {
|
|
126
|
+
const { JSDOM: JSDOMCtor } = await import("jsdom");
|
|
127
|
+
dom = new JSDOMCtor(
|
|
128
|
+
"<!doctype html><html><body><div id=root></div></body></html>",
|
|
129
|
+
{ url: "http://localhost/", pretendToBeVisual: true },
|
|
130
|
+
);
|
|
131
|
+
globalThis.window = dom.window as unknown as typeof globalThis.window;
|
|
132
|
+
globalThis.document = dom.window.document;
|
|
133
|
+
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
134
|
+
globalThis.Element = dom.window.Element;
|
|
135
|
+
globalThis.Node = dom.window.Node;
|
|
136
|
+
globalThis.Event = dom.window.Event;
|
|
137
|
+
globalThis.MouseEvent = dom.window.MouseEvent;
|
|
138
|
+
globalThis.DOMParser = dom.window.DOMParser;
|
|
139
|
+
globalThis.MutationObserver = dom.window.MutationObserver;
|
|
140
|
+
globalThis.Range = dom.window.Range;
|
|
141
|
+
globalThis.AbortController = dom.window.AbortController;
|
|
142
|
+
globalThis.AbortSignal = dom.window.AbortSignal;
|
|
143
|
+
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
|
|
144
|
+
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(
|
|
145
|
+
dom.window,
|
|
146
|
+
);
|
|
147
|
+
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(
|
|
148
|
+
dom.window,
|
|
149
|
+
);
|
|
150
|
+
Object.defineProperty(globalThis, "navigator", {
|
|
151
|
+
value: dom.window.navigator,
|
|
152
|
+
configurable: true,
|
|
153
|
+
});
|
|
154
|
+
// The marks are drawn through the CSS Custom Highlight registry, which jsdom
|
|
155
|
+
// has neither half of. Without both, the editor draws nothing and never opens
|
|
156
|
+
// a provider at all — which is the browser this suite would be testing.
|
|
157
|
+
Object.defineProperty(globalThis, "CSS", {
|
|
158
|
+
value: { highlights: new Map<string, Marks>() },
|
|
159
|
+
configurable: true,
|
|
160
|
+
});
|
|
161
|
+
Object.defineProperty(globalThis, "Highlight", {
|
|
162
|
+
value: Marks,
|
|
163
|
+
configurable: true,
|
|
164
|
+
});
|
|
165
|
+
(
|
|
166
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
167
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
168
|
+
|
|
169
|
+
({ act, createElement } = await import("react"));
|
|
170
|
+
({ createRoot } = await import("react-dom/client"));
|
|
171
|
+
({ ComposeBody } = await import("./compose-body.js"));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
beforeEach(() => {
|
|
175
|
+
container = dom.window.document.createElement("div");
|
|
176
|
+
dom.window.document.body.append(container);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
afterEach(async () => {
|
|
180
|
+
await act(async () => {
|
|
181
|
+
root.unmount();
|
|
182
|
+
});
|
|
183
|
+
container.remove();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
after(() => {
|
|
187
|
+
dom.window.close();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const mount = async (options: SpellcheckOptions): Promise<void> => {
|
|
191
|
+
await act(async () => {
|
|
192
|
+
root = createRoot(container);
|
|
193
|
+
root.render(
|
|
194
|
+
createElement(ComposeBody, {
|
|
195
|
+
mode: "rich",
|
|
196
|
+
onModeChange: () => undefined,
|
|
197
|
+
initialHtml: DOCUMENT,
|
|
198
|
+
initialText: "Ths is redy.",
|
|
199
|
+
onChange: () => undefined,
|
|
200
|
+
onConversionError: () => undefined,
|
|
201
|
+
onLanguageChange: () => undefined,
|
|
202
|
+
languages: ["en", "nl"],
|
|
203
|
+
spellcheck: options,
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
await settle();
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
describe("the composer's spellchecker", () => {
|
|
211
|
+
it("opens a checker for the language the message is being written in", async () => {
|
|
212
|
+
const { options, asked } = recordingSpellcheck();
|
|
213
|
+
|
|
214
|
+
await mount(options);
|
|
215
|
+
|
|
216
|
+
assert.deepEqual(asked, ["en"]);
|
|
217
|
+
assert.equal(
|
|
218
|
+
editable().getAttribute("spellcheck"),
|
|
219
|
+
"false",
|
|
220
|
+
"the browser stops checking while ours is running",
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("follows the chip: a new language is a new checker", async () => {
|
|
225
|
+
const { options, asked, closed } = recordingSpellcheck();
|
|
226
|
+
await mount(options);
|
|
227
|
+
|
|
228
|
+
await chooseLanguage("nl");
|
|
229
|
+
|
|
230
|
+
assert.deepEqual(asked, ["en", "nl"]);
|
|
231
|
+
assert.deepEqual(
|
|
232
|
+
closed,
|
|
233
|
+
["en"],
|
|
234
|
+
"the checker for the language left behind is taken down",
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("hands the message back to the browser where there is no dictionary", async () => {
|
|
239
|
+
const { options } = recordingSpellcheck();
|
|
240
|
+
await mount(options);
|
|
241
|
+
|
|
242
|
+
await chooseLanguage("nl");
|
|
243
|
+
|
|
244
|
+
assert.equal(
|
|
245
|
+
editable().getAttribute("spellcheck"),
|
|
246
|
+
"true",
|
|
247
|
+
"a language nothing here checks is still checked by the browser",
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("leaves the browser to it when the composer is handed no checker", async () => {
|
|
252
|
+
await act(async () => {
|
|
253
|
+
root = createRoot(container);
|
|
254
|
+
root.render(
|
|
255
|
+
createElement(ComposeBody, {
|
|
256
|
+
mode: "rich",
|
|
257
|
+
onModeChange: () => undefined,
|
|
258
|
+
initialHtml: DOCUMENT,
|
|
259
|
+
initialText: "Ths is redy.",
|
|
260
|
+
onChange: () => undefined,
|
|
261
|
+
onConversionError: () => undefined,
|
|
262
|
+
onLanguageChange: () => undefined,
|
|
263
|
+
languages: ["en", "nl"],
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
await settle();
|
|
268
|
+
|
|
269
|
+
assert.equal(
|
|
270
|
+
editable().getAttribute("spellcheck"),
|
|
271
|
+
"true",
|
|
272
|
+
"a composer with no checker of its own is the composer as it shipped",
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -9,6 +9,7 @@ import { ConfirmDialog } from "./confirm-dialog.js";
|
|
|
9
9
|
import { PlainTextEditor } from "./plain-text-editor.js";
|
|
10
10
|
import { markdownToHtml } from "./rich-text-document.js";
|
|
11
11
|
import { RichTextEditor } from "./rich-text-editor.js";
|
|
12
|
+
import type { SpellcheckOptions } from "./rich-text-spellcheck.js";
|
|
12
13
|
import type { ComposeCaret, RichTextValue } from "./rich-text-value.js";
|
|
13
14
|
import { useComposeLanguage } from "./use-compose-language.js";
|
|
14
15
|
|
|
@@ -62,6 +63,12 @@ export interface ComposeBodyProps {
|
|
|
62
63
|
initialLanguage?: string;
|
|
63
64
|
/** Reports the language the message is being written in, so the form can tag it. */
|
|
64
65
|
onLanguageChange: (language: string) => void;
|
|
66
|
+
/**
|
|
67
|
+
* Where checking comes from, for the language the composer is currently in.
|
|
68
|
+
* Left out, the browser does its own. Plain text is a textarea and is always
|
|
69
|
+
* the browser's.
|
|
70
|
+
*/
|
|
71
|
+
spellcheck?: SpellcheckOptions;
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
/**
|
|
@@ -83,6 +90,7 @@ export const ComposeBody = ({
|
|
|
83
90
|
languages,
|
|
84
91
|
initialLanguage,
|
|
85
92
|
onLanguageChange,
|
|
93
|
+
spellcheck,
|
|
86
94
|
}: ComposeBodyProps) => {
|
|
87
95
|
const [richHtml, setRichHtml] = useState(initialHtml);
|
|
88
96
|
const [richGeneration, setRichGeneration] = useState(0);
|
|
@@ -197,6 +205,7 @@ export const ComposeBody = ({
|
|
|
197
205
|
initialCaret={focusSwitchedSurface ? "end" : initialCaret}
|
|
198
206
|
lang={language}
|
|
199
207
|
trailing={trailing}
|
|
208
|
+
spellcheck={spellcheck}
|
|
200
209
|
/>
|
|
201
210
|
)}
|
|
202
211
|
<ConfirmDialog
|
|
@@ -79,7 +79,7 @@ export const useListCursor = ({
|
|
|
79
79
|
onExitSelection,
|
|
80
80
|
}: UseListCursorOptions): ListCursor => {
|
|
81
81
|
// The keyboard "where am I" pointer, distinct from the open thread
|
|
82
|
-
// (
|
|
82
|
+
// (the message segment in the path). j/k move this cursor; Enter opens the
|
|
83
83
|
// focused row, and on desktop the reading pane follows the cursor of its own
|
|
84
84
|
// accord (`useFollowFocusOpen`). It seeds from the open thread so opening a
|
|
85
85
|
// message also focuses its row.
|