@remit/web-client 0.0.210 → 0.0.212

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.210",
3
+ "version": "0.0.212",
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,221 @@
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("leaves focus outside the drawer alone", () => {
111
+ open(createElement(Fragment, null, button("alpha")));
112
+ outside.focus();
113
+
114
+ press("Tab");
115
+
116
+ assert.equal(document.activeElement, outside);
117
+ });
118
+
119
+ it("leaves focus outside the drawer alone on shift+Tab too", () => {
120
+ open(createElement(Fragment, null, button("alpha")));
121
+ outside.focus();
122
+
123
+ press("Tab", true);
124
+
125
+ assert.equal(document.activeElement, outside);
126
+ });
127
+
128
+ it("leaves an interior Tab to the browser", () => {
129
+ open(createElement(Fragment, null, button("alpha"), button("omega")));
130
+ named("alpha").focus();
131
+
132
+ press("Tab");
133
+
134
+ assert.equal(document.activeElement, named("alpha"));
135
+ });
136
+ });
137
+
138
+ describe("Drawer's ring is what a reader can reach", () => {
139
+ it("skips a scrim that took itself out of the tab order", () => {
140
+ open(
141
+ createElement(
142
+ Fragment,
143
+ null,
144
+ button("backdrop", { tabIndex: -1 }),
145
+ button("omega"),
146
+ ),
147
+ );
148
+ named("omega").focus();
149
+
150
+ press("Tab");
151
+
152
+ assert.equal(document.activeElement, closeButton());
153
+ });
154
+
155
+ it("skips disabled, aria-disabled and hidden controls", () => {
156
+ open(
157
+ createElement(
158
+ Fragment,
159
+ null,
160
+ button("omega"),
161
+ button("off", { disabled: true }),
162
+ button("stated-off", { "aria-disabled": "true" }),
163
+ button("gone", { style: { display: "none" } }),
164
+ button("inerted", { inert: true }),
165
+ ),
166
+ );
167
+ named("omega").focus();
168
+
169
+ press("Tab");
170
+
171
+ assert.equal(document.activeElement, closeButton());
172
+ });
173
+
174
+ /**
175
+ * The intelligence pane's reclassify dialog renders inside the drawer, over
176
+ * its panel. A ring scoped to the panel puts that dialog's Cancel last and
177
+ * keeps every control behind its backdrop in the cycle, so Tab walks onto
178
+ * buttons no pointer can reach — the very thing the trap exists to prevent.
179
+ */
180
+ it("hands the ring to a modal opened inside it", () => {
181
+ open(
182
+ createElement(
183
+ Fragment,
184
+ null,
185
+ button("under-the-backdrop"),
186
+ createElement(
187
+ "div",
188
+ {
189
+ key: "nested",
190
+ role: "dialog",
191
+ "aria-modal": "true",
192
+ "aria-label": "Reclassify sender",
193
+ },
194
+ button("automated"),
195
+ button("Cancel"),
196
+ ),
197
+ ),
198
+ );
199
+ named("Cancel").focus();
200
+
201
+ press("Tab");
202
+
203
+ assert.equal(document.activeElement, named("automated"));
204
+
205
+ press("Tab", true);
206
+
207
+ assert.equal(document.activeElement, named("Cancel"));
208
+ });
209
+ });
210
+
211
+ describe("Drawer gives focus back on the way out", () => {
212
+ it("returns it to whatever held it when the drawer went up", () => {
213
+ outside.focus();
214
+ open(createElement(Fragment, null, button("alpha")));
215
+ assert.equal(document.activeElement, closeButton());
216
+
217
+ close();
218
+
219
+ assert.equal(document.activeElement, outside);
220
+ });
221
+ });
@@ -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,36 @@ export const Drawer = ({
40
71
  if (event.key === "Escape") {
41
72
  event.preventDefault();
42
73
  onClose();
74
+ return;
75
+ }
76
+ // Wrap Tab at the ends of the drawer's ring: walking off the last
77
+ // control cycles back to the first instead of landing on controls a
78
+ // pointer cannot reach under the scrim (#747). Focus that is already
79
+ // outside the drawer — an error banner, the fatal-error overlay — is
80
+ // left alone; the drawer does not pull it back in.
81
+ if (event.key !== "Tab" || !drawerRef.current) return;
82
+ const ring = tabRing(drawerRef.current);
83
+ if (ring.length === 0) return;
84
+ const first = ring[0];
85
+ const last = ring[ring.length - 1];
86
+ const active = document.activeElement;
87
+ if (event.shiftKey) {
88
+ if (active === first) {
89
+ event.preventDefault();
90
+ last.focus();
91
+ }
92
+ return;
93
+ }
94
+ if (active === last) {
95
+ event.preventDefault();
96
+ first.focus();
43
97
  }
44
98
  };
45
99
 
46
100
  document.addEventListener("keydown", handleKey);
47
101
 
48
102
  // Move focus into the drawer
49
- const focusable = drawerRef.current?.querySelector<HTMLElement>(
50
- "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
51
- );
52
- focusable?.focus();
103
+ if (drawerRef.current) tabRing(drawerRef.current)[0]?.focus();
53
104
 
54
105
  // Lock body scroll
55
106
  const previousOverflow = document.body.style.overflow;
@@ -1,9 +1,16 @@
1
- import { type IntelligenceData, IntelligencePanel } from "@remit/ui";
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)",