@remit/ui 0.0.80 → 0.0.82
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-types.ts +22 -0
- package/src/components/app-shell.tsx +5 -0
- package/src/components/keyboard-hint-bar.render.test.ts +29 -0
- package/src/components/keyboard-hint-bar.tsx +2 -14
- package/src/components/message-list-pane.stories.tsx +48 -35
- package/src/components/message-list-pane.tsx +82 -4
- package/src/components/message-row.tsx +22 -2
- package/src/components/self-update.ts +3 -4
- package/src/index.ts +30 -2
- package/src/lib/keymap-dispatch.test.ts +255 -0
- package/src/lib/keymap-dispatch.ts +244 -0
- package/src/lib/keymap.test.ts +68 -0
- package/src/lib/keymap.ts +241 -0
- package/src/lib/use-list-cursor.test.ts +273 -0
- package/src/lib/use-list-cursor.ts +263 -0
- package/src/lib/use-list-keyboard.test.ts +172 -0
- package/src/lib/use-list-keyboard.ts +101 -0
- package/src/lib/use-triage-keyboard.test.ts +153 -0
- package/src/lib/use-triage-keyboard.ts +119 -0
package/package.json
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
useRef,
|
|
7
7
|
useState,
|
|
8
8
|
} from "react";
|
|
9
|
+
import type { TriageHandlers } from "../lib/keymap.js";
|
|
9
10
|
import type { SelectionModifiers } from "../lib/use-selection.js";
|
|
10
11
|
import type {
|
|
11
12
|
IntelligenceData,
|
|
@@ -84,6 +85,25 @@ export interface MessageListSelection {
|
|
|
84
85
|
onRowSelect?: (id: string, modifiers: SelectionModifiers) => boolean;
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/**
|
|
89
|
+
* The keyboard layer above the list, owned by whoever mounts it. The handler
|
|
90
|
+
* table is what the pane reads: it offers the keys that table answers, draws
|
|
91
|
+
* the cursor the ones it answers move, and keeps its own arrow-key traversal
|
|
92
|
+
* for the ones it does not. A layer that answers nothing offers nothing, so a
|
|
93
|
+
* host cannot advertise a key it has not wired.
|
|
94
|
+
*
|
|
95
|
+
* `useListKeyboard` builds it; the pane hands `ref` back the element the layer
|
|
96
|
+
* listens on.
|
|
97
|
+
*/
|
|
98
|
+
export interface MessageListKeyboard {
|
|
99
|
+
/** The row the cursor sits on. */
|
|
100
|
+
focusedId: string | undefined;
|
|
101
|
+
/** What the layer answers, and therefore what the footer offers. */
|
|
102
|
+
handlers: TriageHandlers;
|
|
103
|
+
/** Takes the pane element the layer binds its keys to. */
|
|
104
|
+
ref: (element: HTMLElement | null) => void;
|
|
105
|
+
}
|
|
106
|
+
|
|
87
107
|
/**
|
|
88
108
|
* Measures an element's OWN width via ResizeObserver — a container query, not a
|
|
89
109
|
* viewport one. The shell reflows by the space it actually occupies (so it works
|
|
@@ -363,6 +383,8 @@ export interface AppShellProps {
|
|
|
363
383
|
selectionBar?: ReactNode;
|
|
364
384
|
/** The list's selection, forwarded to `MessageListPane`. */
|
|
365
385
|
selection?: MessageListSelection;
|
|
386
|
+
/** The list's keyboard layer, forwarded to `MessageListPane`. */
|
|
387
|
+
keyboard?: MessageListKeyboard;
|
|
366
388
|
intelligence?: IntelligenceData;
|
|
367
389
|
/** Pane 4 visible. Defaults to true when intelligence is present. */
|
|
368
390
|
intelligenceOpen?: boolean;
|
|
@@ -71,6 +71,7 @@ export function AppShell({
|
|
|
71
71
|
initialNarrowView = "list",
|
|
72
72
|
initialTouchState,
|
|
73
73
|
selection,
|
|
74
|
+
keyboard,
|
|
74
75
|
selectionBar,
|
|
75
76
|
intelligence,
|
|
76
77
|
intelligenceOpen = true,
|
|
@@ -129,6 +130,7 @@ export function AppShell({
|
|
|
129
130
|
onSelectBriefCategory={selectCategory}
|
|
130
131
|
initialTouchState={initialTouchState}
|
|
131
132
|
selection={selection}
|
|
133
|
+
keyboard={keyboard}
|
|
132
134
|
selectionBar={selectionBar}
|
|
133
135
|
/>
|
|
134
136
|
);
|
|
@@ -190,6 +192,7 @@ function AppShellList({
|
|
|
190
192
|
onSelectBriefCategory,
|
|
191
193
|
initialTouchState,
|
|
192
194
|
selection,
|
|
195
|
+
keyboard,
|
|
193
196
|
selectionBar,
|
|
194
197
|
}: Pick<
|
|
195
198
|
AppShellProps,
|
|
@@ -209,6 +212,7 @@ function AppShellList({
|
|
|
209
212
|
| "onSelectThread"
|
|
210
213
|
| "initialTouchState"
|
|
211
214
|
| "selection"
|
|
215
|
+
| "keyboard"
|
|
212
216
|
| "selectionBar"
|
|
213
217
|
> & {
|
|
214
218
|
narrowView: NarrowView;
|
|
@@ -252,6 +256,7 @@ function AppShellList({
|
|
|
252
256
|
isDesktop={showReadingPane}
|
|
253
257
|
initialTouchState={initialTouchState}
|
|
254
258
|
selection={selection}
|
|
259
|
+
keyboard={keyboard}
|
|
255
260
|
selectionBar={selectionBar}
|
|
256
261
|
/>
|
|
257
262
|
);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { keyboardHintsFor } from "../lib/keymap.js";
|
|
6
|
+
import { KeyboardHintBar } from "./keyboard-hint-bar.js";
|
|
7
|
+
|
|
8
|
+
describe("KeyboardHintBar", () => {
|
|
9
|
+
it("offers the app's own set by default", () => {
|
|
10
|
+
const html = renderToString(createElement(KeyboardHintBar));
|
|
11
|
+
assert.match(html, /<span>navigate<\/span>/);
|
|
12
|
+
assert.match(html, /<span>mute<\/span>/);
|
|
13
|
+
assert.match(html, /<span>all shortcuts<\/span>/);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("offers only what the host's handlers answer", () => {
|
|
17
|
+
const hints = keyboardHintsFor({ focusNext: () => undefined });
|
|
18
|
+
const html = renderToString(createElement(KeyboardHintBar, { hints }));
|
|
19
|
+
assert.match(html, /<span>navigate<\/span>/);
|
|
20
|
+
assert.doesNotMatch(html, /<span>mute<\/span>/);
|
|
21
|
+
assert.doesNotMatch(html, /<span>all shortcuts<\/span>/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("renders nothing for a host that serves none of them", () => {
|
|
25
|
+
const hints = keyboardHintsFor({ toggleStar: () => undefined });
|
|
26
|
+
assert.deepEqual(hints, []);
|
|
27
|
+
assert.equal(renderToString(createElement(KeyboardHintBar, { hints })), "");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -1,27 +1,14 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { defaultKeyboardHints, type KeyboardHint } from "../lib/keymap.js";
|
|
3
4
|
import { Kbd } from "./kbd.js";
|
|
4
5
|
|
|
5
|
-
export interface KeyboardHint {
|
|
6
|
-
/** One or more keys shown as `Kbd` chips. */
|
|
7
|
-
keys: string[];
|
|
8
|
-
/** What the keys do. */
|
|
9
|
-
label: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
6
|
export interface KeyboardHintBarProps {
|
|
13
7
|
/** Override the default hint set. */
|
|
14
8
|
hints?: KeyboardHint[];
|
|
15
9
|
className?: string;
|
|
16
10
|
}
|
|
17
11
|
|
|
18
|
-
/** The persistent message-list footer hint set (desktop). */
|
|
19
|
-
export const defaultKeyboardHints: KeyboardHint[] = [
|
|
20
|
-
{ keys: ["j", "k"], label: "navigate" },
|
|
21
|
-
{ keys: ["m"], label: "mute" },
|
|
22
|
-
{ keys: ["?"], label: "all shortcuts" },
|
|
23
|
-
];
|
|
24
|
-
|
|
25
12
|
/**
|
|
26
13
|
* The keyboard-shortcut hint footer under the message list. Keyboard-first
|
|
27
14
|
* discoverability — the shortcuts aren't hidden behind the `?` modal. The host
|
|
@@ -32,6 +19,7 @@ export function KeyboardHintBar({
|
|
|
32
19
|
hints = defaultKeyboardHints,
|
|
33
20
|
className,
|
|
34
21
|
}: KeyboardHintBarProps) {
|
|
22
|
+
if (hints.length === 0) return null;
|
|
35
23
|
return (
|
|
36
24
|
<footer
|
|
37
25
|
className={cn(
|
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import type { Decorator, Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { inboxFilterConfig } from "../filter-presets.js";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
type SelectionModifiers,
|
|
7
|
-
useSelection,
|
|
8
|
-
} from "../lib/use-selection.js";
|
|
9
|
-
import type { ThreadSection } from "./app-shell-types.js";
|
|
4
|
+
import { useListKeyboard } from "../lib/use-list-keyboard.js";
|
|
5
|
+
import type { MessageListKeyboard, ThreadSection } from "./app-shell-types.js";
|
|
10
6
|
import { FilterSheet } from "./filter-sheet.js";
|
|
11
7
|
import { MailHeader } from "./mail-header.js";
|
|
12
8
|
import { MessageListPane } from "./message-list-pane.js";
|
|
@@ -89,8 +85,41 @@ const narrowFrame: Decorator = (Story) => (
|
|
|
89
85
|
</div>
|
|
90
86
|
);
|
|
91
87
|
|
|
88
|
+
/**
|
|
89
|
+
* A pane with no keyboard over it — a story whose rows come from elsewhere, or
|
|
90
|
+
* that has no rows at all. It answers nothing and so offers nothing.
|
|
91
|
+
*/
|
|
92
|
+
const noKeyboard: MessageListKeyboard = {
|
|
93
|
+
focusedId: undefined,
|
|
94
|
+
handlers: {},
|
|
95
|
+
ref: () => undefined,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The list under the triage keyboard: j/k walk the rows, x and Space tick the
|
|
100
|
+
* one under the cursor, Shift+j/k build a range and ⌘A takes them all. The
|
|
101
|
+
* footer offers what is wired.
|
|
102
|
+
*/
|
|
103
|
+
function LiveList({ briefFilters = false }: { briefFilters?: boolean }) {
|
|
104
|
+
const orderedIds = sections.flatMap((s) => s.threads).map((t) => t.id);
|
|
105
|
+
const list = useListKeyboard({ orderedIds, isDesktop: true });
|
|
106
|
+
return (
|
|
107
|
+
<MessageListPane
|
|
108
|
+
listTitle="Inbox"
|
|
109
|
+
listMeta="3 conversations"
|
|
110
|
+
sections={sections}
|
|
111
|
+
flatList={!briefFilters}
|
|
112
|
+
briefFilters={briefFilters}
|
|
113
|
+
isDesktop
|
|
114
|
+
onSelectThread={() => undefined}
|
|
115
|
+
selection={list.selection}
|
|
116
|
+
keyboard={list.keyboard}
|
|
117
|
+
/>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
92
121
|
export const DesktopList: Story = {
|
|
93
|
-
|
|
122
|
+
render: () => <LiveList />,
|
|
94
123
|
decorators: [desktopFrame],
|
|
95
124
|
};
|
|
96
125
|
|
|
@@ -100,7 +129,7 @@ export const NarrowTouchList: Story = {
|
|
|
100
129
|
};
|
|
101
130
|
|
|
102
131
|
export const Brief: Story = {
|
|
103
|
-
|
|
132
|
+
render: () => <LiveList briefFilters />,
|
|
104
133
|
decorators: [desktopFrame],
|
|
105
134
|
};
|
|
106
135
|
|
|
@@ -187,13 +216,14 @@ export const InboxWithFilterExpanded: Story = {
|
|
|
187
216
|
decorators: [narrowFrame],
|
|
188
217
|
};
|
|
189
218
|
|
|
190
|
-
/** Consumer-supplied `listBody` slot — the pane renders the chrome
|
|
191
|
-
*
|
|
192
|
-
* the web-client's virtualized inbox path. */
|
|
219
|
+
/** Consumer-supplied `listBody` slot — the pane renders the chrome while the
|
|
220
|
+
* caller owns the scrollable rows, and the keys over those rows with them.
|
|
221
|
+
* This models the web-client's virtualized inbox path. */
|
|
193
222
|
export const CustomListBody: Story = {
|
|
194
223
|
args: {
|
|
195
224
|
isDesktop: true,
|
|
196
225
|
flatList: true,
|
|
226
|
+
keyboard: noKeyboard,
|
|
197
227
|
listBody: (
|
|
198
228
|
<div className="flex-1 overflow-y-auto divide-y divide-line">
|
|
199
229
|
{sections.flatMap((s) =>
|
|
@@ -217,7 +247,6 @@ export const CustomListBody: Story = {
|
|
|
217
247
|
};
|
|
218
248
|
|
|
219
249
|
function SelectableList({ isDesktop }: { isDesktop: boolean }) {
|
|
220
|
-
const selection = useSelection();
|
|
221
250
|
const [trashedIds, setTrashedIds] = useState<ReadonlySet<string>>(new Set());
|
|
222
251
|
const [readIds, setReadIds] = useState<ReadonlySet<string>>(new Set());
|
|
223
252
|
|
|
@@ -230,6 +259,8 @@ function SelectableList({ isDesktop }: { isDesktop: boolean }) {
|
|
|
230
259
|
),
|
|
231
260
|
}));
|
|
232
261
|
const orderedIds = visible.flatMap((s) => s.threads).map((t) => t.id);
|
|
262
|
+
const list = useListKeyboard({ orderedIds, isDesktop });
|
|
263
|
+
const { selection } = list.cursor;
|
|
233
264
|
const allSelected =
|
|
234
265
|
orderedIds.length > 0 &&
|
|
235
266
|
orderedIds.every((id) => selection.selectedIds.has(id));
|
|
@@ -239,24 +270,6 @@ function SelectableList({ isDesktop }: { isDesktop: boolean }) {
|
|
|
239
270
|
selection.clearSelection();
|
|
240
271
|
};
|
|
241
272
|
|
|
242
|
-
// Shift and cmd/ctrl come off a mouse, which the touch list has no path for.
|
|
243
|
-
const onRowSelect = isDesktop
|
|
244
|
-
? (id: string, modifiers: SelectionModifiers) => {
|
|
245
|
-
const intent = rowSelectIntent(modifiers);
|
|
246
|
-
if (intent === "range") {
|
|
247
|
-
selection.selectRange(orderedIds, id);
|
|
248
|
-
return true;
|
|
249
|
-
}
|
|
250
|
-
if (intent === "toggle") {
|
|
251
|
-
selection.toggle(id);
|
|
252
|
-
return true;
|
|
253
|
-
}
|
|
254
|
-
selection.clearSelection();
|
|
255
|
-
selection.setAnchor(id);
|
|
256
|
-
return false;
|
|
257
|
-
}
|
|
258
|
-
: undefined;
|
|
259
|
-
|
|
260
273
|
return (
|
|
261
274
|
<MessageListPane
|
|
262
275
|
listTitle="Inbox"
|
|
@@ -265,11 +278,8 @@ function SelectableList({ isDesktop }: { isDesktop: boolean }) {
|
|
|
265
278
|
flatList
|
|
266
279
|
isDesktop={isDesktop}
|
|
267
280
|
onSelectThread={() => undefined}
|
|
268
|
-
selection={
|
|
269
|
-
|
|
270
|
-
onToggle: selection.toggle,
|
|
271
|
-
onRowSelect,
|
|
272
|
-
}}
|
|
281
|
+
selection={list.selection}
|
|
282
|
+
keyboard={list.keyboard}
|
|
273
283
|
selectionBar={
|
|
274
284
|
<SelectionTopBar
|
|
275
285
|
title="Inbox"
|
|
@@ -328,6 +338,7 @@ export const EmptyState: Story = {
|
|
|
328
338
|
isDesktop: true,
|
|
329
339
|
flatList: true,
|
|
330
340
|
listState: "empty",
|
|
341
|
+
keyboard: noKeyboard,
|
|
331
342
|
},
|
|
332
343
|
decorators: [desktopFrame],
|
|
333
344
|
};
|
|
@@ -342,6 +353,7 @@ export const FilteredEmptyState: Story = {
|
|
|
342
353
|
isDesktop: true,
|
|
343
354
|
flatList: true,
|
|
344
355
|
listState: "empty",
|
|
356
|
+
keyboard: noKeyboard,
|
|
345
357
|
listFilter: {
|
|
346
358
|
label: "Personal",
|
|
347
359
|
reach: "whole-folder",
|
|
@@ -360,6 +372,7 @@ export const ErrorState: Story = {
|
|
|
360
372
|
isDesktop: true,
|
|
361
373
|
flatList: true,
|
|
362
374
|
listState: "error",
|
|
375
|
+
keyboard: noKeyboard,
|
|
363
376
|
errorMessage: "Request timed out while loading this mailbox.",
|
|
364
377
|
onRetry: () => undefined,
|
|
365
378
|
onReportError: () => undefined,
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Menu } from "lucide-react";
|
|
2
2
|
import type { MouseEvent, ReactNode } from "react";
|
|
3
|
-
import { useCallback, useRef, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { defaultKeyboardHints, keyboardHintsFor } from "../lib/keymap.js";
|
|
4
5
|
import { LIST_ROW_SELECTOR, useRovingFocus } from "../lib/roving-focus.js";
|
|
5
6
|
import { deriveIsMultiSelectMode, modifiersOf } from "../lib/use-selection.js";
|
|
6
7
|
import type {
|
|
7
8
|
AppShellProps,
|
|
9
|
+
MessageListKeyboard,
|
|
8
10
|
MessageListSelection,
|
|
9
11
|
TouchSeed,
|
|
10
12
|
} from "./app-shell-types.js";
|
|
@@ -54,6 +56,7 @@ export function MessageListPane({
|
|
|
54
56
|
isDesktop,
|
|
55
57
|
initialTouchState,
|
|
56
58
|
selection,
|
|
59
|
+
keyboard,
|
|
57
60
|
selectionBar,
|
|
58
61
|
paneOverlay,
|
|
59
62
|
listBody,
|
|
@@ -103,6 +106,13 @@ export function MessageListPane({
|
|
|
103
106
|
* none of it. Absent means the list does not offer multi-select.
|
|
104
107
|
*/
|
|
105
108
|
selection?: MessageListSelection;
|
|
109
|
+
/**
|
|
110
|
+
* The keyboard layer driving the list, when the caller mounts one. The pane
|
|
111
|
+
* binds it to its own element, draws the cursor it moves and offers only the
|
|
112
|
+
* keys its handlers answer; absent, the footer offers the app's own set and
|
|
113
|
+
* the rows keep their arrow-key traversal.
|
|
114
|
+
*/
|
|
115
|
+
keyboard?: MessageListKeyboard;
|
|
106
116
|
/**
|
|
107
117
|
* The pane header. The caller mounts it for every state of the list: a
|
|
108
118
|
* `SelectionTopBar` names the view while nothing is ticked and carries the
|
|
@@ -134,11 +144,60 @@ export function MessageListPane({
|
|
|
134
144
|
}) {
|
|
135
145
|
const Row = density === "compact" ? CompactRow : ComfortableRow;
|
|
136
146
|
const flatListRef = useRef<HTMLDivElement>(null);
|
|
147
|
+
const paneRef = useRef<HTMLElement | null>(null);
|
|
148
|
+
|
|
149
|
+
// The layer answers the arrows only if it registered them. Anything else it
|
|
150
|
+
// hands over — a layer with no cursor keys, or no layer at all — leaves the
|
|
151
|
+
// rows their own traversal and their own single tab stop.
|
|
152
|
+
const walksRows =
|
|
153
|
+
keyboard !== undefined &&
|
|
154
|
+
keyboard.handlers.focusNext !== undefined &&
|
|
155
|
+
keyboard.handlers.focusPrevious !== undefined;
|
|
137
156
|
useRovingFocus({
|
|
138
157
|
containerRef: flatListRef,
|
|
139
158
|
itemSelector: LIST_ROW_SELECTOR,
|
|
159
|
+
enabled: !walksRows,
|
|
140
160
|
});
|
|
141
161
|
|
|
162
|
+
const cursorId = walksRows ? keyboard.focusedId : undefined;
|
|
163
|
+
// One tab stop into the list, as the roving group gives it: the row the
|
|
164
|
+
// cursor is on, or the first row before it has moved.
|
|
165
|
+
const tabStopId = walksRows
|
|
166
|
+
? (cursorId ?? sections[0]?.threads[0]?.id)
|
|
167
|
+
: undefined;
|
|
168
|
+
const rowTabIndex = useCallback(
|
|
169
|
+
(id: string): number | undefined =>
|
|
170
|
+
tabStopId === undefined ? undefined : id === tabStopId ? 0 : -1,
|
|
171
|
+
[tabStopId],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// Real browser focus follows the cursor, so Tab, Shift+Tab and the focus
|
|
175
|
+
// ring agree with the row the list highlights — and so the keys keep
|
|
176
|
+
// reaching the layer, which listens on the pane rather than the window.
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (cursorId === undefined) return;
|
|
179
|
+
const pane = paneRef.current;
|
|
180
|
+
if (!pane) return;
|
|
181
|
+
const row = pane.querySelector<HTMLElement>(
|
|
182
|
+
`${LIST_ROW_SELECTOR}[data-message-id="${cursorId}"]`,
|
|
183
|
+
);
|
|
184
|
+
if (!row || row === pane.ownerDocument.activeElement) return;
|
|
185
|
+
row.focus({ preventScroll: false });
|
|
186
|
+
}, [cursorId]);
|
|
187
|
+
|
|
188
|
+
// A layer bound to this pane hears nothing until focus is inside it. Taking
|
|
189
|
+
// focus on mount is what keeps j working without a click first — and only
|
|
190
|
+
// from a document where nothing else has claimed it, so a page of stories
|
|
191
|
+
// does not fight over the caret.
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
if (!walksRows) return;
|
|
194
|
+
const pane = paneRef.current;
|
|
195
|
+
if (!pane) return;
|
|
196
|
+
const active = pane.ownerDocument.activeElement;
|
|
197
|
+
if (active !== null && active !== pane.ownerDocument.body) return;
|
|
198
|
+
pane.focus({ preventScroll: true });
|
|
199
|
+
}, [walksRows]);
|
|
200
|
+
|
|
142
201
|
const touchTriage = !isDesktop && !briefFilters && listState === "ready";
|
|
143
202
|
const [refreshing, setRefreshing] = useState(false);
|
|
144
203
|
const initialPeek: SwipePeek | undefined =
|
|
@@ -198,6 +257,8 @@ export function MessageListPane({
|
|
|
198
257
|
<Row
|
|
199
258
|
thread={thread}
|
|
200
259
|
active={active}
|
|
260
|
+
focused={thread.id === cursorId}
|
|
261
|
+
tabIndex={rowTabIndex(thread.id)}
|
|
201
262
|
selection={rowSelection(thread.id)}
|
|
202
263
|
onClick={(event) => {
|
|
203
264
|
if (takeRowSelect(thread.id, event)) return;
|
|
@@ -205,11 +266,18 @@ export function MessageListPane({
|
|
|
205
266
|
}}
|
|
206
267
|
/>
|
|
207
268
|
),
|
|
208
|
-
[Row, rowSelection, takeRowSelect],
|
|
269
|
+
[Row, rowSelection, takeRowSelect, cursorId, rowTabIndex],
|
|
209
270
|
);
|
|
210
271
|
|
|
211
272
|
return (
|
|
212
|
-
<section
|
|
273
|
+
<section
|
|
274
|
+
ref={(element) => {
|
|
275
|
+
paneRef.current = element;
|
|
276
|
+
keyboard?.ref(element);
|
|
277
|
+
}}
|
|
278
|
+
tabIndex={walksRows ? -1 : undefined}
|
|
279
|
+
className="relative flex h-full w-full flex-col bg-surface outline-none"
|
|
280
|
+
>
|
|
213
281
|
{selectionBar ??
|
|
214
282
|
(hideHeader ? null : (
|
|
215
283
|
<header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-row-inset">
|
|
@@ -302,6 +370,8 @@ export function MessageListPane({
|
|
|
302
370
|
key={thread.id}
|
|
303
371
|
thread={thread}
|
|
304
372
|
active={thread.id === selectedThreadId}
|
|
373
|
+
focused={thread.id === cursorId}
|
|
374
|
+
tabIndex={rowTabIndex(thread.id)}
|
|
305
375
|
selection={rowSelection(thread.id)}
|
|
306
376
|
onClick={(event) => {
|
|
307
377
|
if (takeRowSelect(thread.id, event)) return;
|
|
@@ -315,7 +385,15 @@ export function MessageListPane({
|
|
|
315
385
|
</div>
|
|
316
386
|
)}
|
|
317
387
|
|
|
318
|
-
{isDesktop &&
|
|
388
|
+
{isDesktop && (
|
|
389
|
+
<KeyboardHintBar
|
|
390
|
+
hints={
|
|
391
|
+
keyboard
|
|
392
|
+
? keyboardHintsFor(keyboard.handlers)
|
|
393
|
+
: defaultKeyboardHints
|
|
394
|
+
}
|
|
395
|
+
/>
|
|
396
|
+
)}
|
|
319
397
|
{paneOverlay}
|
|
320
398
|
</section>
|
|
321
399
|
);
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
SyntheticEvent,
|
|
7
7
|
} from "react";
|
|
8
8
|
import { cn } from "../lib/cn.js";
|
|
9
|
+
import { ROW_ATTRIBUTE } from "../lib/keymap-dispatch.js";
|
|
9
10
|
import { LIST_ROW_ATTRIBUTE } from "../lib/roving-focus.js";
|
|
10
11
|
import { categoryTone, type ThreadRowData } from "./app-shell-types.js";
|
|
11
12
|
import { Avatar } from "./avatar.js";
|
|
@@ -324,21 +325,37 @@ export function ComfortableRowBody({
|
|
|
324
325
|
);
|
|
325
326
|
}
|
|
326
327
|
|
|
328
|
+
/**
|
|
329
|
+
* What marks a row as a row: the roving-focus marker, the message-list marker
|
|
330
|
+
* the keyboard layer reads to leave Enter and Space with the list rather than
|
|
331
|
+
* with the button under focus, and the id the cursor finds it by. The app's own
|
|
332
|
+
* row carries the same three (`MessageRow`), so a key pressed on a kit row does
|
|
333
|
+
* what it does on an app row.
|
|
334
|
+
*/
|
|
335
|
+
const rowMarkers = (thread: ThreadRowData) => ({
|
|
336
|
+
...LIST_ROW_ATTRIBUTE,
|
|
337
|
+
[ROW_ATTRIBUTE]: "",
|
|
338
|
+
"data-message-id": thread.id,
|
|
339
|
+
});
|
|
340
|
+
|
|
327
341
|
export function CompactRow({
|
|
328
342
|
thread,
|
|
329
343
|
active,
|
|
330
344
|
focused,
|
|
345
|
+
tabIndex,
|
|
331
346
|
onClick,
|
|
332
347
|
}: {
|
|
333
348
|
thread: ThreadRowData;
|
|
334
349
|
active?: boolean;
|
|
335
350
|
focused?: boolean;
|
|
351
|
+
tabIndex?: number;
|
|
336
352
|
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
337
353
|
}) {
|
|
338
354
|
return (
|
|
339
355
|
<button
|
|
340
356
|
type="button"
|
|
341
|
-
{...
|
|
357
|
+
{...rowMarkers(thread)}
|
|
358
|
+
tabIndex={tabIndex}
|
|
342
359
|
onClick={onClick}
|
|
343
360
|
className={compactRowClass({ active, focused })}
|
|
344
361
|
>
|
|
@@ -351,19 +368,22 @@ export function ComfortableRow({
|
|
|
351
368
|
thread,
|
|
352
369
|
active,
|
|
353
370
|
focused,
|
|
371
|
+
tabIndex,
|
|
354
372
|
selection,
|
|
355
373
|
onClick,
|
|
356
374
|
}: {
|
|
357
375
|
thread: ThreadRowData;
|
|
358
376
|
active?: boolean;
|
|
359
377
|
focused?: boolean;
|
|
378
|
+
tabIndex?: number;
|
|
360
379
|
selection?: RowSelection;
|
|
361
380
|
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
362
381
|
}) {
|
|
363
382
|
return (
|
|
364
383
|
<button
|
|
365
384
|
type="button"
|
|
366
|
-
{...
|
|
385
|
+
{...rowMarkers(thread)}
|
|
386
|
+
tabIndex={tabIndex}
|
|
367
387
|
onClick={onClick}
|
|
368
388
|
className={cn("group", comfortableRowClass({ active, focused }))}
|
|
369
389
|
>
|
|
@@ -18,10 +18,9 @@ export interface ReleaseInfo {
|
|
|
18
18
|
export type UpdatePhase = "preparing" | "restarting" | "reconnecting";
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Identifies one update attempt. The
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* update in flight from an ordinary server blip.
|
|
21
|
+
* Identifies one update attempt. The server reports it for as long as the run
|
|
22
|
+
* is the current or last one, which is how a client tells the run it asked for
|
|
23
|
+
* from a later one, and an update in flight from an ordinary server blip.
|
|
25
24
|
*/
|
|
26
25
|
export type UpdateRunId = string;
|
|
27
26
|
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ export {
|
|
|
30
30
|
type Density,
|
|
31
31
|
INTELLIGENCE_MIN_WIDTH,
|
|
32
32
|
isBriefCategory,
|
|
33
|
+
type MessageListKeyboard,
|
|
33
34
|
type MessageListSelection,
|
|
34
35
|
type NarrowView,
|
|
35
36
|
type NavAccount,
|
|
@@ -270,8 +271,6 @@ export {
|
|
|
270
271
|
} from "./components/isolated-email-frame.js";
|
|
271
272
|
export { Kbd, type KbdProps } from "./components/kbd.js";
|
|
272
273
|
export {
|
|
273
|
-
defaultKeyboardHints,
|
|
274
|
-
type KeyboardHint,
|
|
275
274
|
KeyboardHintBar,
|
|
276
275
|
type KeyboardHintBarProps,
|
|
277
276
|
} from "./components/keyboard-hint-bar.js";
|
|
@@ -668,6 +667,28 @@ export {
|
|
|
668
667
|
isFocusable,
|
|
669
668
|
isSelectable,
|
|
670
669
|
} from "./lib/folder-tree-focus.js";
|
|
670
|
+
export {
|
|
671
|
+
defaultKeyboardHints,
|
|
672
|
+
KEY_HINT_GROUPS,
|
|
673
|
+
type KeyboardHint,
|
|
674
|
+
type KeyHint,
|
|
675
|
+
type KeyHintGroup,
|
|
676
|
+
keyboardHintsFor,
|
|
677
|
+
keysForAction,
|
|
678
|
+
shortcutHintForAction,
|
|
679
|
+
type TriageAction,
|
|
680
|
+
type TriageHandlers,
|
|
681
|
+
tooltipForAction,
|
|
682
|
+
} from "./lib/keymap.js";
|
|
683
|
+
export {
|
|
684
|
+
type DispatchResult,
|
|
685
|
+
dispatchKey,
|
|
686
|
+
isControlTarget,
|
|
687
|
+
isEditableTarget,
|
|
688
|
+
type KeyStroke,
|
|
689
|
+
ROW_ATTRIBUTE,
|
|
690
|
+
type SequencePrefix,
|
|
691
|
+
} from "./lib/keymap-dispatch.js";
|
|
671
692
|
export {
|
|
672
693
|
isLabelColorValue,
|
|
673
694
|
type LabelColorValue,
|
|
@@ -708,6 +729,12 @@ export {
|
|
|
708
729
|
type SuggestKeyState,
|
|
709
730
|
suggestKeyAction,
|
|
710
731
|
} from "./lib/suggest-keys.js";
|
|
732
|
+
export { type ListCursor, useListCursor } from "./lib/use-list-cursor.js";
|
|
733
|
+
export {
|
|
734
|
+
type ListKeyboard,
|
|
735
|
+
type UseListKeyboardOptions,
|
|
736
|
+
useListKeyboard,
|
|
737
|
+
} from "./lib/use-list-keyboard.js";
|
|
711
738
|
export {
|
|
712
739
|
type UseLongPressOptions,
|
|
713
740
|
type UseLongPressResult,
|
|
@@ -733,6 +760,7 @@ export {
|
|
|
733
760
|
type UseSuggestListInput,
|
|
734
761
|
useSuggestList,
|
|
735
762
|
} from "./lib/use-suggest-list.js";
|
|
763
|
+
export { useTriageKeyboard } from "./lib/use-triage-keyboard.js";
|
|
736
764
|
export {
|
|
737
765
|
backExits,
|
|
738
766
|
clauseSentence,
|