@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 +1 -1
- package/src/components/app-shell.render.test.ts +1 -0
- package/src/components/brief-section.tsx +2 -2
- package/src/components/brief-sections.keyboard.test.ts +175 -0
- package/src/components/brief-sections.tsx +6 -3
- package/src/components/message-list-pane.tsx +8 -2
- package/src/components/message-row.tsx +9 -0
- package/src/components/nav-sidebar.keyboard.test.ts +143 -0
- package/src/components/nav-sidebar.render.test.ts +37 -0
- package/src/components/nav-sidebar.stories.tsx +2 -0
- package/src/components/nav-sidebar.tsx +54 -11
- package/src/components/outbox-row.tsx +2 -0
- package/src/index.ts +8 -0
- package/src/lib/roving-focus.test.ts +240 -0
- package/src/lib/roving-focus.ts +113 -0
package/package.json
CHANGED
|
@@ -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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
-
|
|
564
|
-
|
|
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 (
|
|
644
|
+
if (isDrawer) return navBody;
|
|
616
645
|
|
|
617
646
|
return (
|
|
618
|
-
<aside
|
|
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
|
+
}
|