@remit/ui 0.0.114 → 0.0.116
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 +4 -4
- package/src/components/isolated-email-frame.tsx +1 -18
- package/src/components/popover-menu.tsx +230 -27
- package/src/components/pull-to-refresh.tsx +1 -19
- 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/index.ts +5 -0
- package/src/lib/use-match-media.ts +25 -0
- package/src/rich-text.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.116",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src"
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"scripts": {
|
|
14
14
|
"test": "npm run test:typecheck && npm run test:run",
|
|
15
15
|
"test:typecheck": "tsgo --noEmit",
|
|
16
|
-
"test:run": "node --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=90 --test 'src/**/*.test.ts'"
|
|
16
|
+
"test:run": "node $NODE_TEST_FLAGS --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=90 --test 'src/**/*.test.ts'"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"@fontsource-variable/geist": "^5",
|
|
@@ -44,12 +44,12 @@
|
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"react": "^19",
|
|
46
46
|
"react-dom": "^19",
|
|
47
|
-
"@types/react": "^19"
|
|
47
|
+
"@types/react": "^19",
|
|
48
|
+
"@types/react-dom": "^19"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
51
|
"@storybook/react": "^9",
|
|
51
52
|
"@types/jsdom": "^28.0.3",
|
|
52
|
-
"@types/react-dom": "^19",
|
|
53
53
|
"jsdom": "^29.1.1",
|
|
54
54
|
"react": "^19",
|
|
55
55
|
"react-dom": "^19",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useMatchMedia } from "../lib/use-match-media.js";
|
|
2
3
|
import { buildEmailSrcDoc, type EmailFrameVariant } from "./email-frame-css.js";
|
|
3
4
|
|
|
4
5
|
export interface IsolatedEmailFrameProps {
|
|
@@ -86,24 +87,6 @@ export const computeFitScale = (
|
|
|
86
87
|
return Math.max(MIN_SCALE, containerWidth / contentWidth);
|
|
87
88
|
};
|
|
88
89
|
|
|
89
|
-
const useMatchMedia = (query: string): boolean => {
|
|
90
|
-
const [matches, setMatches] = useState(() => {
|
|
91
|
-
if (typeof window === "undefined" || !window.matchMedia) return false;
|
|
92
|
-
return window.matchMedia(query).matches;
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
useEffect(() => {
|
|
96
|
-
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
97
|
-
const mql = window.matchMedia(query);
|
|
98
|
-
setMatches(mql.matches);
|
|
99
|
-
const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
|
|
100
|
-
mql.addEventListener("change", handler);
|
|
101
|
-
return () => mql.removeEventListener("change", handler);
|
|
102
|
-
}, [query]);
|
|
103
|
-
|
|
104
|
-
return matches;
|
|
105
|
-
};
|
|
106
|
-
|
|
107
90
|
/** Named (non-character) keys worth replaying: moving around and closing. */
|
|
108
91
|
const FORWARDED_NAMED_KEYS = new Set([
|
|
109
92
|
"Enter",
|
|
@@ -1,8 +1,139 @@
|
|
|
1
1
|
import { EllipsisVertical } from "lucide-react";
|
|
2
|
-
import
|
|
2
|
+
import type { CSSProperties, Ref, RefObject } from "react";
|
|
3
|
+
import {
|
|
4
|
+
type ReactNode,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { createPortal } from "react-dom";
|
|
3
11
|
import { cn } from "../lib/cn.js";
|
|
4
12
|
import { Button } from "./button.js";
|
|
5
13
|
|
|
14
|
+
/** Viewport-relative bounding box of whatever a menu opens against. */
|
|
15
|
+
export interface PopoverMenuAnchor {
|
|
16
|
+
readonly left: number;
|
|
17
|
+
readonly right: number;
|
|
18
|
+
readonly top: number;
|
|
19
|
+
readonly bottom: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ANCHOR_GAP_PX = 4;
|
|
23
|
+
const VIEWPORT_MARGIN_PX = 8;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where the panel lands: below the anchor and aligned to whichever edge
|
|
27
|
+
* `align` names, pulled back onto the screen on every side it would
|
|
28
|
+
* otherwise cross. A panel too tall for the space below flips above the
|
|
29
|
+
* anchor instead of running past the bottom edge.
|
|
30
|
+
*/
|
|
31
|
+
function clampToViewport(
|
|
32
|
+
anchor: PopoverMenuAnchor,
|
|
33
|
+
panel: { width: number; height: number },
|
|
34
|
+
align: "start" | "end",
|
|
35
|
+
): { left: number; top: number } {
|
|
36
|
+
const maxLeft = Math.max(
|
|
37
|
+
VIEWPORT_MARGIN_PX,
|
|
38
|
+
window.innerWidth - panel.width - VIEWPORT_MARGIN_PX,
|
|
39
|
+
);
|
|
40
|
+
const preferredLeft =
|
|
41
|
+
align === "end" ? anchor.right - panel.width : anchor.left;
|
|
42
|
+
const left = Math.min(Math.max(preferredLeft, VIEWPORT_MARGIN_PX), maxLeft);
|
|
43
|
+
|
|
44
|
+
const below = anchor.bottom + ANCHOR_GAP_PX;
|
|
45
|
+
const above = anchor.top - ANCHOR_GAP_PX - panel.height;
|
|
46
|
+
const fitsBelow =
|
|
47
|
+
below + panel.height <= window.innerHeight - VIEWPORT_MARGIN_PX;
|
|
48
|
+
const top = fitsBelow || above < VIEWPORT_MARGIN_PX ? below : above;
|
|
49
|
+
|
|
50
|
+
return { left, top };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Measures the anchor and the panel's own size, then keeps the panel's fixed
|
|
55
|
+
* position clamped to the viewport for as long as it is open — reset on every
|
|
56
|
+
* resize and on scroll anywhere in the ancestor chain, since a fixed position
|
|
57
|
+
* does not follow a scrolled anchor on its own.
|
|
58
|
+
*/
|
|
59
|
+
function useAnchoredPlacement(
|
|
60
|
+
panelRef: RefObject<HTMLElement | null>,
|
|
61
|
+
open: boolean,
|
|
62
|
+
align: "start" | "end",
|
|
63
|
+
getAnchor: () => PopoverMenuAnchor | null,
|
|
64
|
+
): CSSProperties | null {
|
|
65
|
+
const [style, setStyle] = useState<CSSProperties | null>(null);
|
|
66
|
+
const getAnchorRef = useRef(getAnchor);
|
|
67
|
+
getAnchorRef.current = getAnchor;
|
|
68
|
+
|
|
69
|
+
useLayoutEffect(() => {
|
|
70
|
+
if (!open) {
|
|
71
|
+
setStyle(null);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const place = () => {
|
|
75
|
+
const panel = panelRef.current;
|
|
76
|
+
const anchor = getAnchorRef.current();
|
|
77
|
+
if (!panel || !anchor) return;
|
|
78
|
+
// `.width`/`.height` over the rect's own edges: a `getBoundingClientRect`
|
|
79
|
+
// stand-in in a test carries the edges without the derived pair.
|
|
80
|
+
const rect = panel.getBoundingClientRect();
|
|
81
|
+
const size = {
|
|
82
|
+
width: rect.right - rect.left,
|
|
83
|
+
height: rect.bottom - rect.top,
|
|
84
|
+
};
|
|
85
|
+
const placement = clampToViewport(anchor, size, align);
|
|
86
|
+
setStyle((previous) =>
|
|
87
|
+
previous?.left === placement.left && previous.top === placement.top
|
|
88
|
+
? previous
|
|
89
|
+
: { position: "fixed", left: placement.left, top: placement.top },
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
place();
|
|
93
|
+
window.addEventListener("resize", place);
|
|
94
|
+
window.addEventListener("scroll", place, true);
|
|
95
|
+
return () => {
|
|
96
|
+
window.removeEventListener("resize", place);
|
|
97
|
+
window.removeEventListener("scroll", place, true);
|
|
98
|
+
};
|
|
99
|
+
}, [open, align, panelRef]);
|
|
100
|
+
|
|
101
|
+
return style;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface PopoverMenuPortalProps {
|
|
105
|
+
open: boolean;
|
|
106
|
+
align?: "start" | "end";
|
|
107
|
+
getAnchor: () => PopoverMenuAnchor | null;
|
|
108
|
+
panelRef: RefObject<HTMLElement | null>;
|
|
109
|
+
children: ReactNode;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Carries a menu panel out of the DOM subtree it opened from and into the
|
|
114
|
+
* document body, positioned at a fixed, viewport-clamped point. A panel left
|
|
115
|
+
* in place is clipped by the first scrolling ancestor between it and the
|
|
116
|
+
* page — the compose body, a card, anything with its own `overflow` — no
|
|
117
|
+
* matter how high its `z-index` climbs; escaping that ancestor takes leaving
|
|
118
|
+
* its DOM subtree, which only a portal does.
|
|
119
|
+
*/
|
|
120
|
+
export function PopoverMenuPortal({
|
|
121
|
+
open,
|
|
122
|
+
align = "start",
|
|
123
|
+
getAnchor,
|
|
124
|
+
panelRef,
|
|
125
|
+
children,
|
|
126
|
+
}: PopoverMenuPortalProps) {
|
|
127
|
+
const style = useAnchoredPlacement(panelRef, open, align, getAnchor);
|
|
128
|
+
if (!open) return null;
|
|
129
|
+
return createPortal(
|
|
130
|
+
<div style={style ?? { position: "fixed", visibility: "hidden" }}>
|
|
131
|
+
{children}
|
|
132
|
+
</div>,
|
|
133
|
+
document.body,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
6
137
|
export interface PopoverMenuItem {
|
|
7
138
|
/** Stable key, also the accessible text of the row. */
|
|
8
139
|
key: string;
|
|
@@ -11,6 +142,83 @@ export interface PopoverMenuItem {
|
|
|
11
142
|
onSelect: () => void;
|
|
12
143
|
}
|
|
13
144
|
|
|
145
|
+
export interface PopoverMenuPanelProps {
|
|
146
|
+
label?: string;
|
|
147
|
+
children: ReactNode;
|
|
148
|
+
className?: string;
|
|
149
|
+
ref?: Ref<HTMLDivElement>;
|
|
150
|
+
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
|
151
|
+
"data-testid"?: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The menu itself, without a trigger: the surface, the scrolling and the
|
|
156
|
+
* rounding every dropdown in the kit shares. A menu that hangs off a button
|
|
157
|
+
* gets it through {@link PopoverMenu}; one anchored to something else — the
|
|
158
|
+
* misspelt word under the pointer — positions this and keeps the same chrome.
|
|
159
|
+
*/
|
|
160
|
+
export function PopoverMenuPanel({
|
|
161
|
+
label,
|
|
162
|
+
children,
|
|
163
|
+
className,
|
|
164
|
+
ref,
|
|
165
|
+
onKeyDown,
|
|
166
|
+
"data-testid": testId,
|
|
167
|
+
}: PopoverMenuPanelProps) {
|
|
168
|
+
return (
|
|
169
|
+
<div
|
|
170
|
+
ref={ref}
|
|
171
|
+
role="menu"
|
|
172
|
+
aria-label={label}
|
|
173
|
+
tabIndex={-1}
|
|
174
|
+
onKeyDown={onKeyDown}
|
|
175
|
+
data-testid={testId}
|
|
176
|
+
className={cn(
|
|
177
|
+
"z-50 flex max-h-[60dvh] min-w-44 flex-col overflow-y-auto overscroll-contain rounded-md border border-line bg-surface py-1 shadow-lg outline-none",
|
|
178
|
+
className,
|
|
179
|
+
)}
|
|
180
|
+
>
|
|
181
|
+
{children}
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface PopoverMenuRowProps {
|
|
187
|
+
label: string;
|
|
188
|
+
icon?: ReactNode;
|
|
189
|
+
onSelect: () => void;
|
|
190
|
+
className?: string;
|
|
191
|
+
lang?: string;
|
|
192
|
+
"data-testid"?: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** One selectable row of a menu panel, at the kit's touch height. */
|
|
196
|
+
export function PopoverMenuRow({
|
|
197
|
+
label,
|
|
198
|
+
icon,
|
|
199
|
+
onSelect,
|
|
200
|
+
className,
|
|
201
|
+
lang,
|
|
202
|
+
"data-testid": testId,
|
|
203
|
+
}: PopoverMenuRowProps) {
|
|
204
|
+
return (
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
role="menuitem"
|
|
208
|
+
lang={lang}
|
|
209
|
+
onClick={onSelect}
|
|
210
|
+
data-testid={testId}
|
|
211
|
+
className={cn(
|
|
212
|
+
"flex min-h-11 items-center gap-3 px-4 py-2.5 text-left text-sm text-fg transition-colors hover:bg-surface-sunken",
|
|
213
|
+
className,
|
|
214
|
+
)}
|
|
215
|
+
>
|
|
216
|
+
{icon && <span className="shrink-0 text-fg-subtle">{icon}</span>}
|
|
217
|
+
{label}
|
|
218
|
+
</button>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
14
222
|
export interface PopoverMenuProps {
|
|
15
223
|
/** Accessible label for the trigger button. */
|
|
16
224
|
triggerLabel: string;
|
|
@@ -65,16 +273,18 @@ export function PopoverMenu({
|
|
|
65
273
|
}: PopoverMenuProps) {
|
|
66
274
|
const [open, setOpen] = useState(false);
|
|
67
275
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
276
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
68
277
|
|
|
69
278
|
useEffect(() => {
|
|
70
279
|
if (!open) return;
|
|
71
280
|
const onPointer = (event: MouseEvent) => {
|
|
281
|
+
const target = event.target as Node;
|
|
72
282
|
if (
|
|
73
|
-
containerRef.current
|
|
74
|
-
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
|
|
283
|
+
containerRef.current?.contains(target) ||
|
|
284
|
+
panelRef.current?.contains(target)
|
|
285
|
+
)
|
|
286
|
+
return;
|
|
287
|
+
setOpen(false);
|
|
78
288
|
};
|
|
79
289
|
const onKey = (event: KeyboardEvent) => {
|
|
80
290
|
if (event.key === "Escape") setOpen(false);
|
|
@@ -108,34 +318,27 @@ export function PopoverMenu({
|
|
|
108
318
|
>
|
|
109
319
|
{triggerText}
|
|
110
320
|
</Button>
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
>
|
|
321
|
+
<PopoverMenuPortal
|
|
322
|
+
open={open}
|
|
323
|
+
align={align}
|
|
324
|
+
panelRef={panelRef}
|
|
325
|
+
getAnchor={() => containerRef.current?.getBoundingClientRect() ?? null}
|
|
326
|
+
>
|
|
327
|
+
<PopoverMenuPanel ref={panelRef}>
|
|
119
328
|
{items.map((item) => (
|
|
120
|
-
<
|
|
329
|
+
<PopoverMenuRow
|
|
121
330
|
key={item.key}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
331
|
+
label={item.label}
|
|
332
|
+
icon={item.icon}
|
|
333
|
+
onSelect={() => {
|
|
125
334
|
setOpen(false);
|
|
126
335
|
item.onSelect();
|
|
127
336
|
}}
|
|
128
|
-
|
|
129
|
-
>
|
|
130
|
-
{item.icon && (
|
|
131
|
-
<span className="shrink-0 text-fg-subtle">{item.icon}</span>
|
|
132
|
-
)}
|
|
133
|
-
{item.label}
|
|
134
|
-
</button>
|
|
337
|
+
/>
|
|
135
338
|
))}
|
|
136
339
|
{children}
|
|
137
|
-
</
|
|
138
|
-
|
|
340
|
+
</PopoverMenuPanel>
|
|
341
|
+
</PopoverMenuPortal>
|
|
139
342
|
</div>
|
|
140
343
|
);
|
|
141
344
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ReactElement } from "react";
|
|
2
|
-
import { useEffect, useState } from "react";
|
|
3
2
|
import ReactPullToRefresh from "react-simple-pull-to-refresh";
|
|
4
3
|
import { DESKTOP_MEDIA_QUERY } from "../lib/layout-breakpoints.js";
|
|
4
|
+
import { useMatchMedia } from "../lib/use-match-media.js";
|
|
5
5
|
|
|
6
6
|
export interface PullToRefreshProps {
|
|
7
7
|
children: ReactElement;
|
|
@@ -9,24 +9,6 @@ export interface PullToRefreshProps {
|
|
|
9
9
|
isRefreshing?: boolean;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
const useMatchMedia = (query: string): boolean => {
|
|
13
|
-
const [matches, setMatches] = useState(() => {
|
|
14
|
-
if (typeof window === "undefined" || !window.matchMedia) return false;
|
|
15
|
-
return window.matchMedia(query).matches;
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
useEffect(() => {
|
|
19
|
-
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
20
|
-
const mql = window.matchMedia(query);
|
|
21
|
-
setMatches(mql.matches);
|
|
22
|
-
const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
|
|
23
|
-
mql.addEventListener("change", handler);
|
|
24
|
-
return () => mql.removeEventListener("change", handler);
|
|
25
|
-
}, [query]);
|
|
26
|
-
|
|
27
|
-
return matches;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
12
|
/**
|
|
31
13
|
* Wraps a scrollable list with a pull-to-refresh gesture on mobile. Below the
|
|
32
14
|
* desktop breakpoint (Tailwind `lg`) a downward pull at the top of the list
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { BookPlus, EyeOff } from "lucide-react";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { DESKTOP_MEDIA_QUERY } from "../lib/layout-breakpoints.js";
|
|
4
|
+
import { useRovingFocus } from "../lib/roving-focus.js";
|
|
5
|
+
import { useMatchMedia } from "../lib/use-match-media.js";
|
|
6
|
+
import { BottomSheet } from "./bottom-sheet.js";
|
|
7
|
+
import {
|
|
8
|
+
type PopoverMenuAnchor,
|
|
9
|
+
PopoverMenuPanel,
|
|
10
|
+
PopoverMenuPortal,
|
|
11
|
+
PopoverMenuRow,
|
|
12
|
+
} from "./popover-menu.js";
|
|
13
|
+
|
|
14
|
+
const SKELETON_WIDTHS = ["w-28", "w-20", "w-24"];
|
|
15
|
+
|
|
16
|
+
export type CorrectionMenuAnchor = PopoverMenuAnchor;
|
|
17
|
+
|
|
18
|
+
export interface RichTextCorrectionMenuProps {
|
|
19
|
+
word: string;
|
|
20
|
+
/** Null while the checker is still answering. */
|
|
21
|
+
suggestions: readonly string[] | null;
|
|
22
|
+
/** What the checker said when it could not answer. */
|
|
23
|
+
failure: string | null;
|
|
24
|
+
anchor: CorrectionMenuAnchor;
|
|
25
|
+
language: string;
|
|
26
|
+
onReplace: (suggestion: string) => void;
|
|
27
|
+
onIgnore: () => void;
|
|
28
|
+
/** Rendered only when the mount carries somewhere to put the word. */
|
|
29
|
+
onAddWord?: () => void;
|
|
30
|
+
/**
|
|
31
|
+
* `returnFocus` is false when the writer's own click is already on its way
|
|
32
|
+
* somewhere else, and taking the caret back would fight it.
|
|
33
|
+
*/
|
|
34
|
+
onDismiss: (returnFocus: boolean) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const CorrectionRows = ({
|
|
38
|
+
word,
|
|
39
|
+
suggestions,
|
|
40
|
+
failure,
|
|
41
|
+
language,
|
|
42
|
+
onReplace,
|
|
43
|
+
onIgnore,
|
|
44
|
+
onAddWord,
|
|
45
|
+
}: Omit<RichTextCorrectionMenuProps, "anchor" | "onDismiss">) => (
|
|
46
|
+
<>
|
|
47
|
+
<p
|
|
48
|
+
lang={language}
|
|
49
|
+
data-testid="spell-word"
|
|
50
|
+
className="px-4 pb-1 pt-1.5 text-xs font-medium text-fg-subtle"
|
|
51
|
+
>
|
|
52
|
+
{word}
|
|
53
|
+
</p>
|
|
54
|
+
{suggestions === null && failure === null
|
|
55
|
+
? SKELETON_WIDTHS.map((width) => (
|
|
56
|
+
<div
|
|
57
|
+
key={width}
|
|
58
|
+
aria-hidden="true"
|
|
59
|
+
data-testid="spell-suggestion-skeleton"
|
|
60
|
+
className="flex min-h-11 items-center px-4 py-2.5"
|
|
61
|
+
>
|
|
62
|
+
<span
|
|
63
|
+
className={`h-3 animate-pulse rounded bg-surface-sunken ${width}`}
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
))
|
|
67
|
+
: null}
|
|
68
|
+
{failure !== null && (
|
|
69
|
+
<p
|
|
70
|
+
role="alert"
|
|
71
|
+
data-testid="spell-suggestions-failed"
|
|
72
|
+
className="px-4 py-2 text-sm text-danger"
|
|
73
|
+
>
|
|
74
|
+
No suggestions came back: {failure}
|
|
75
|
+
</p>
|
|
76
|
+
)}
|
|
77
|
+
{suggestions?.length === 0 && (
|
|
78
|
+
<p
|
|
79
|
+
data-testid="spell-no-suggestions"
|
|
80
|
+
className="px-4 py-2 text-sm text-fg-muted"
|
|
81
|
+
>
|
|
82
|
+
No suggestions for this word.
|
|
83
|
+
</p>
|
|
84
|
+
)}
|
|
85
|
+
{suggestions?.map((suggestion) => (
|
|
86
|
+
<PopoverMenuRow
|
|
87
|
+
key={suggestion}
|
|
88
|
+
label={suggestion}
|
|
89
|
+
lang={language}
|
|
90
|
+
data-testid="spell-suggestion"
|
|
91
|
+
className="font-medium"
|
|
92
|
+
onSelect={() => onReplace(suggestion)}
|
|
93
|
+
/>
|
|
94
|
+
))}
|
|
95
|
+
<hr className="my-1 border-line" />
|
|
96
|
+
<PopoverMenuRow
|
|
97
|
+
label="Ignore for now"
|
|
98
|
+
icon={<EyeOff className="size-4" />}
|
|
99
|
+
data-testid="spell-ignore"
|
|
100
|
+
onSelect={onIgnore}
|
|
101
|
+
/>
|
|
102
|
+
{onAddWord && (
|
|
103
|
+
<PopoverMenuRow
|
|
104
|
+
label="Add to dictionary"
|
|
105
|
+
icon={<BookPlus className="size-4" />}
|
|
106
|
+
data-testid="spell-add-word"
|
|
107
|
+
onSelect={onAddWord}
|
|
108
|
+
/>
|
|
109
|
+
)}
|
|
110
|
+
</>
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The corrections offered for one misspelt word. The menu is up before the
|
|
115
|
+
* checker has answered — skeleton rows stand where the suggestions will be —
|
|
116
|
+
* and it never goes quiet: a request that failed says so where the suggestions
|
|
117
|
+
* would have been, and ignoring the word stays reachable either way.
|
|
118
|
+
*
|
|
119
|
+
* Below the desktop gate the same rows are a sheet, because a popover anchored
|
|
120
|
+
* to a word sits under the thumb that opened it.
|
|
121
|
+
*/
|
|
122
|
+
export const RichTextCorrectionMenu = ({
|
|
123
|
+
anchor,
|
|
124
|
+
onDismiss,
|
|
125
|
+
...rows
|
|
126
|
+
}: RichTextCorrectionMenuProps) => {
|
|
127
|
+
const desktop = useMatchMedia(DESKTOP_MEDIA_QUERY);
|
|
128
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
129
|
+
|
|
130
|
+
useRovingFocus({ containerRef: panelRef, itemSelector: '[role="menuitem"]' });
|
|
131
|
+
|
|
132
|
+
// The menu takes focus as it opens, so Escape and the arrow keys have
|
|
133
|
+
// somewhere to land rather than staying behind in the message.
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
panelRef.current?.focus();
|
|
136
|
+
}, []);
|
|
137
|
+
|
|
138
|
+
// A press rather than a click, and a pointer press rather than a mouse one:
|
|
139
|
+
// the tap that opened this menu is followed by the browser's compatibility
|
|
140
|
+
// mouse events, which a `mousedown` listener reads as a press somewhere else
|
|
141
|
+
// and closes the menu on the way up.
|
|
142
|
+
//
|
|
143
|
+
// Escape is caught here too, at the document rather than the panel: the
|
|
144
|
+
// panel takes focus on mount so the panel's own `onKeyDown` ordinarily
|
|
145
|
+
// gets there first and this one never fires (its `stopPropagation` keeps
|
|
146
|
+
// the keydown from reaching here), but a focus call that lands a beat
|
|
147
|
+
// later than the browser is ready for is not something to depend on —
|
|
148
|
+
// this is what actually closes the menu when that happens.
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!desktop) return;
|
|
151
|
+
const onPointer = (event: Event) => {
|
|
152
|
+
if (panelRef.current?.contains(event.target as Node)) return;
|
|
153
|
+
onDismiss(false);
|
|
154
|
+
};
|
|
155
|
+
const onDocumentKeyDown = (event: KeyboardEvent) => {
|
|
156
|
+
if (event.key !== "Escape") return;
|
|
157
|
+
onDismiss(true);
|
|
158
|
+
};
|
|
159
|
+
document.addEventListener("pointerdown", onPointer);
|
|
160
|
+
document.addEventListener("keydown", onDocumentKeyDown);
|
|
161
|
+
return () => {
|
|
162
|
+
document.removeEventListener("pointerdown", onPointer);
|
|
163
|
+
document.removeEventListener("keydown", onDocumentKeyDown);
|
|
164
|
+
};
|
|
165
|
+
}, [desktop, onDismiss]);
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Escape closes it, and so does Tab: a menu is not a dialog, and the repo
|
|
169
|
+
* traps focus nowhere. Letting Tab walk on would put the caret behind an
|
|
170
|
+
* open sheet, so the menu goes first and the message has the focus back
|
|
171
|
+
* before the next stop.
|
|
172
|
+
*/
|
|
173
|
+
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
174
|
+
if (event.key !== "Escape" && event.key !== "Tab") return;
|
|
175
|
+
event.preventDefault();
|
|
176
|
+
event.stopPropagation();
|
|
177
|
+
onDismiss(true);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
if (!desktop) {
|
|
181
|
+
return (
|
|
182
|
+
<BottomSheet
|
|
183
|
+
open
|
|
184
|
+
onClose={() => onDismiss(true)}
|
|
185
|
+
dismissLabel="Close corrections"
|
|
186
|
+
>
|
|
187
|
+
<div
|
|
188
|
+
ref={panelRef}
|
|
189
|
+
role="menu"
|
|
190
|
+
aria-label={`Corrections for ${rows.word}`}
|
|
191
|
+
tabIndex={-1}
|
|
192
|
+
onKeyDown={onKeyDown}
|
|
193
|
+
data-testid="spell-menu"
|
|
194
|
+
className="flex flex-col overflow-y-auto pb-4 outline-none"
|
|
195
|
+
>
|
|
196
|
+
<CorrectionRows {...rows} />
|
|
197
|
+
</div>
|
|
198
|
+
</BottomSheet>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<PopoverMenuPortal
|
|
204
|
+
open
|
|
205
|
+
align="start"
|
|
206
|
+
panelRef={panelRef}
|
|
207
|
+
getAnchor={() => anchor}
|
|
208
|
+
>
|
|
209
|
+
<PopoverMenuPanel
|
|
210
|
+
ref={panelRef}
|
|
211
|
+
label={`Corrections for ${rows.word}`}
|
|
212
|
+
onKeyDown={onKeyDown}
|
|
213
|
+
data-testid="spell-menu"
|
|
214
|
+
className="max-w-64"
|
|
215
|
+
>
|
|
216
|
+
<CorrectionRows {...rows} />
|
|
217
|
+
</PopoverMenuPanel>
|
|
218
|
+
</PopoverMenuPortal>
|
|
219
|
+
);
|
|
220
|
+
};
|