@remit/ui 0.0.79 → 0.0.81

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.
@@ -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
- rowSelectIntent,
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";
@@ -68,7 +64,6 @@ const meta: Meta<typeof MessageListPane> = {
68
64
  listMeta: "3 conversations",
69
65
  sections,
70
66
  onSelectThread: () => undefined,
71
- onSelectBriefCategory: () => undefined,
72
67
  },
73
68
  };
74
69
  export default meta;
@@ -90,8 +85,41 @@ const narrowFrame: Decorator = (Story) => (
90
85
  </div>
91
86
  );
92
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
+
93
121
  export const DesktopList: Story = {
94
- args: { isDesktop: true, flatList: true },
122
+ render: () => <LiveList />,
95
123
  decorators: [desktopFrame],
96
124
  };
97
125
 
@@ -101,7 +129,7 @@ export const NarrowTouchList: Story = {
101
129
  };
102
130
 
103
131
  export const Brief: Story = {
104
- args: { isDesktop: true, briefFilters: true, sections },
132
+ render: () => <LiveList briefFilters />,
105
133
  decorators: [desktopFrame],
106
134
  };
107
135
 
@@ -188,13 +216,14 @@ export const InboxWithFilterExpanded: Story = {
188
216
  decorators: [narrowFrame],
189
217
  };
190
218
 
191
- /** Consumer-supplied `listBody` slot — the pane renders the chrome (header,
192
- * keyboard hints) while the caller owns the scrollable rows. This models
193
- * 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. */
194
222
  export const CustomListBody: Story = {
195
223
  args: {
196
224
  isDesktop: true,
197
225
  flatList: true,
226
+ keyboard: noKeyboard,
198
227
  listBody: (
199
228
  <div className="flex-1 overflow-y-auto divide-y divide-line">
200
229
  {sections.flatMap((s) =>
@@ -218,7 +247,6 @@ export const CustomListBody: Story = {
218
247
  };
219
248
 
220
249
  function SelectableList({ isDesktop }: { isDesktop: boolean }) {
221
- const selection = useSelection();
222
250
  const [trashedIds, setTrashedIds] = useState<ReadonlySet<string>>(new Set());
223
251
  const [readIds, setReadIds] = useState<ReadonlySet<string>>(new Set());
224
252
 
@@ -231,6 +259,8 @@ function SelectableList({ isDesktop }: { isDesktop: boolean }) {
231
259
  ),
232
260
  }));
233
261
  const orderedIds = visible.flatMap((s) => s.threads).map((t) => t.id);
262
+ const list = useListKeyboard({ orderedIds, isDesktop });
263
+ const { selection } = list.cursor;
234
264
  const allSelected =
235
265
  orderedIds.length > 0 &&
236
266
  orderedIds.every((id) => selection.selectedIds.has(id));
@@ -240,24 +270,6 @@ function SelectableList({ isDesktop }: { isDesktop: boolean }) {
240
270
  selection.clearSelection();
241
271
  };
242
272
 
243
- // Shift and cmd/ctrl come off a mouse, which the touch list has no path for.
244
- const onRowSelect = isDesktop
245
- ? (id: string, modifiers: SelectionModifiers) => {
246
- const intent = rowSelectIntent(modifiers);
247
- if (intent === "range") {
248
- selection.selectRange(orderedIds, id);
249
- return true;
250
- }
251
- if (intent === "toggle") {
252
- selection.toggle(id);
253
- return true;
254
- }
255
- selection.clearSelection();
256
- selection.setAnchor(id);
257
- return false;
258
- }
259
- : undefined;
260
-
261
273
  return (
262
274
  <MessageListPane
263
275
  listTitle="Inbox"
@@ -266,11 +278,8 @@ function SelectableList({ isDesktop }: { isDesktop: boolean }) {
266
278
  flatList
267
279
  isDesktop={isDesktop}
268
280
  onSelectThread={() => undefined}
269
- selection={{
270
- selectedIds: selection.selectedIds,
271
- onToggle: selection.toggle,
272
- onRowSelect,
273
- }}
281
+ selection={list.selection}
282
+ keyboard={list.keyboard}
274
283
  selectionBar={
275
284
  <SelectionTopBar
276
285
  title="Inbox"
@@ -329,6 +338,7 @@ export const EmptyState: Story = {
329
338
  isDesktop: true,
330
339
  flatList: true,
331
340
  listState: "empty",
341
+ keyboard: noKeyboard,
332
342
  },
333
343
  decorators: [desktopFrame],
334
344
  };
@@ -343,6 +353,7 @@ export const FilteredEmptyState: Story = {
343
353
  isDesktop: true,
344
354
  flatList: true,
345
355
  listState: "empty",
356
+ keyboard: noKeyboard,
346
357
  listFilter: {
347
358
  label: "Personal",
348
359
  reach: "whole-folder",
@@ -361,6 +372,7 @@ export const ErrorState: Story = {
361
372
  isDesktop: true,
362
373
  flatList: true,
363
374
  listState: "error",
375
+ keyboard: noKeyboard,
364
376
  errorMessage: "Request timed out while loading this mailbox.",
365
377
  onRetry: () => undefined,
366
378
  onReportError: () => undefined,
@@ -1,14 +1,16 @@
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";
11
- import { BriefSections } from "./brief-sections.js";
13
+ import { type BriefFilterSurface, BriefSections } from "./brief-sections.js";
12
14
  import { Button } from "./button.js";
13
15
  import { KeyboardHintBar } from "./keyboard-hint-bar.js";
14
16
  import {
@@ -46,15 +48,15 @@ export function MessageListPane({
46
48
  errorMessage,
47
49
  onRetry,
48
50
  onReportError,
49
- briefCategory,
51
+ briefFilter,
50
52
  selectedThreadId,
51
53
  density = "comfortable",
52
54
  onSelectThread,
53
- onSelectBriefCategory,
54
55
  onOpenNav,
55
56
  isDesktop,
56
57
  initialTouchState,
57
58
  selection,
59
+ keyboard,
58
60
  selectionBar,
59
61
  paneOverlay,
60
62
  listBody,
@@ -71,11 +73,9 @@ export function MessageListPane({
71
73
  | "errorMessage"
72
74
  | "onRetry"
73
75
  | "onReportError"
74
- | "briefCategory"
75
76
  | "selectedThreadId"
76
77
  | "density"
77
78
  | "onSelectThread"
78
- | "onSelectBriefCategory"
79
79
  > & {
80
80
  /**
81
81
  * The active category filter, when the caller has one. Without it the empty
@@ -84,6 +84,14 @@ export function MessageListPane({
84
84
  * unfiltered, so a surface that filters must pass this.
85
85
  */
86
86
  listFilter?: MessageListFilter;
87
+ /**
88
+ * The brief's category scope, account pills and attribute chips, held by the
89
+ * caller. The cross-account brief is segmented from this panel, and a caller
90
+ * narrowing the same rows on a second surface hands both the one set it
91
+ * holds. Absent, the chips are the brief's own, no source row is offered and
92
+ * every category is in scope.
93
+ */
94
+ briefFilter?: BriefFilterSurface;
87
95
  /** Name of the collection, e.g. "Inbox". Passed to the empty state. */
88
96
  listScopeLabel?: string;
89
97
  /** When set, the list header shows a folders/menu button that opens the nav
@@ -98,6 +106,13 @@ export function MessageListPane({
98
106
  * none of it. Absent means the list does not offer multi-select.
99
107
  */
100
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;
101
116
  /**
102
117
  * The pane header. The caller mounts it for every state of the list: a
103
118
  * `SelectionTopBar` names the view while nothing is ticked and carries the
@@ -129,11 +144,60 @@ export function MessageListPane({
129
144
  }) {
130
145
  const Row = density === "compact" ? CompactRow : ComfortableRow;
131
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;
132
156
  useRovingFocus({
133
157
  containerRef: flatListRef,
134
158
  itemSelector: LIST_ROW_SELECTOR,
159
+ enabled: !walksRows,
135
160
  });
136
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
+
137
201
  const touchTriage = !isDesktop && !briefFilters && listState === "ready";
138
202
  const [refreshing, setRefreshing] = useState(false);
139
203
  const initialPeek: SwipePeek | undefined =
@@ -193,6 +257,8 @@ export function MessageListPane({
193
257
  <Row
194
258
  thread={thread}
195
259
  active={active}
260
+ focused={thread.id === cursorId}
261
+ tabIndex={rowTabIndex(thread.id)}
196
262
  selection={rowSelection(thread.id)}
197
263
  onClick={(event) => {
198
264
  if (takeRowSelect(thread.id, event)) return;
@@ -200,11 +266,18 @@ export function MessageListPane({
200
266
  }}
201
267
  />
202
268
  ),
203
- [Row, rowSelection, takeRowSelect],
269
+ [Row, rowSelection, takeRowSelect, cursorId, rowTabIndex],
204
270
  );
205
271
 
206
272
  return (
207
- <section className="relative flex h-full w-full flex-col bg-surface">
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
+ >
208
281
  {selectionBar ??
209
282
  (hideHeader ? null : (
210
283
  <header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-row-inset">
@@ -250,12 +323,11 @@ export function MessageListPane({
250
323
  />
251
324
  ) : briefFilters ? (
252
325
  <BriefSections
326
+ {...(briefFilter ?? {})}
253
327
  sections={sections}
254
- briefCategory={briefCategory}
255
328
  selectedThreadId={selectedThreadId}
256
329
  Row={BriefRow}
257
330
  onSelectThread={onSelectThread}
258
- onSelectBriefCategory={onSelectBriefCategory}
259
331
  />
260
332
  ) : listBody != null ? (
261
333
  /* Consumer-provided body wins on every width — it owns the rows
@@ -298,6 +370,8 @@ export function MessageListPane({
298
370
  key={thread.id}
299
371
  thread={thread}
300
372
  active={thread.id === selectedThreadId}
373
+ focused={thread.id === cursorId}
374
+ tabIndex={rowTabIndex(thread.id)}
301
375
  selection={rowSelection(thread.id)}
302
376
  onClick={(event) => {
303
377
  if (takeRowSelect(thread.id, event)) return;
@@ -311,7 +385,15 @@ export function MessageListPane({
311
385
  </div>
312
386
  )}
313
387
 
314
- {isDesktop && <KeyboardHintBar />}
388
+ {isDesktop && (
389
+ <KeyboardHintBar
390
+ hints={
391
+ keyboard
392
+ ? keyboardHintsFor(keyboard.handlers)
393
+ : defaultKeyboardHints
394
+ }
395
+ />
396
+ )}
315
397
  {paneOverlay}
316
398
  </section>
317
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
- {...LIST_ROW_ATTRIBUTE}
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
- {...LIST_ROW_ATTRIBUTE}
385
+ {...rowMarkers(thread)}
386
+ tabIndex={tabIndex}
367
387
  onClick={onClick}
368
388
  className={cn("group", comfortableRowClass({ active, focused }))}
369
389
  >
package/src/index.ts CHANGED
@@ -29,6 +29,8 @@ export {
29
29
  categoryTone,
30
30
  type Density,
31
31
  INTELLIGENCE_MIN_WIDTH,
32
+ isBriefCategory,
33
+ type MessageListKeyboard,
32
34
  type MessageListSelection,
33
35
  type NarrowView,
34
36
  type NavAccount,
@@ -87,9 +89,14 @@ export {
87
89
  SECTION_ROW_CAP,
88
90
  } from "./components/brief-section.js";
89
91
  export {
92
+ type BriefCategoryControl,
93
+ type BriefFilterControl,
90
94
  type BriefFilterId,
95
+ type BriefFilterSurface,
91
96
  BriefSections,
92
97
  type BriefSectionsProps,
98
+ type BriefSourceControl,
99
+ isBriefFilterId,
93
100
  matchesBriefFilters,
94
101
  } from "./components/brief-sections.js";
95
102
  export {
@@ -264,8 +271,6 @@ export {
264
271
  } from "./components/isolated-email-frame.js";
265
272
  export { Kbd, type KbdProps } from "./components/kbd.js";
266
273
  export {
267
- defaultKeyboardHints,
268
- type KeyboardHint,
269
274
  KeyboardHintBar,
270
275
  type KeyboardHintBarProps,
271
276
  } from "./components/keyboard-hint-bar.js";
@@ -662,6 +667,28 @@ export {
662
667
  isFocusable,
663
668
  isSelectable,
664
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";
665
692
  export {
666
693
  isLabelColorValue,
667
694
  type LabelColorValue,
@@ -702,6 +729,12 @@ export {
702
729
  type SuggestKeyState,
703
730
  suggestKeyAction,
704
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";
705
738
  export {
706
739
  type UseLongPressOptions,
707
740
  type UseLongPressResult,
@@ -727,6 +760,7 @@ export {
727
760
  type UseSuggestListInput,
728
761
  useSuggestList,
729
762
  } from "./lib/use-suggest-list.js";
763
+ export { useTriageKeyboard } from "./lib/use-triage-keyboard.js";
730
764
  export {
731
765
  backExits,
732
766
  clauseSentence,