@remit/ui 0.0.27 → 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.27",
3
+ "version": "0.0.28",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -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
+ });
@@ -19,8 +19,9 @@ import {
19
19
  X,
20
20
  } from "lucide-react";
21
21
  import type { ReactNode } from "react";
22
- import { useState } from "react";
22
+ import { useRef, useState } from "react";
23
23
  import { cn } from "../lib/cn.js";
24
+ import { useRovingFocus } from "../lib/roving-focus.js";
24
25
  import type {
25
26
  AppShellProps,
26
27
  NavAccount,
@@ -33,6 +34,13 @@ import type {
33
34
  /* Pane 1: navigation sidebar */
34
35
  /* ------------------------------------------------------------------ */
35
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
+
36
44
  function navItemClassName({
37
45
  active,
38
46
  dimmed,
@@ -44,6 +52,7 @@ function navItemClassName({
44
52
  }): string {
45
53
  return cn(
46
54
  "flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors",
55
+ FOCUS_RING,
47
56
  indent && "pl-7",
48
57
  active
49
58
  ? "bg-accent-2-soft font-medium text-accent-2"
@@ -241,7 +250,10 @@ function SavedSearchRow({
241
250
  <button
242
251
  type="button"
243
252
  onClick={onSelect}
244
- 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
+ )}
245
257
  >
246
258
  <Search className="size-4 shrink-0 text-fg-subtle" />
247
259
  <span className="min-w-0 flex-1 truncate">{query}</span>
@@ -251,7 +263,10 @@ function SavedSearchRow({
251
263
  type="button"
252
264
  onClick={onRemove}
253
265
  aria-label={`Remove saved search: ${query}`}
254
- 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
+ )}
255
270
  >
256
271
  <X className="size-3.5" />
257
272
  </button>
@@ -289,7 +304,10 @@ function SavedSearchesGroup({
289
304
  <button
290
305
  type="button"
291
306
  onClick={onSaveCurrentSearch}
292
- 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
+ )}
293
311
  >
294
312
  <BookmarkPlus className="size-4 shrink-0" />
295
313
  <span className="min-w-0 flex-1 truncate">
@@ -368,6 +386,7 @@ function AccountNav({
368
386
  aria-expanded={accountOpen}
369
387
  className={cn(
370
388
  "flex w-full items-center gap-1.5 px-2 pb-1 text-left transition-colors hover:text-fg",
389
+ FOCUS_RING,
371
390
  account.muted && "opacity-55",
372
391
  )}
373
392
  >
@@ -465,7 +484,10 @@ function AccountNav({
465
484
  type="button"
466
485
  onClick={toggleFolders}
467
486
  aria-expanded={foldersOpen}
468
- 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
+ )}
469
491
  >
470
492
  {foldersOpen ? (
471
493
  <ChevronDown className="size-3 shrink-0" />
@@ -499,7 +521,10 @@ function AccountNav({
499
521
  <button
500
522
  type="button"
501
523
  onClick={() => setShowAllFolders((all) => !all)}
502
- 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
+ )}
503
528
  >
504
529
  {showAllFolders
505
530
  ? "Show less"
@@ -559,11 +584,14 @@ export function NavSidebar({
559
584
  onRemoveSavedSearch,
560
585
  onSaveCurrentSearch,
561
586
  }: NavSidebarProps) {
587
+ const containerRef = useRef<HTMLElement>(null);
588
+ useRovingFocus({ containerRef, itemSelector: NAV_ITEM_SELECTOR });
589
+ const isDrawer = variant === "drawer";
590
+
562
591
  const navBody = (
563
592
  <nav
564
- className={
565
- variant === "drawer" ? "px-2 py-2" : "flex-1 overflow-y-auto px-2 py-2"
566
- }
593
+ ref={isDrawer ? containerRef : undefined}
594
+ className={isDrawer ? "px-2 py-2" : "flex-1 overflow-y-auto px-2 py-2"}
567
595
  aria-label="Mailboxes"
568
596
  >
569
597
  <NavItem
@@ -613,10 +641,13 @@ export function NavSidebar({
613
641
  </nav>
614
642
  );
615
643
 
616
- if (variant === "drawer") return navBody;
644
+ if (isDrawer) return navBody;
617
645
 
618
646
  return (
619
- <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
+ >
620
651
  {/* no toolbar over the sidebar (Apple Mail-style): nav content
621
652
  starts at the top; the datum bar exists only over the
622
653
  list/reading/intelligence panes */}
@@ -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
+ }