@remit/ui 0.0.80 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.80",
3
+ "version": "0.0.81",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -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
- 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";
@@ -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
- args: { isDesktop: true, flatList: true },
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
- args: { isDesktop: true, briefFilters: true, sections },
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 (header,
191
- * keyboard hints) while the caller owns the scrollable rows. This models
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
- selectedIds: selection.selectedIds,
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 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
+ >
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 && <KeyboardHintBar />}
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
- {...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
@@ -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,