@remit/web-client 0.0.210 → 0.0.211
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/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.211",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The drawer's focus trap (#747).
|
|
3
|
+
*
|
|
4
|
+
* The drawer is `aria-modal`, so a screen reader offers nothing outside it and
|
|
5
|
+
* the scrim puts everything outside it out of a pointer's reach. Tab has to
|
|
6
|
+
* agree with both: it cycles inside the drawer, and the ring is the controls a
|
|
7
|
+
* reader can actually see and press — not every element that happens to match
|
|
8
|
+
* a focusable selector.
|
|
9
|
+
*/
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
12
|
+
import {
|
|
13
|
+
act,
|
|
14
|
+
createElement,
|
|
15
|
+
Fragment,
|
|
16
|
+
type ReactElement,
|
|
17
|
+
type ReactNode,
|
|
18
|
+
} from "react";
|
|
19
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
20
|
+
import { Drawer } from "./Drawer";
|
|
21
|
+
|
|
22
|
+
let container: HTMLElement;
|
|
23
|
+
let root: Root;
|
|
24
|
+
let outside: HTMLButtonElement;
|
|
25
|
+
|
|
26
|
+
const press = (key: string, shiftKey = false): void => {
|
|
27
|
+
act(() => {
|
|
28
|
+
document.dispatchEvent(
|
|
29
|
+
new KeyboardEvent("keydown", { key, shiftKey, bubbles: true }),
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const named = (label: string): HTMLElement => {
|
|
35
|
+
const found = container.querySelector<HTMLElement>(`[data-name="${label}"]`);
|
|
36
|
+
if (!found) throw new Error(`expected an element named ${label}`);
|
|
37
|
+
return found;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const closeButton = (): HTMLElement => {
|
|
41
|
+
const buttons = container.querySelectorAll<HTMLElement>(
|
|
42
|
+
'.safe-area-frame [aria-label="Close menu"]',
|
|
43
|
+
);
|
|
44
|
+
const found = buttons[0];
|
|
45
|
+
if (!found) throw new Error("expected the drawer's close button");
|
|
46
|
+
return found;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const render = (isOpen: boolean, children: ReactNode): void => {
|
|
50
|
+
act(() => {
|
|
51
|
+
root.render(
|
|
52
|
+
createElement(Drawer, {
|
|
53
|
+
isOpen,
|
|
54
|
+
onClose: () => undefined,
|
|
55
|
+
ariaLabel: "Message details",
|
|
56
|
+
side: "right",
|
|
57
|
+
children,
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const open = (children: ReactNode): void => render(true, children);
|
|
64
|
+
const close = (): void => render(false, null);
|
|
65
|
+
|
|
66
|
+
const button = (
|
|
67
|
+
name: string,
|
|
68
|
+
props: Record<string, unknown> = {},
|
|
69
|
+
): ReactElement =>
|
|
70
|
+
createElement(
|
|
71
|
+
"button",
|
|
72
|
+
{ key: name, type: "button", "data-name": name, ...props },
|
|
73
|
+
name,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
container = document.getElementById("root") as unknown as HTMLElement;
|
|
78
|
+
container.innerHTML = "";
|
|
79
|
+
outside = document.createElement("button");
|
|
80
|
+
outside.type = "button";
|
|
81
|
+
outside.textContent = "Toolbar toggle";
|
|
82
|
+
document.body.appendChild(outside);
|
|
83
|
+
root = createRoot(container);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
act(() => root.unmount());
|
|
88
|
+
outside.remove();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("Drawer keeps Tab inside itself", () => {
|
|
92
|
+
it("wraps backwards from the first element to the last", () => {
|
|
93
|
+
open(createElement(Fragment, null, button("alpha"), button("omega")));
|
|
94
|
+
closeButton().focus();
|
|
95
|
+
|
|
96
|
+
press("Tab", true);
|
|
97
|
+
|
|
98
|
+
assert.equal(document.activeElement, named("omega"));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("wraps forwards from the last element to the first", () => {
|
|
102
|
+
open(createElement(Fragment, null, button("alpha"), button("omega")));
|
|
103
|
+
named("omega").focus();
|
|
104
|
+
|
|
105
|
+
press("Tab");
|
|
106
|
+
|
|
107
|
+
assert.equal(document.activeElement, closeButton());
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("pulls focus back in when it is somewhere else entirely", () => {
|
|
111
|
+
open(createElement(Fragment, null, button("alpha")));
|
|
112
|
+
outside.focus();
|
|
113
|
+
|
|
114
|
+
press("Tab");
|
|
115
|
+
|
|
116
|
+
assert.equal(document.activeElement, closeButton());
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("leaves an interior Tab to the browser", () => {
|
|
120
|
+
open(createElement(Fragment, null, button("alpha"), button("omega")));
|
|
121
|
+
named("alpha").focus();
|
|
122
|
+
|
|
123
|
+
press("Tab");
|
|
124
|
+
|
|
125
|
+
assert.equal(document.activeElement, named("alpha"));
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("Drawer's ring is what a reader can reach", () => {
|
|
130
|
+
it("skips a scrim that took itself out of the tab order", () => {
|
|
131
|
+
open(
|
|
132
|
+
createElement(
|
|
133
|
+
Fragment,
|
|
134
|
+
null,
|
|
135
|
+
button("backdrop", { tabIndex: -1 }),
|
|
136
|
+
button("omega"),
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
named("omega").focus();
|
|
140
|
+
|
|
141
|
+
press("Tab");
|
|
142
|
+
|
|
143
|
+
assert.equal(document.activeElement, closeButton());
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("skips disabled, aria-disabled and hidden controls", () => {
|
|
147
|
+
open(
|
|
148
|
+
createElement(
|
|
149
|
+
Fragment,
|
|
150
|
+
null,
|
|
151
|
+
button("omega"),
|
|
152
|
+
button("off", { disabled: true }),
|
|
153
|
+
button("stated-off", { "aria-disabled": "true" }),
|
|
154
|
+
button("gone", { style: { display: "none" } }),
|
|
155
|
+
button("inerted", { inert: true }),
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
named("omega").focus();
|
|
159
|
+
|
|
160
|
+
press("Tab");
|
|
161
|
+
|
|
162
|
+
assert.equal(document.activeElement, closeButton());
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The intelligence pane's reclassify dialog renders inside the drawer, over
|
|
167
|
+
* its panel. A ring scoped to the panel puts that dialog's Cancel last and
|
|
168
|
+
* keeps every control behind its backdrop in the cycle, so Tab walks onto
|
|
169
|
+
* buttons no pointer can reach — the very thing the trap exists to prevent.
|
|
170
|
+
*/
|
|
171
|
+
it("hands the ring to a modal opened inside it", () => {
|
|
172
|
+
open(
|
|
173
|
+
createElement(
|
|
174
|
+
Fragment,
|
|
175
|
+
null,
|
|
176
|
+
button("under-the-backdrop"),
|
|
177
|
+
createElement(
|
|
178
|
+
"div",
|
|
179
|
+
{
|
|
180
|
+
key: "nested",
|
|
181
|
+
role: "dialog",
|
|
182
|
+
"aria-modal": "true",
|
|
183
|
+
"aria-label": "Reclassify sender",
|
|
184
|
+
},
|
|
185
|
+
button("automated"),
|
|
186
|
+
button("Cancel"),
|
|
187
|
+
),
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
named("Cancel").focus();
|
|
191
|
+
|
|
192
|
+
press("Tab");
|
|
193
|
+
|
|
194
|
+
assert.equal(document.activeElement, named("automated"));
|
|
195
|
+
|
|
196
|
+
press("Tab", true);
|
|
197
|
+
|
|
198
|
+
assert.equal(document.activeElement, named("Cancel"));
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("Drawer gives focus back on the way out", () => {
|
|
203
|
+
it("returns it to whatever held it when the drawer went up", () => {
|
|
204
|
+
outside.focus();
|
|
205
|
+
open(createElement(Fragment, null, button("alpha")));
|
|
206
|
+
assert.equal(document.activeElement, closeButton());
|
|
207
|
+
|
|
208
|
+
close();
|
|
209
|
+
|
|
210
|
+
assert.equal(document.activeElement, outside);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -2,6 +2,37 @@ import { cn } from "@remit/ui";
|
|
|
2
2
|
import { X } from "lucide-react";
|
|
3
3
|
import { type ReactNode, useEffect, useRef } from "react";
|
|
4
4
|
|
|
5
|
+
const FOCUSABLE_SELECTOR =
|
|
6
|
+
"a[href], button, input, select, textarea, [tabindex]";
|
|
7
|
+
|
|
8
|
+
const isTabbable = (element: HTMLElement): boolean => {
|
|
9
|
+
if (element.tabIndex < 0) return false;
|
|
10
|
+
if (element.hasAttribute("disabled")) return false;
|
|
11
|
+
if (element.getAttribute("aria-disabled") === "true") return false;
|
|
12
|
+
if (element.closest("[inert], [hidden], [aria-hidden='true']")) return false;
|
|
13
|
+
const style = getComputedStyle(element);
|
|
14
|
+
return style.display !== "none" && style.visibility !== "hidden";
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The elements Tab may reach, in the order it reaches them.
|
|
19
|
+
*
|
|
20
|
+
* A modal opened from inside the drawer takes the ring with it: the
|
|
21
|
+
* intelligence pane's reclassify dialog renders under the drawer's own panel,
|
|
22
|
+
* so a ring scoped to the panel puts its Cancel last and leaves every control
|
|
23
|
+
* behind that dialog's backdrop in the cycle — Tab walks onto buttons no
|
|
24
|
+
* pointer can reach, which is the bug the trap exists to prevent.
|
|
25
|
+
*/
|
|
26
|
+
const tabRing = (panel: HTMLElement): HTMLElement[] => {
|
|
27
|
+
const nested = panel.querySelectorAll<HTMLElement>(
|
|
28
|
+
'[role="dialog"][aria-modal="true"]',
|
|
29
|
+
);
|
|
30
|
+
const root = nested[nested.length - 1] ?? panel;
|
|
31
|
+
return [...root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
|
|
32
|
+
isTabbable,
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
5
36
|
interface DrawerProps {
|
|
6
37
|
isOpen: boolean;
|
|
7
38
|
onClose: () => void;
|
|
@@ -40,16 +71,35 @@ export const Drawer = ({
|
|
|
40
71
|
if (event.key === "Escape") {
|
|
41
72
|
event.preventDefault();
|
|
42
73
|
onClose();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Trap Tab inside the drawer: it is `aria-modal`, so a screen reader
|
|
77
|
+
// offers nothing outside it — and letting Tab walk out would land on
|
|
78
|
+
// controls a pointer cannot reach under the scrim (#747).
|
|
79
|
+
if (event.key !== "Tab" || !drawerRef.current) return;
|
|
80
|
+
const ring = tabRing(drawerRef.current);
|
|
81
|
+
if (ring.length === 0) return;
|
|
82
|
+
const first = ring[0];
|
|
83
|
+
const last = ring[ring.length - 1];
|
|
84
|
+
const active = document.activeElement;
|
|
85
|
+
const inside = active instanceof HTMLElement && ring.includes(active);
|
|
86
|
+
if (event.shiftKey) {
|
|
87
|
+
if (!inside || active === first) {
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
last.focus();
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (!inside || active === last) {
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
first.focus();
|
|
43
96
|
}
|
|
44
97
|
};
|
|
45
98
|
|
|
46
99
|
document.addEventListener("keydown", handleKey);
|
|
47
100
|
|
|
48
101
|
// Move focus into the drawer
|
|
49
|
-
|
|
50
|
-
"button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
|
|
51
|
-
);
|
|
52
|
-
focusable?.focus();
|
|
102
|
+
if (drawerRef.current) tabRing(drawerRef.current)[0]?.focus();
|
|
53
103
|
|
|
54
104
|
// Lock body scroll
|
|
55
105
|
const previousOverflow = document.body.style.overflow;
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Avatar,
|
|
3
|
+
type IntelligenceData,
|
|
4
|
+
IntelligencePanel,
|
|
5
|
+
RefreshButton,
|
|
6
|
+
ShellTopBar,
|
|
7
|
+
} from "@remit/ui";
|
|
2
8
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
9
|
import { ChevronLeft, Info } from "lucide-react";
|
|
4
10
|
import { useState } from "react";
|
|
5
11
|
import { expect, userEvent, within } from "storybook/test";
|
|
6
12
|
import { Drawer } from "@/components/layout/Drawer";
|
|
13
|
+
import { MessageToolbar } from "@/components/mail/MessageToolbar";
|
|
7
14
|
import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
|
|
8
15
|
|
|
9
16
|
/**
|
|
@@ -29,6 +36,8 @@ type Story = StoryObj;
|
|
|
29
36
|
|
|
30
37
|
const PHONE_WIDTH = 390;
|
|
31
38
|
const PHONE_HEIGHT = 720;
|
|
39
|
+
const MID_WIDTH = 1100;
|
|
40
|
+
const MID_HEIGHT = 640;
|
|
32
41
|
|
|
33
42
|
const intelligence: IntelligenceData = {
|
|
34
43
|
sender: {
|
|
@@ -213,6 +222,119 @@ export const DismissedAndNotCarried: Story = {
|
|
|
213
222
|
},
|
|
214
223
|
};
|
|
215
224
|
|
|
225
|
+
/**
|
|
226
|
+
* The two-pane desktop band between 1024 and 1280px, where the rail has no room
|
|
227
|
+
* and the drawer is the surface. The drawer is right-anchored and 320px wide,
|
|
228
|
+
* so it lands squarely on the reading toolbar's own intelligence toggle — the
|
|
229
|
+
* control that put it there. The shell's top bar is here because the geometry
|
|
230
|
+
* is the point: it is what stands the toolbar clear of the drawer's own header,
|
|
231
|
+
* as it does in the app.
|
|
232
|
+
*/
|
|
233
|
+
const MidWidthReader = () => {
|
|
234
|
+
const drawer = useIntelligenceDrawer(messages[0]?.id ?? null);
|
|
235
|
+
const [search, setSearch] = useState("");
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<div
|
|
239
|
+
className="flex flex-col overflow-hidden rounded-lg border border-line bg-canvas"
|
|
240
|
+
// As on the phone frame: the transform makes this a containing block,
|
|
241
|
+
// so the drawer's `position: fixed` resolves against it.
|
|
242
|
+
style={{
|
|
243
|
+
width: MID_WIDTH,
|
|
244
|
+
height: MID_HEIGHT,
|
|
245
|
+
transform: "translateZ(0)",
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
248
|
+
<ShellTopBar
|
|
249
|
+
search={{
|
|
250
|
+
value: search,
|
|
251
|
+
scope: "global",
|
|
252
|
+
onChange: setSearch,
|
|
253
|
+
onClear: () => setSearch(""),
|
|
254
|
+
onClearQuery: () => setSearch(""),
|
|
255
|
+
onRemoveChip: () => undefined,
|
|
256
|
+
}}
|
|
257
|
+
onCompose={() => undefined}
|
|
258
|
+
onReportBug={() => undefined}
|
|
259
|
+
onOpenSettings={() => undefined}
|
|
260
|
+
refreshControl={
|
|
261
|
+
<RefreshButton
|
|
262
|
+
state="idle"
|
|
263
|
+
label="Refresh all accounts"
|
|
264
|
+
onRefresh={() => undefined}
|
|
265
|
+
/>
|
|
266
|
+
}
|
|
267
|
+
account={
|
|
268
|
+
<Avatar
|
|
269
|
+
name="Matthijs van Henten"
|
|
270
|
+
email="mvh@example.com"
|
|
271
|
+
size="sm"
|
|
272
|
+
/>
|
|
273
|
+
}
|
|
274
|
+
/>
|
|
275
|
+
<MessageToolbar
|
|
276
|
+
hasThread
|
|
277
|
+
intelligenceOpen={drawer.isOpen}
|
|
278
|
+
canToggleIntelligence
|
|
279
|
+
onToggleIntelligence={drawer.toggle}
|
|
280
|
+
/>
|
|
281
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4 text-sm text-fg">
|
|
282
|
+
<h1 className="text-lg font-semibold leading-snug">
|
|
283
|
+
{messages[0]?.subject}
|
|
284
|
+
</h1>
|
|
285
|
+
<p className="mt-1 text-2xs text-fg-subtle">{messages[0]?.from}</p>
|
|
286
|
+
<p className="mt-4 max-w-2xl">{messages[0]?.body}</p>
|
|
287
|
+
</div>
|
|
288
|
+
<Drawer
|
|
289
|
+
isOpen={drawer.isOpen}
|
|
290
|
+
onClose={drawer.close}
|
|
291
|
+
ariaLabel="Message details"
|
|
292
|
+
side="right"
|
|
293
|
+
>
|
|
294
|
+
<IntelligencePanel data={intelligence} hideCloseButton />
|
|
295
|
+
</Drawer>
|
|
296
|
+
</div>
|
|
297
|
+
);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The toggle the drawer covers. It is the only way into this surface for a
|
|
302
|
+
* message with no warning on it, so the drawer lands on it — and stays there:
|
|
303
|
+
* the toggle is the way in and nothing else. Getting back out is the drawer's
|
|
304
|
+
* own job, and its X sits clear of the toggle to do it (#747).
|
|
305
|
+
*/
|
|
306
|
+
export const CoveredToggle: Story = {
|
|
307
|
+
render: () => <MidWidthReader />,
|
|
308
|
+
play: async ({ canvasElement }) => {
|
|
309
|
+
const canvas = within(canvasElement);
|
|
310
|
+
|
|
311
|
+
await userEvent.click(
|
|
312
|
+
canvas.getByRole("button", { name: "Show intelligence sidebar" }),
|
|
313
|
+
);
|
|
314
|
+
const drawer = canvas.getByRole("dialog", { name: "Message details" });
|
|
315
|
+
await expect(drawer).toBeVisible();
|
|
316
|
+
|
|
317
|
+
// The toggle stays in the toolbar the drawer covers. Nothing lifts it over
|
|
318
|
+
// the surface it opened, so a press aimed at it reaches the drawer.
|
|
319
|
+
await expect(drawer).not.toContainElement(
|
|
320
|
+
canvas.getByRole("button", { name: "Hide intelligence sidebar" }),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
// The header's X, not the scrim that shares its name: the second of the
|
|
324
|
+
// two, and the one a reader can see.
|
|
325
|
+
const close = within(drawer).getAllByLabelText("Close menu")[1];
|
|
326
|
+
await expect(close).toBeVisible();
|
|
327
|
+
await userEvent.click(close);
|
|
328
|
+
|
|
329
|
+
await expect(
|
|
330
|
+
canvas.queryByRole("dialog", { name: "Message details" }),
|
|
331
|
+
).toBeNull();
|
|
332
|
+
await expect(
|
|
333
|
+
canvas.getByRole("button", { name: "Show intelligence sidebar" }),
|
|
334
|
+
).toHaveAttribute("aria-pressed", "false");
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
|
|
216
338
|
/** The drawer over the dark theme. */
|
|
217
339
|
export const OpenOverThreadDark: Story = {
|
|
218
340
|
name: "Open Over Thread (dark)",
|