@remit/ui 0.0.27 → 0.0.29
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/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.tsx +42 -11
- package/src/components/outbox-row.tsx +2 -0
- package/src/components/selection-top-bar.stories.tsx +63 -1
- 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
|
@@ -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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
-
|
|
565
|
-
|
|
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 (
|
|
644
|
+
if (isDrawer) return navBody;
|
|
617
645
|
|
|
618
646
|
return (
|
|
619
|
-
<aside
|
|
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
|
>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { FolderInput } from "lucide-react";
|
|
2
3
|
import { SelectionTopBar } from "./selection-top-bar.js";
|
|
3
4
|
|
|
4
5
|
const meta: Meta<typeof SelectionTopBar> = {
|
|
@@ -22,6 +23,18 @@ export default meta;
|
|
|
22
23
|
|
|
23
24
|
type Story = StoryObj<typeof SelectionTopBar>;
|
|
24
25
|
|
|
26
|
+
/** Stand-in for the caller's move-to-folder trigger, which owns its own
|
|
27
|
+
* folder-picker data. The bar only reserves the slot. */
|
|
28
|
+
const MoveSlot = () => (
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
aria-label="Move selected messages"
|
|
32
|
+
className="inline-flex size-11 shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-raised"
|
|
33
|
+
>
|
|
34
|
+
<FolderInput className="size-4" />
|
|
35
|
+
</button>
|
|
36
|
+
);
|
|
37
|
+
|
|
25
38
|
export const One: Story = { args: { count: 1 } };
|
|
26
39
|
|
|
27
40
|
export const Many: Story = { args: { count: 3 } };
|
|
@@ -75,7 +88,7 @@ export const AllSelected: Story = {
|
|
|
75
88
|
/**
|
|
76
89
|
* The search has more matches than are loaded: an escalation notice offers a
|
|
77
90
|
* real button (not prose) naming the scope. Tapping it is what flips the
|
|
78
|
-
* selection's identity from an id set to the search query (`
|
|
91
|
+
* selection's identity from an id set to the search query (`useEscalatedActions`
|
|
79
92
|
* in web-client). No count in the label yet — the real client's own read path
|
|
80
93
|
* (`ThreadOperations.searchThreads`) only counts within a capped recency
|
|
81
94
|
* window short of paging the whole result set, and paging it just to seed a
|
|
@@ -107,11 +120,19 @@ export const EscalationAvailable: Story = {
|
|
|
107
120
|
* Selection has been escalated to the search query: the count names the
|
|
108
121
|
* query's total, not a materialized id count, and the notice offers a way
|
|
109
122
|
* back to the bounded selection.
|
|
123
|
+
*
|
|
124
|
+
* Every verb the bar carries stays available here (#114). An escalated
|
|
125
|
+
* selection is a predicate rather than an id list, so the web-client runs
|
|
126
|
+
* move and mark-read by paging that predicate the same way delete does —
|
|
127
|
+
* from the bar's side nothing changes, which is the point: an escalated
|
|
128
|
+
* selection that could only be deleted forced anyone wanting to file those
|
|
129
|
+
* messages back to the loaded page.
|
|
110
130
|
*/
|
|
111
131
|
export const Escalated: Story = {
|
|
112
132
|
args: {
|
|
113
133
|
count: 3412,
|
|
114
134
|
statusLabel: 'All 3,412 matching "npm" selected',
|
|
135
|
+
moveSlot: <MoveSlot />,
|
|
115
136
|
notice: {
|
|
116
137
|
tone: "info",
|
|
117
138
|
text: "",
|
|
@@ -199,3 +220,44 @@ export const PartialFailure: Story = {
|
|
|
199
220
|
export const ZeroSelected: Story = {
|
|
200
221
|
args: { count: 0 },
|
|
201
222
|
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A move over an escalated selection: same chunked run as a delete, worded for
|
|
226
|
+
* the action that is running and toned as ordinary progress rather than
|
|
227
|
+
* destructive. Mark-read and the move slot are hidden while it runs — nothing
|
|
228
|
+
* here can act mid-run.
|
|
229
|
+
*/
|
|
230
|
+
export const MovingWithProgress: Story = {
|
|
231
|
+
args: {
|
|
232
|
+
count: 3412,
|
|
233
|
+
statusLabel: "Moving 1,200 of 3,412…",
|
|
234
|
+
isBusy: true,
|
|
235
|
+
progress: { value: 1200, max: 3412, tone: "info" },
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/** Mark-read over the same escalated selection. */
|
|
240
|
+
export const MarkingReadWithProgress: Story = {
|
|
241
|
+
args: {
|
|
242
|
+
count: 3412,
|
|
243
|
+
statusLabel: "Marking 1,200 of 3,412 as read…",
|
|
244
|
+
isBusy: true,
|
|
245
|
+
progress: { value: 1200, max: 3412, tone: "info" },
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Partial failure of a move rather than a delete: the notice names the action
|
|
251
|
+
* that ran, and Retry resends that same action against what is still selected.
|
|
252
|
+
*/
|
|
253
|
+
export const PartialFailureMove: Story = {
|
|
254
|
+
args: {
|
|
255
|
+
count: 340,
|
|
256
|
+
moveSlot: <MoveSlot />,
|
|
257
|
+
notice: {
|
|
258
|
+
tone: "danger",
|
|
259
|
+
text: "3,072 moved. 340 couldn't be moved.",
|
|
260
|
+
action: { label: "Retry 340", onClick: () => undefined },
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
};
|
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
|
+
}
|