@remit/ui 0.0.26 → 0.0.28

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.26",
3
+ "version": "0.0.28",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -155,6 +155,7 @@ describe("AppShell nav: pane vs slide-over by width (#784)", () => {
155
155
  it("≥1024px: nav is a persistent pane, no trigger", () => {
156
156
  const html = render({ flatList: true, initialWidth: 1100 });
157
157
  assert.match(html, navMarker, "nav renders as a pane");
158
+ assert.match(html, /Settings/, "the desktop nav pins a Settings footer");
158
159
  assert.doesNotMatch(
159
160
  html,
160
161
  navTriggerMarker,
@@ -57,7 +57,7 @@ export function BriefSection({
57
57
  type="button"
58
58
  aria-expanded={!collapsed}
59
59
  onClick={() => setCollapsed((v) => !v)}
60
- className="sticky top-0 z-10 flex h-section-row w-full items-center gap-1.5 border-b border-line bg-surface-sunken px-row-inset text-left transition-colors hover:bg-surface"
60
+ className="sticky top-0 z-10 flex h-section-row w-full items-center gap-1.5 border-b border-line bg-surface-sunken px-row-inset text-left outline-none transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
61
61
  >
62
62
  <span className="flex-1 text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
63
63
  {section.label}
@@ -89,7 +89,7 @@ export function BriefSection({
89
89
  <button
90
90
  type="button"
91
91
  onClick={() => setExpanded((v) => !v)}
92
- className="flex w-full items-center justify-center border-b border-line px-row-inset py-1.5 text-2xs font-medium text-accent transition-colors hover:bg-surface"
92
+ className="flex w-full items-center justify-center border-b border-line px-row-inset py-1.5 text-2xs font-medium text-accent outline-none transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
93
93
  >
94
94
  {expanded ? "Show less" : `Show ${hiddenCount} more`}
95
95
  {!expanded && <ChevronDown className="ml-1 size-3" />}
@@ -0,0 +1,175 @@
1
+ /**
2
+ * BriefSections arrow-key traversal (#143) — mounted against jsdom rather than
3
+ * `renderToString`, since focus and keydown need a real `document`.
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
7
+ import type { JSDOM } from "jsdom";
8
+ import { act, createElement } from "react";
9
+ import { createRoot, type Root } from "react-dom/client";
10
+ import { LIST_ROW_SELECTOR } from "../lib/roving-focus.js";
11
+ import type { ThreadSection } from "./app-shell-types.js";
12
+ import { BriefSections } from "./brief-sections.js";
13
+ import { ComfortableRow } from "./message-row.js";
14
+
15
+ const sections: ThreadSection[] = [
16
+ {
17
+ id: "personal",
18
+ label: "Personal",
19
+ threads: [
20
+ {
21
+ id: "t1",
22
+ accountId: "a1",
23
+ fromName: "Priya Nair",
24
+ fromEmail: "priya@example.com",
25
+ subject: "Design review tomorrow",
26
+ snippet: "Can we move it to 2pm?",
27
+ timeLabel: "8:15",
28
+ category: "personal",
29
+ },
30
+ {
31
+ id: "t2",
32
+ accountId: "a1",
33
+ fromName: "Alex Rivera",
34
+ fromEmail: "alex@example.com",
35
+ subject: "Q3 planning notes",
36
+ snippet: "Notes from today.",
37
+ timeLabel: "9:42",
38
+ category: "personal",
39
+ },
40
+ ],
41
+ },
42
+ {
43
+ id: "newsletter",
44
+ label: "Newsletter",
45
+ threads: [
46
+ {
47
+ id: "t3",
48
+ accountId: "a1",
49
+ fromName: "The Weekly Brief",
50
+ fromEmail: "hello@weekly.example",
51
+ subject: "This week in product",
52
+ snippet: "Five stories you missed.",
53
+ timeLabel: "Thu",
54
+ category: "newsletter",
55
+ },
56
+ ],
57
+ },
58
+ ];
59
+
60
+ let dom: JSDOM;
61
+ let container: HTMLElement;
62
+ let root: Root;
63
+
64
+ before(async () => {
65
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
66
+ dom = new JSDOMCtor(
67
+ "<!doctype html><html><body><div id=root></div></body></html>",
68
+ { url: "http://localhost/", pretendToBeVisual: true },
69
+ );
70
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
71
+ globalThis.document = dom.window.document;
72
+ globalThis.HTMLElement = dom.window.HTMLElement;
73
+ globalThis.Element = dom.window.Element;
74
+ globalThis.KeyboardEvent = dom.window.KeyboardEvent;
75
+ Object.defineProperty(globalThis, "navigator", {
76
+ value: dom.window.navigator,
77
+ configurable: true,
78
+ });
79
+ (
80
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
81
+ ).IS_REACT_ACT_ENVIRONMENT = true;
82
+ });
83
+
84
+ after(() => {
85
+ dom.window.close();
86
+ });
87
+
88
+ beforeEach(() => {
89
+ container = dom.window.document.getElementById(
90
+ "root",
91
+ ) as unknown as HTMLElement;
92
+ container.innerHTML = "";
93
+ root = createRoot(container);
94
+ });
95
+
96
+ afterEach(() => {
97
+ act(() => {
98
+ root.unmount();
99
+ });
100
+ });
101
+
102
+ function pressKey(target: Element, key: string) {
103
+ target.dispatchEvent(
104
+ new dom.window.KeyboardEvent("keydown", { key, bubbles: true }),
105
+ );
106
+ }
107
+
108
+ function rows(): HTMLElement[] {
109
+ return Array.from(container.querySelectorAll(LIST_ROW_SELECTOR));
110
+ }
111
+
112
+ function mount(onSelectThread: (id: string) => void = () => undefined) {
113
+ act(() => {
114
+ root.render(
115
+ createElement(BriefSections, {
116
+ sections,
117
+ Row: ComfortableRow,
118
+ onSelectThread,
119
+ onSelectBriefCategory: () => undefined,
120
+ }),
121
+ );
122
+ });
123
+ }
124
+
125
+ describe("BriefSections arrow-key traversal", () => {
126
+ it("puts only the first row in the tab order before anything is focused", () => {
127
+ mount();
128
+ const items = rows();
129
+ assert.equal(items.length, 3);
130
+ assert.equal(items[0]?.tabIndex, 0);
131
+ assert.equal(items[1]?.tabIndex, -1);
132
+ assert.equal(items[2]?.tabIndex, -1);
133
+ });
134
+
135
+ it("ArrowDown crosses a section boundary and Enter opens the row", () => {
136
+ let selected: string | undefined;
137
+ mount((id) => {
138
+ selected = id;
139
+ });
140
+ const items = rows();
141
+
142
+ act(() => items[0]?.focus());
143
+ act(() => pressKey(items[0] as Element, "ArrowDown"));
144
+ act(() => pressKey(items[1] as Element, "ArrowDown"));
145
+ assert.equal(dom.window.document.activeElement, items[2]);
146
+
147
+ act(() => (items[2] as HTMLElement).click());
148
+ assert.equal(selected, "t3");
149
+ });
150
+
151
+ it("ArrowUp walks back and Home returns to the first row", () => {
152
+ mount();
153
+ const items = rows();
154
+
155
+ act(() => items[2]?.focus());
156
+ act(() => pressKey(items[2] as Element, "ArrowUp"));
157
+ assert.equal(dom.window.document.activeElement, items[1]);
158
+
159
+ act(() => pressKey(items[1] as Element, "Home"));
160
+ assert.equal(dom.window.document.activeElement, items[0]);
161
+ });
162
+
163
+ it("steps over the section headers between rows", () => {
164
+ mount();
165
+ const headers = Array.from(
166
+ container.querySelectorAll<HTMLElement>("button[aria-expanded]"),
167
+ );
168
+ assert.ok(headers.length > 0);
169
+ const items = rows();
170
+
171
+ act(() => items[1]?.focus());
172
+ act(() => pressKey(items[1] as Element, "ArrowDown"));
173
+ assert.equal(dom.window.document.activeElement, items[2]);
174
+ });
175
+ });
@@ -1,4 +1,5 @@
1
- import { useState } from "react";
1
+ import { useRef, useState } from "react";
2
+ import { LIST_ROW_SELECTOR, useRovingFocus } from "../lib/roving-focus.js";
2
3
  import type {
3
4
  BriefCategoryFilter,
4
5
  ThreadRowData,
@@ -98,6 +99,8 @@ export function BriefSections({
98
99
  }: BriefSectionsProps) {
99
100
  const [active, setActive] = useState<ReadonlySet<BriefFilterId>>(new Set());
100
101
  const [sheetExpanded, setSheetExpanded] = useState(defaultExpanded);
102
+ const listRef = useRef<HTMLDivElement>(null);
103
+ useRovingFocus({ containerRef: listRef, itemSelector: LIST_ROW_SELECTOR });
101
104
 
102
105
  const toggleFilter = (id: BriefFilterId) => {
103
106
  setActive((prev) => {
@@ -142,7 +145,7 @@ export function BriefSections({
142
145
  const empty = showSections ? filtered.length === 0 : flatRows.length === 0;
143
146
 
144
147
  const listBody = (
145
- <>
148
+ <div ref={listRef}>
146
149
  {showSections ? (
147
150
  filtered.map((section) => (
148
151
  <BriefSection
@@ -170,7 +173,7 @@ export function BriefSections({
170
173
  No threads match these filters.
171
174
  </div>
172
175
  )}
173
- </>
176
+ </div>
174
177
  );
175
178
 
176
179
  // One source of truth for both breakpoints: a click-to-expand Filters bar
@@ -1,6 +1,7 @@
1
1
  import { Menu } from "lucide-react";
2
2
  import type { ReactNode } from "react";
3
- import { useState } from "react";
3
+ import { useRef, useState } from "react";
4
+ import { LIST_ROW_SELECTOR, useRovingFocus } from "../lib/roving-focus.js";
4
5
  import type { AppShellProps, TouchSeed } from "./app-shell-types.js";
5
6
  import { BriefSections } from "./brief-sections.js";
6
7
  import { Button } from "./button.js";
@@ -89,6 +90,11 @@ export function MessageListPane({
89
90
  hideHeader?: boolean;
90
91
  }) {
91
92
  const Row = density === "compact" ? CompactRow : ComfortableRow;
93
+ const flatListRef = useRef<HTMLDivElement>(null);
94
+ useRovingFocus({
95
+ containerRef: flatListRef,
96
+ itemSelector: LIST_ROW_SELECTOR,
97
+ });
92
98
 
93
99
  const touchTriage = !isDesktop && !briefFilters && listState === "ready";
94
100
  const seededRows = sections.flatMap((section) => section.threads);
@@ -212,7 +218,7 @@ export function MessageListPane({
212
218
  refreshing={refreshing}
213
219
  />
214
220
  ) : (
215
- <div className="flex-1 overflow-y-auto">
221
+ <div ref={flatListRef} className="flex-1 overflow-y-auto">
216
222
  {sections.map((section) => (
217
223
  <div key={section.id}>
218
224
  {/* The plain flat mailbox suppresses section labels — it is one
@@ -1,10 +1,15 @@
1
1
  import { Paperclip, ShieldAlert, Star } from "lucide-react";
2
2
  import type { ReactNode } from "react";
3
3
  import { cn } from "../lib/cn.js";
4
+ import { LIST_ROW_ATTRIBUTE } from "../lib/roving-focus.js";
4
5
  import { categoryTone, type ThreadRowData } from "./app-shell-types.js";
5
6
  import { Avatar } from "./avatar.js";
6
7
  import { Badge } from "./badge.js";
7
8
 
9
+ /** Visible keyboard-focus ring for a row reached by the list's arrow-key cursor. */
10
+ const ROW_FOCUS_RING =
11
+ "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset";
12
+
8
13
  /**
9
14
  * Returns the CSS classes for a compact row outer element.
10
15
  * `active` = open/selected; `focused` = keyboard-focused (left accent rail).
@@ -18,6 +23,7 @@ export const compactRowClass = ({
18
23
  }) =>
19
24
  cn(
20
25
  "relative flex h-8 w-full items-center gap-2 px-row-inset text-left",
26
+ ROW_FOCUS_RING,
21
27
  active
22
28
  ? "bg-accent-2-soft"
23
29
  : focused
@@ -42,6 +48,7 @@ export const comfortableRowClass = ({
42
48
  cn(
43
49
  // full-bleed highlight; content inset with a clear unread-dot gutter
44
50
  "relative flex w-full items-start gap-3 py-2 pl-5 pr-row-inset text-left transition-colors",
51
+ ROW_FOCUS_RING,
45
52
  active
46
53
  ? "bg-accent-2-soft"
47
54
  : focused
@@ -194,6 +201,7 @@ export function CompactRow({
194
201
  return (
195
202
  <button
196
203
  type="button"
204
+ {...LIST_ROW_ATTRIBUTE}
197
205
  onClick={onClick}
198
206
  className={compactRowClass({ active })}
199
207
  >
@@ -214,6 +222,7 @@ export function ComfortableRow({
214
222
  return (
215
223
  <button
216
224
  type="button"
225
+ {...LIST_ROW_ATTRIBUTE}
217
226
  onClick={onClick}
218
227
  className={comfortableRowClass({ active })}
219
228
  >
@@ -0,0 +1,143 @@
1
+ /**
2
+ * NavSidebar arrow-key traversal (#143) — mounted against jsdom rather than
3
+ * `renderToString`, since focus and keydown need a real `document`.
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
7
+ import type { JSDOM } from "jsdom";
8
+ import { act, createElement } from "react";
9
+ import { createRoot, type Root } from "react-dom/client";
10
+ import type { NavAccount } from "./app-shell-types.js";
11
+ import { NavSidebar } from "./nav-sidebar.js";
12
+
13
+ const accounts: NavAccount[] = [
14
+ {
15
+ id: "acct-personal",
16
+ label: "Personal",
17
+ email: "person@example.com",
18
+ mailboxes: [
19
+ { id: "personal-inbox", name: "Inbox", role: "inbox" },
20
+ { id: "personal-sent", name: "Sent", role: "sent" },
21
+ ],
22
+ },
23
+ ];
24
+
25
+ let dom: JSDOM;
26
+ let container: HTMLElement;
27
+ let root: Root;
28
+
29
+ before(async () => {
30
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
31
+ dom = new JSDOMCtor(
32
+ "<!doctype html><html><body><div id=root></div></body></html>",
33
+ { url: "http://localhost/", pretendToBeVisual: true },
34
+ );
35
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
36
+ globalThis.document = dom.window.document;
37
+ globalThis.HTMLElement = dom.window.HTMLElement;
38
+ globalThis.Element = dom.window.Element;
39
+ globalThis.KeyboardEvent = dom.window.KeyboardEvent;
40
+ Object.defineProperty(globalThis, "navigator", {
41
+ value: dom.window.navigator,
42
+ configurable: true,
43
+ });
44
+ (
45
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
46
+ ).IS_REACT_ACT_ENVIRONMENT = true;
47
+ });
48
+
49
+ after(() => {
50
+ dom.window.close();
51
+ });
52
+
53
+ beforeEach(() => {
54
+ container = dom.window.document.getElementById(
55
+ "root",
56
+ ) as unknown as HTMLElement;
57
+ container.innerHTML = "";
58
+ root = createRoot(container);
59
+ });
60
+
61
+ afterEach(() => {
62
+ act(() => {
63
+ root.unmount();
64
+ });
65
+ });
66
+
67
+ function pressKey(target: Element, key: string) {
68
+ target.dispatchEvent(
69
+ new dom.window.KeyboardEvent("keydown", { key, bubbles: true }),
70
+ );
71
+ }
72
+
73
+ function navItems(): HTMLElement[] {
74
+ return Array.from(
75
+ container.querySelectorAll("button:not([disabled]), a[href]"),
76
+ );
77
+ }
78
+
79
+ function mount(variant?: "desktop" | "drawer") {
80
+ act(() => {
81
+ root.render(
82
+ createElement(NavSidebar, {
83
+ accounts,
84
+ selectedNavId: "personal-inbox",
85
+ onSelectNav: () => undefined,
86
+ variant,
87
+ }),
88
+ );
89
+ });
90
+ }
91
+
92
+ describe("NavSidebar arrow-key traversal", () => {
93
+ it("puts only the first entry in the tab order before anything is focused", () => {
94
+ mount();
95
+ const items = navItems();
96
+ assert.ok(items.length > 3);
97
+ assert.equal(items[0]?.tabIndex, 0);
98
+ assert.ok(items.slice(1).every((el) => el.tabIndex === -1));
99
+ });
100
+
101
+ it("ArrowDown walks forward, ArrowUp walks back", () => {
102
+ mount();
103
+ const items = navItems();
104
+ act(() => items[0]?.focus());
105
+ act(() => pressKey(items[0] as Element, "ArrowDown"));
106
+ assert.equal(dom.window.document.activeElement, items[1]);
107
+
108
+ act(() => pressKey(items[1] as Element, "ArrowDown"));
109
+ assert.equal(dom.window.document.activeElement, items[2]);
110
+
111
+ act(() => pressKey(items[2] as Element, "ArrowUp"));
112
+ assert.equal(dom.window.document.activeElement, items[1]);
113
+ });
114
+
115
+ it("End reaches the Settings footer and Home returns to the top", () => {
116
+ mount();
117
+ const items = navItems();
118
+ act(() => items[0]?.focus());
119
+ act(() => pressKey(items[0] as Element, "End"));
120
+ const last = items[items.length - 1];
121
+ assert.equal(dom.window.document.activeElement, last);
122
+ assert.match(last?.textContent ?? "", /Settings/);
123
+
124
+ act(() => pressKey(last as Element, "Home"));
125
+ assert.equal(dom.window.document.activeElement, items[0]);
126
+ });
127
+
128
+ it("traverses the drawer variant too", () => {
129
+ mount("drawer");
130
+ const items = navItems();
131
+ act(() => items[0]?.focus());
132
+ act(() => pressKey(items[0] as Element, "ArrowDown"));
133
+ assert.equal(dom.window.document.activeElement, items[1]);
134
+ });
135
+
136
+ it("leaves Left/Right to the browser", () => {
137
+ mount();
138
+ const items = navItems();
139
+ act(() => items[0]?.focus());
140
+ act(() => pressKey(items[0] as Element, "ArrowRight"));
141
+ assert.equal(dom.window.document.activeElement, items[0]);
142
+ });
143
+ });
@@ -107,6 +107,43 @@ describe("NavSidebar", () => {
107
107
  assert.match(html, /text-accent-2/);
108
108
  });
109
109
 
110
+ it("pins a Settings footer on the desktop variant", () => {
111
+ const html = renderToString(
112
+ createElement(NavSidebar, {
113
+ accounts,
114
+ selectedNavId: "personal-inbox",
115
+ onSelectNav: () => undefined,
116
+ }),
117
+ );
118
+ assert.match(html, /Settings/);
119
+ assert.match(html, /lucide-settings/);
120
+ });
121
+
122
+ it("renders the Settings footer as an anchor via linkComponent", () => {
123
+ const html = renderToString(
124
+ createElement(NavSidebar, {
125
+ accounts,
126
+ selectedNavId: "personal-inbox",
127
+ onSelectNav: () => undefined,
128
+ linkComponent: hrefLink,
129
+ }),
130
+ );
131
+ assert.match(html, /<a href="\/mail\/settings"/);
132
+ });
133
+
134
+ it("omits the Settings footer on the drawer variant", () => {
135
+ const html = renderToString(
136
+ createElement(NavSidebar, {
137
+ accounts,
138
+ selectedNavId: "personal-inbox",
139
+ onSelectNav: () => undefined,
140
+ variant: "drawer",
141
+ }),
142
+ );
143
+ assert.doesNotMatch(html, /Settings/);
144
+ assert.doesNotMatch(html, /lucide-settings/);
145
+ });
146
+
110
147
  it("renders navigation entries as anchors via linkComponent", () => {
111
148
  const html = renderToString(
112
149
  createElement(NavSidebar, {
@@ -77,6 +77,8 @@ export const Brief: Story = { args: { selectedNavId: "brief" } };
77
77
 
78
78
  export const Flagged: Story = { args: { selectedNavId: "flagged" } };
79
79
 
80
+ export const Settings: Story = { args: { selectedNavId: "settings" } };
81
+
80
82
  export const ManyFolders: Story = {
81
83
  args: {
82
84
  accounts: [manyFoldersAccount],
@@ -12,14 +12,16 @@ import {
12
12
  Mails,
13
13
  Search,
14
14
  Send,
15
+ Settings,
15
16
  Sparkles,
16
17
  Star,
17
18
  Trash2,
18
19
  X,
19
20
  } from "lucide-react";
20
21
  import type { ReactNode } from "react";
21
- import { useState } from "react";
22
+ import { useRef, useState } from "react";
22
23
  import { cn } from "../lib/cn.js";
24
+ import { useRovingFocus } from "../lib/roving-focus.js";
23
25
  import type {
24
26
  AppShellProps,
25
27
  NavAccount,
@@ -32,6 +34,13 @@ import type {
32
34
  /* Pane 1: navigation sidebar */
33
35
  /* ------------------------------------------------------------------ */
34
36
 
37
+ /** Every interactive row and toggle in the sidebar's arrow-key group. */
38
+ const NAV_ITEM_SELECTOR = "button:not([disabled]), a[href]";
39
+
40
+ /** Visible keyboard-focus ring shared by every item in that group. */
41
+ const FOCUS_RING =
42
+ "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-surface-sunken";
43
+
35
44
  function navItemClassName({
36
45
  active,
37
46
  dimmed,
@@ -43,6 +52,7 @@ function navItemClassName({
43
52
  }): string {
44
53
  return cn(
45
54
  "flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors",
55
+ FOCUS_RING,
46
56
  indent && "pl-7",
47
57
  active
48
58
  ? "bg-accent-2-soft font-medium text-accent-2"
@@ -240,7 +250,10 @@ function SavedSearchRow({
240
250
  <button
241
251
  type="button"
242
252
  onClick={onSelect}
243
- className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1 text-left text-sm text-fg-muted transition-colors hover:text-fg"
253
+ className={cn(
254
+ "flex min-w-0 flex-1 items-center gap-2 px-2 py-1 text-left text-sm text-fg-muted transition-colors hover:text-fg",
255
+ FOCUS_RING,
256
+ )}
244
257
  >
245
258
  <Search className="size-4 shrink-0 text-fg-subtle" />
246
259
  <span className="min-w-0 flex-1 truncate">{query}</span>
@@ -250,7 +263,10 @@ function SavedSearchRow({
250
263
  type="button"
251
264
  onClick={onRemove}
252
265
  aria-label={`Remove saved search: ${query}`}
253
- className="shrink-0 rounded-full p-1 text-fg-subtle opacity-0 transition-opacity hover:bg-surface-sunken hover:text-fg group-hover:opacity-100 focus-visible:opacity-100"
266
+ className={cn(
267
+ "shrink-0 rounded-full p-1 text-fg-subtle opacity-0 transition-opacity hover:bg-surface-sunken hover:text-fg group-hover:opacity-100 focus-visible:opacity-100",
268
+ FOCUS_RING,
269
+ )}
254
270
  >
255
271
  <X className="size-3.5" />
256
272
  </button>
@@ -288,7 +304,10 @@ function SavedSearchesGroup({
288
304
  <button
289
305
  type="button"
290
306
  onClick={onSaveCurrentSearch}
291
- className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm text-accent transition-colors hover:bg-surface"
307
+ className={cn(
308
+ "flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm text-accent transition-colors hover:bg-surface",
309
+ FOCUS_RING,
310
+ )}
292
311
  >
293
312
  <BookmarkPlus className="size-4 shrink-0" />
294
313
  <span className="min-w-0 flex-1 truncate">
@@ -367,6 +386,7 @@ function AccountNav({
367
386
  aria-expanded={accountOpen}
368
387
  className={cn(
369
388
  "flex w-full items-center gap-1.5 px-2 pb-1 text-left transition-colors hover:text-fg",
389
+ FOCUS_RING,
370
390
  account.muted && "opacity-55",
371
391
  )}
372
392
  >
@@ -464,7 +484,10 @@ function AccountNav({
464
484
  type="button"
465
485
  onClick={toggleFolders}
466
486
  aria-expanded={foldersOpen}
467
- className="mt-1 flex w-full items-center gap-1 px-2 py-1 text-left text-2xs font-semibold uppercase tracking-wider text-fg-subtle transition-colors hover:text-fg"
487
+ className={cn(
488
+ "mt-1 flex w-full items-center gap-1 px-2 py-1 text-left text-2xs font-semibold uppercase tracking-wider text-fg-subtle transition-colors hover:text-fg",
489
+ FOCUS_RING,
490
+ )}
468
491
  >
469
492
  {foldersOpen ? (
470
493
  <ChevronDown className="size-3 shrink-0" />
@@ -498,7 +521,10 @@ function AccountNav({
498
521
  <button
499
522
  type="button"
500
523
  onClick={() => setShowAllFolders((all) => !all)}
501
- className="ml-7 flex items-center px-2 py-1 text-2xs font-medium text-accent transition-colors hover:underline"
524
+ className={cn(
525
+ "ml-7 flex items-center px-2 py-1 text-2xs font-medium text-accent transition-colors hover:underline",
526
+ FOCUS_RING,
527
+ )}
502
528
  >
503
529
  {showAllFolders
504
530
  ? "Show less"
@@ -558,11 +584,14 @@ export function NavSidebar({
558
584
  onRemoveSavedSearch,
559
585
  onSaveCurrentSearch,
560
586
  }: NavSidebarProps) {
587
+ const containerRef = useRef<HTMLElement>(null);
588
+ useRovingFocus({ containerRef, itemSelector: NAV_ITEM_SELECTOR });
589
+ const isDrawer = variant === "drawer";
590
+
561
591
  const navBody = (
562
592
  <nav
563
- className={
564
- variant === "drawer" ? "px-2 py-2" : "flex-1 overflow-y-auto px-2 py-2"
565
- }
593
+ ref={isDrawer ? containerRef : undefined}
594
+ className={isDrawer ? "px-2 py-2" : "flex-1 overflow-y-auto px-2 py-2"}
566
595
  aria-label="Mailboxes"
567
596
  >
568
597
  <NavItem
@@ -612,14 +641,28 @@ export function NavSidebar({
612
641
  </nav>
613
642
  );
614
643
 
615
- if (variant === "drawer") return navBody;
644
+ if (isDrawer) return navBody;
616
645
 
617
646
  return (
618
- <aside className="flex h-full w-full flex-col bg-surface-sunken">
647
+ <aside
648
+ ref={containerRef}
649
+ className="flex h-full w-full flex-col bg-surface-sunken"
650
+ >
619
651
  {/* no toolbar over the sidebar (Apple Mail-style): nav content
620
652
  starts at the top; the datum bar exists only over the
621
653
  list/reading/intelligence panes */}
622
654
  {navBody}
655
+ <div className="border-t border-line px-2 py-2">
656
+ <NavItem
657
+ navId="settings"
658
+ linkComponent={linkComponent}
659
+ icon={<Settings className="size-4" />}
660
+ label="Settings"
661
+ ariaLabel="Settings"
662
+ active={selectedNavId === "settings"}
663
+ onClick={() => onSelectNav?.("settings")}
664
+ />
665
+ </div>
623
666
  </aside>
624
667
  );
625
668
  }
@@ -1,5 +1,6 @@
1
1
  import { RotateCcw, Send, Trash2 } from "lucide-react";
2
2
  import { cn } from "../lib/cn.js";
3
+ import { LIST_ROW_ATTRIBUTE } from "../lib/roving-focus.js";
3
4
  import type { OutboxStatus } from "./outbox-status-badge.js";
4
5
  import { OutboxStatusBadge } from "./outbox-status-badge.js";
5
6
  import { RowActions } from "./row-actions.js";
@@ -59,6 +60,7 @@ export function OutboxRow({
59
60
  >
60
61
  <button
61
62
  type="button"
63
+ {...LIST_ROW_ATTRIBUTE}
62
64
  onClick={onSelect}
63
65
  className="flex flex-1 min-w-0 items-start gap-3 text-left"
64
66
  >
package/src/index.ts CHANGED
@@ -483,6 +483,14 @@ export {
483
483
  sanitizeInlineStyle,
484
484
  sanitizeStyleElementCss,
485
485
  } from "./lib/email-sanitizer.js";
486
+ export {
487
+ LIST_ROW_ATTRIBUTE,
488
+ LIST_ROW_SELECTOR,
489
+ type RovingOrientation,
490
+ rovingNextIndex,
491
+ type UseRovingFocusOptions,
492
+ useRovingFocus,
493
+ } from "./lib/roving-focus.js";
486
494
  export {
487
495
  type UseLongPressOptions,
488
496
  type UseLongPressResult,
@@ -0,0 +1,240 @@
1
+ /**
2
+ * roving-focus — `rovingNextIndex` is pure and exercised directly; the hook is
3
+ * exercised against jsdom-mounted elements, following `use-long-press.test.ts`:
4
+ * real KeyboardEvents at a real focused node, asserting on the DOM side effects
5
+ * the hook owns (focus, tabIndex), which `renderToString` cannot reach.
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 RefObject, useRef } from "react";
11
+ import { createRoot, type Root } from "react-dom/client";
12
+ import {
13
+ LIST_ROW_ATTRIBUTE,
14
+ LIST_ROW_SELECTOR,
15
+ rovingNextIndex,
16
+ useRovingFocus,
17
+ } from "./roving-focus.js";
18
+
19
+ describe("rovingNextIndex", () => {
20
+ it("moves forward and clamps at the last item", () => {
21
+ assert.equal(rovingNextIndex("ArrowDown", -1, 3), 0);
22
+ assert.equal(rovingNextIndex("ArrowDown", 0, 3), 1);
23
+ assert.equal(rovingNextIndex("ArrowDown", 2, 3), 2);
24
+ });
25
+
26
+ it("moves backward and clamps at the first item", () => {
27
+ assert.equal(rovingNextIndex("ArrowUp", -1, 3), 0);
28
+ assert.equal(rovingNextIndex("ArrowUp", 2, 3), 1);
29
+ assert.equal(rovingNextIndex("ArrowUp", 0, 3), 0);
30
+ });
31
+
32
+ it("Home and End jump to the first/last item", () => {
33
+ assert.equal(rovingNextIndex("Home", 2, 5), 0);
34
+ assert.equal(rovingNextIndex("End", 0, 5), 4);
35
+ });
36
+
37
+ it("ignores unrelated keys", () => {
38
+ assert.equal(rovingNextIndex("Tab", 0, 3), null);
39
+ assert.equal(rovingNextIndex("Enter", 0, 3), null);
40
+ });
41
+
42
+ it("returns null for an empty group regardless of key", () => {
43
+ assert.equal(rovingNextIndex("ArrowDown", 0, 0), null);
44
+ assert.equal(rovingNextIndex("Home", -1, 0), null);
45
+ });
46
+
47
+ it("uses Left/Right instead of Up/Down in a horizontal group", () => {
48
+ assert.equal(rovingNextIndex("ArrowRight", 0, 3, "horizontal"), 1);
49
+ assert.equal(rovingNextIndex("ArrowLeft", 1, 3, "horizontal"), 0);
50
+ assert.equal(rovingNextIndex("ArrowDown", 0, 3, "horizontal"), null);
51
+ });
52
+ });
53
+
54
+ let dom: JSDOM;
55
+ let container: HTMLElement;
56
+ let root: Root;
57
+
58
+ before(async () => {
59
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
60
+ dom = new JSDOMCtor(
61
+ "<!doctype html><html><body><div id=root></div></body></html>",
62
+ { url: "http://localhost/", pretendToBeVisual: true },
63
+ );
64
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
65
+ globalThis.document = dom.window.document;
66
+ globalThis.HTMLElement = dom.window.HTMLElement;
67
+ globalThis.Element = dom.window.Element;
68
+ globalThis.KeyboardEvent = dom.window.KeyboardEvent;
69
+ Object.defineProperty(globalThis, "navigator", {
70
+ value: dom.window.navigator,
71
+ configurable: true,
72
+ });
73
+ (
74
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
75
+ ).IS_REACT_ACT_ENVIRONMENT = true;
76
+ });
77
+
78
+ after(() => {
79
+ dom.window.close();
80
+ });
81
+
82
+ beforeEach(() => {
83
+ container = dom.window.document.getElementById(
84
+ "root",
85
+ ) as unknown as HTMLElement;
86
+ container.innerHTML = "";
87
+ root = createRoot(container);
88
+ });
89
+
90
+ afterEach(() => {
91
+ act(() => {
92
+ root.unmount();
93
+ });
94
+ });
95
+
96
+ function pressKey(target: Element, key: string) {
97
+ target.dispatchEvent(
98
+ new dom.window.KeyboardEvent("keydown", { key, bubbles: true }),
99
+ );
100
+ }
101
+
102
+ function rows(): HTMLButtonElement[] {
103
+ return Array.from(container.querySelectorAll(LIST_ROW_SELECTOR));
104
+ }
105
+
106
+ interface GroupProps {
107
+ count: number;
108
+ containerRef: RefObject<HTMLDivElement | null>;
109
+ /** Renders an unmarked control beside every row, as a real list does. */
110
+ withNestedControls?: boolean;
111
+ }
112
+
113
+ function Group({ count, containerRef, withNestedControls }: GroupProps) {
114
+ useRovingFocus({ containerRef, itemSelector: LIST_ROW_SELECTOR });
115
+ return createElement(
116
+ "div",
117
+ { ref: containerRef },
118
+ Array.from({ length: count }, (_, i) =>
119
+ createElement(
120
+ "div",
121
+ { key: i },
122
+ createElement(
123
+ "button",
124
+ { type: "button", ...LIST_ROW_ATTRIBUTE },
125
+ `row-${i}`,
126
+ ),
127
+ withNestedControls
128
+ ? createElement("button", { type: "button" }, `action-${i}`)
129
+ : null,
130
+ ),
131
+ ),
132
+ );
133
+ }
134
+
135
+ function Harness(props: Omit<GroupProps, "containerRef">) {
136
+ const containerRef = useRef<HTMLDivElement>(null);
137
+ return createElement(Group, { ...props, containerRef });
138
+ }
139
+
140
+ function mount(props: Omit<GroupProps, "containerRef">) {
141
+ act(() => {
142
+ root.render(createElement(Harness, props));
143
+ });
144
+ }
145
+
146
+ describe("useRovingFocus", () => {
147
+ it("gives only the first row a tab stop before any focus has moved", () => {
148
+ mount({ count: 3 });
149
+ const items = rows();
150
+ assert.equal(items[0]?.tabIndex, 0);
151
+ assert.equal(items[1]?.tabIndex, -1);
152
+ assert.equal(items[2]?.tabIndex, -1);
153
+ });
154
+
155
+ it("ArrowDown moves focus to the next row and the tab stop follows", () => {
156
+ mount({ count: 3 });
157
+ const items = rows();
158
+ act(() => items[0]?.focus());
159
+ act(() => pressKey(items[0] as Element, "ArrowDown"));
160
+
161
+ assert.equal(dom.window.document.activeElement, items[1]);
162
+ assert.equal(items[1]?.tabIndex, 0);
163
+ assert.equal(items[0]?.tabIndex, -1);
164
+ });
165
+
166
+ it("ArrowUp clamps at the first row", () => {
167
+ mount({ count: 3 });
168
+ const items = rows();
169
+ act(() => items[0]?.focus());
170
+ act(() => pressKey(items[0] as Element, "ArrowUp"));
171
+
172
+ assert.equal(dom.window.document.activeElement, items[0]);
173
+ });
174
+
175
+ it("ArrowDown clamps at the last row", () => {
176
+ mount({ count: 2 });
177
+ const items = rows();
178
+ act(() => items[1]?.focus());
179
+ act(() => pressKey(items[1] as Element, "ArrowDown"));
180
+
181
+ assert.equal(dom.window.document.activeElement, items[1]);
182
+ });
183
+
184
+ it("Home/End jump to the first/last row", () => {
185
+ mount({ count: 4 });
186
+ const items = rows();
187
+ act(() => items[2]?.focus());
188
+ act(() => pressKey(items[2] as Element, "End"));
189
+ assert.equal(dom.window.document.activeElement, items[3]);
190
+
191
+ act(() => pressKey(items[3] as Element, "Home"));
192
+ assert.equal(dom.window.document.activeElement, items[0]);
193
+ });
194
+
195
+ it("mouse-focusing a row moves the tab stop there too", () => {
196
+ mount({ count: 3 });
197
+ const items = rows();
198
+ act(() => items[2]?.focus());
199
+
200
+ assert.equal(items[2]?.tabIndex, 0);
201
+ assert.equal(items[0]?.tabIndex, -1);
202
+ });
203
+
204
+ it("steps over controls that are not rows", () => {
205
+ mount({ count: 3, withNestedControls: true });
206
+ const items = rows();
207
+ act(() => items[0]?.focus());
208
+ act(() => pressKey(items[0] as Element, "ArrowDown"));
209
+
210
+ assert.equal(dom.window.document.activeElement, items[1]);
211
+ assert.match(items[1]?.textContent ?? "", /row-1/);
212
+ });
213
+
214
+ it("enters at the first row when focus sits on a non-row control", () => {
215
+ mount({ count: 3, withNestedControls: true });
216
+ const nested = container.querySelectorAll<HTMLButtonElement>(
217
+ `button:not(${LIST_ROW_SELECTOR})`,
218
+ );
219
+ act(() => nested[1]?.focus());
220
+ act(() => pressKey(nested[1] as Element, "ArrowDown"));
221
+
222
+ assert.equal(dom.window.document.activeElement, rows()[0]);
223
+ });
224
+
225
+ it("keeps a handled key from reaching a window-level listener", () => {
226
+ mount({ count: 3 });
227
+ let seen = 0;
228
+ const spy = () => {
229
+ seen += 1;
230
+ };
231
+ dom.window.addEventListener("keydown", spy);
232
+ const items = rows();
233
+ act(() => items[0]?.focus());
234
+ act(() => pressKey(items[0] as Element, "ArrowDown"));
235
+ act(() => pressKey(items[1] as Element, "Enter"));
236
+ dom.window.removeEventListener("keydown", spy);
237
+
238
+ assert.equal(seen, 1);
239
+ });
240
+ });
@@ -0,0 +1,113 @@
1
+ import { type RefObject, useEffect } from "react";
2
+
3
+ /** Arrow-key axis a roving-focus group listens on. */
4
+ export type RovingOrientation = "vertical" | "horizontal";
5
+
6
+ /**
7
+ * Marks the focusable element of a message-list row. Spread it onto the row's
8
+ * own control so the list's arrow-key cursor walks rows and steps over the
9
+ * controls nested inside them (a row's retry/delete actions, a section header).
10
+ */
11
+ export const LIST_ROW_ATTRIBUTE = { "data-list-row": "" } as const;
12
+
13
+ /** Selector matching {@link LIST_ROW_ATTRIBUTE}. */
14
+ export const LIST_ROW_SELECTOR = "[data-list-row]";
15
+
16
+ /**
17
+ * Next index for a roving-tabindex group given a keystroke, the current index,
18
+ * and the item count. Clamps at both ends; `Home`/`End` jump regardless of
19
+ * orientation. Returns null when the key is not one the group owns.
20
+ */
21
+ export function rovingNextIndex(
22
+ key: string,
23
+ currentIndex: number,
24
+ itemCount: number,
25
+ orientation: RovingOrientation = "vertical",
26
+ ): number | null {
27
+ if (itemCount === 0) return null;
28
+ const forwardKey = orientation === "vertical" ? "ArrowDown" : "ArrowRight";
29
+ const backwardKey = orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
30
+ if (key === forwardKey) {
31
+ return currentIndex < 0 ? 0 : Math.min(currentIndex + 1, itemCount - 1);
32
+ }
33
+ if (key === backwardKey) {
34
+ return currentIndex <= 0 ? 0 : currentIndex - 1;
35
+ }
36
+ if (key === "Home") return 0;
37
+ if (key === "End") return itemCount - 1;
38
+ return null;
39
+ }
40
+
41
+ export interface UseRovingFocusOptions {
42
+ containerRef: RefObject<HTMLElement | null>;
43
+ /** CSS selector, scoped to the container, matching every roving item. */
44
+ itemSelector: string;
45
+ orientation?: RovingOrientation;
46
+ }
47
+
48
+ function rovingItems(
49
+ container: HTMLElement,
50
+ itemSelector: string,
51
+ ): HTMLElement[] {
52
+ return Array.from(container.querySelectorAll<HTMLElement>(itemSelector));
53
+ }
54
+
55
+ /**
56
+ * Arrow-key traversal over the focusable items inside a container, with a
57
+ * roving tabindex so Tab reaches the group at one stop and Up/Down (or
58
+ * Left/Right) walk it from there. Pass the container's ref; the hook binds its
59
+ * own listeners to it.
60
+ *
61
+ * Items are discovered by querying the DOM rather than threading an index
62
+ * through every component that renders one: the nav sidebar builds its entries
63
+ * across collapsible per-account subsections, and the brief's rows come from a
64
+ * consumer-supplied row component, so neither has a flat array to index into.
65
+ *
66
+ * A handled key stops propagating, so a window-level keyboard layer above the
67
+ * group does not act on the same press.
68
+ */
69
+ export function useRovingFocus({
70
+ containerRef,
71
+ itemSelector,
72
+ orientation = "vertical",
73
+ }: UseRovingFocusOptions): void {
74
+ // No dependency array: items appear and disappear as sections expand, rows
75
+ // load, or filters change, none of which this hook's own inputs describe.
76
+ useEffect(() => {
77
+ const container = containerRef.current;
78
+ if (!container) return;
79
+
80
+ const syncTabIndex = () => {
81
+ const items = rovingItems(container, itemSelector);
82
+ if (items.length === 0) return;
83
+ const focused = items.find((item) => item === document.activeElement);
84
+ const active = focused ?? items[0];
85
+ for (const item of items) {
86
+ item.tabIndex = item === active ? 0 : -1;
87
+ }
88
+ };
89
+
90
+ const onKeyDown = (event: KeyboardEvent) => {
91
+ const items = rovingItems(container, itemSelector);
92
+ const currentIndex = items.indexOf(document.activeElement as HTMLElement);
93
+ const nextIndex = rovingNextIndex(
94
+ event.key,
95
+ currentIndex,
96
+ items.length,
97
+ orientation,
98
+ );
99
+ if (nextIndex === null || nextIndex === currentIndex) return;
100
+ event.preventDefault();
101
+ event.stopPropagation();
102
+ items[nextIndex]?.focus();
103
+ };
104
+
105
+ syncTabIndex();
106
+ container.addEventListener("focusin", syncTabIndex);
107
+ container.addEventListener("keydown", onKeyDown);
108
+ return () => {
109
+ container.removeEventListener("focusin", syncTabIndex);
110
+ container.removeEventListener("keydown", onKeyDown);
111
+ };
112
+ });
113
+ }