@remit/ui 0.0.74 → 0.0.76

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,8 +1,13 @@
1
1
  import { Menu } from "lucide-react";
2
- import type { ReactNode } from "react";
3
- import { useCallback, useMemo, useRef, useState } from "react";
2
+ import type { MouseEvent, ReactNode } from "react";
3
+ import { useCallback, useRef, useState } from "react";
4
4
  import { LIST_ROW_SELECTOR, useRovingFocus } from "../lib/roving-focus.js";
5
- import type { AppShellProps, TouchSeed } from "./app-shell-types.js";
5
+ import { deriveIsMultiSelectMode, modifiersOf } from "../lib/use-selection.js";
6
+ import type {
7
+ AppShellProps,
8
+ MessageListSelection,
9
+ TouchSeed,
10
+ } from "./app-shell-types.js";
6
11
  import { BriefSections } from "./brief-sections.js";
7
12
  import { Button } from "./button.js";
8
13
  import { KeyboardHintBar } from "./keyboard-hint-bar.js";
@@ -16,12 +21,14 @@ import {
16
21
  type BriefRowComponent,
17
22
  ComfortableRow,
18
23
  CompactRow,
24
+ type RowSelection,
19
25
  type RowToggleEvent,
20
26
  } from "./message-row.js";
21
- import { SelectionTopBar } from "./selection-top-bar.js";
22
27
  import type { SwipePeek } from "./swipeable-row.js";
23
28
  import { TouchListBody } from "./touch-list.js";
24
29
 
30
+ const NO_SELECTION: ReadonlySet<string> = new Set();
31
+
25
32
  /* ------------------------------------------------------------------ */
26
33
  /* Pane 2: message list (sectioned, dense, density toggle) */
27
34
  /* ------------------------------------------------------------------ */
@@ -47,6 +54,7 @@ export function MessageListPane({
47
54
  onOpenNav,
48
55
  isDesktop,
49
56
  initialTouchState,
57
+ selection,
50
58
  selectionBar,
51
59
  paneOverlay,
52
60
  listBody,
@@ -85,9 +93,16 @@ export function MessageListPane({
85
93
  isDesktop: boolean;
86
94
  initialTouchState?: TouchSeed;
87
95
  /**
88
- * Replaces the pane header when a selection is active. The caller controls
89
- * the selection state and toolbar actions (mark-read, move, delete, cancel).
90
- * When omitted the pane's built-in touch-triage selection bar is used.
96
+ * The list's selection. The pane draws it the row checkboxes, and the
97
+ * always-visible ones the touch list shows once a row is ticked — and holds
98
+ * none of it. Absent means the list does not offer multi-select.
99
+ */
100
+ selection?: MessageListSelection;
101
+ /**
102
+ * The pane header. The caller mounts it for every state of the list: a
103
+ * `SelectionTopBar` names the view while nothing is ticked and carries the
104
+ * count and the verbs (mark-read, move, delete, cancel) from the first
105
+ * ticked row. Replaces the pane's own plain title header.
91
106
  */
92
107
  selectionBar?: ReactNode;
93
108
  /**
@@ -107,9 +122,8 @@ export function MessageListPane({
107
122
  */
108
123
  listBody?: ReactNode;
109
124
  /**
110
- * Suppress the built-in title header. The consumer owns the header (e.g. the
111
- * shared `MailHeader` rendered above the pane). The selection bar still
112
- * replaces the (now absent) header while a selection is active.
125
+ * Suppress the built-in title header, for a consumer that renders its own
126
+ * above the pane.
113
127
  */
114
128
  hideHeader?: boolean;
115
129
  }) {
@@ -121,38 +135,7 @@ export function MessageListPane({
121
135
  });
122
136
 
123
137
  const touchTriage = !isDesktop && !briefFilters && listState === "ready";
124
- const seededRows = sections.flatMap((section) => section.threads);
125
- const [selectionMode, setSelectionMode] = useState(
126
- initialTouchState === "selection",
127
- );
128
- // The seed is a touch-triage state; desktop starts unselected and gets there
129
- // through the row checkboxes.
130
- const [checkedIds, setCheckedIds] = useState<ReadonlySet<string>>(() =>
131
- initialTouchState === "selection" && !isDesktop
132
- ? new Set(seededRows.slice(0, 2).map((t) => t.id))
133
- : new Set(),
134
- );
135
- // What the fallback bar's verbs have done to the mock rows. A demo bar whose
136
- // Trash only closes the bar is a Trash that deletes nothing, which is the one
137
- // thing a selection bar must never be — so these verbs act on the rows the
138
- // mock owns, the same way `refresh` below fakes a refresh visibly.
139
- const [trashedIds, setTrashedIds] = useState<ReadonlySet<string>>(new Set());
140
- const [readIds, setReadIds] = useState<ReadonlySet<string>>(new Set());
141
138
  const [refreshing, setRefreshing] = useState(false);
142
- const touchSections = useMemo(
143
- () =>
144
- trashedIds.size === 0 && readIds.size === 0
145
- ? sections
146
- : sections.map((section) => ({
147
- ...section,
148
- threads: section.threads
149
- .filter((thread) => !trashedIds.has(thread.id))
150
- .map((thread) =>
151
- readIds.has(thread.id) ? { ...thread, isRead: true } : thread,
152
- ),
153
- })),
154
- [sections, trashedIds, readIds],
155
- );
156
139
  const initialPeek: SwipePeek | undefined =
157
140
  initialTouchState === "peek-trailing"
158
141
  ? "trailing"
@@ -160,92 +143,70 @@ export function MessageListPane({
160
143
  ? "leading"
161
144
  : undefined;
162
145
 
163
- const toggleCheck = useCallback((id: string) => {
164
- setCheckedIds((prev) => {
165
- const next = new Set(prev);
166
- if (next.has(id)) next.delete(id);
167
- else next.add(id);
168
- if (next.size === 0) setSelectionMode(false);
169
- return next;
170
- });
171
- }, []);
172
- const enterSelection = (id: string) => {
173
- setSelectionMode(true);
174
- setCheckedIds(new Set([id]));
175
- };
176
- const cancelSelection = () => {
177
- setSelectionMode(false);
178
- setCheckedIds(new Set());
179
- };
180
- const trashChecked = () => {
181
- setTrashedIds((prev) => new Set([...prev, ...checkedIds]));
182
- cancelSelection();
183
- };
184
- const markCheckedRead = () => {
185
- setReadIds((prev) => new Set([...prev, ...checkedIds]));
186
- cancelSelection();
187
- };
188
146
  const refresh = () => {
189
147
  setRefreshing(true);
190
148
  setTimeout(() => setRefreshing(false), 1400);
191
149
  };
192
150
 
193
- // Desktop rows carry the checkbox the app's own rows have: it takes the
194
- // avatar's place on hover, and checking one puts the pane in selection.
195
- const desktopSelectable = isDesktop && !selectionBar && listState === "ready";
151
+ const selectedIds = selection?.selectedIds ?? NO_SELECTION;
152
+ const toggleSelected = selection?.onToggle;
153
+ const onRowSelect = selection?.onRowSelect;
154
+ // Multi-select is the touch affordance, and it is a function of the count
155
+ // rather than a flag, so ticking the last row off leaves it on its own.
156
+ const selectionMode = deriveIsMultiSelectMode(selectedIds.size, isDesktop);
157
+
158
+ // The checkbox takes the avatar's place on hover, and stays put once the row
159
+ // is ticked.
196
160
  const rowSelection = useCallback(
197
- (id: string) =>
198
- desktopSelectable
199
- ? {
200
- checked: checkedIds.has(id),
201
- onToggle: (event: RowToggleEvent) => {
202
- event.preventDefault();
203
- event.stopPropagation();
204
- toggleCheck(id);
205
- },
206
- }
207
- : undefined,
208
- [desktopSelectable, checkedIds, toggleCheck],
161
+ (id: string): RowSelection | undefined => {
162
+ if (!toggleSelected) return undefined;
163
+ return {
164
+ checked: selectedIds.has(id),
165
+ onToggle: (event: RowToggleEvent) => {
166
+ event.preventDefault();
167
+ event.stopPropagation();
168
+ toggleSelected(id);
169
+ },
170
+ };
171
+ },
172
+ [selectedIds, toggleSelected],
173
+ );
174
+
175
+ // Shift ranges and cmd/ctrl ticks, so a modified click never opens the row it
176
+ // was aimed at. Shift-click otherwise drags a native text selection across
177
+ // the rows it spans — the row highlight is the selection the user asked for.
178
+ const takeRowSelect = useCallback(
179
+ (id: string, event: MouseEvent): boolean => {
180
+ if (!onRowSelect?.(id, modifiersOf(event))) return false;
181
+ event.preventDefault();
182
+ if (event.shiftKey) window.getSelection()?.removeAllRanges();
183
+ return true;
184
+ },
185
+ [onRowSelect],
209
186
  );
210
187
 
211
188
  // The brief drives rows through a `BriefRowComponent`, whose props carry no
212
189
  // selection — a consumer's own row (the web client's) wires its checkbox
213
190
  // itself. Binding it here keeps the kit's rows selectable there too.
214
191
  const BriefRow: BriefRowComponent = useCallback(
215
- (props) => <Row {...props} selection={rowSelection(props.thread.id)} />,
216
- [Row, rowSelection],
192
+ ({ thread, active, onClick }) => (
193
+ <Row
194
+ thread={thread}
195
+ active={active}
196
+ selection={rowSelection(thread.id)}
197
+ onClick={(event) => {
198
+ if (takeRowSelect(thread.id, event)) return;
199
+ onClick?.();
200
+ }}
201
+ />
202
+ ),
203
+ [Row, rowSelection, takeRowSelect],
217
204
  );
218
205
 
219
- const selectableIds = seededRows.map((thread) => thread.id);
220
- const allChecked =
221
- selectableIds.length > 0 && selectableIds.every((id) => checkedIds.has(id));
222
- const selectAll = {
223
- checked: allChecked,
224
- indeterminate: checkedIds.size > 0 && !allChecked,
225
- onChange: () =>
226
- setCheckedIds(allChecked ? new Set() : new Set(selectableIds)),
227
- };
228
-
229
- // When the caller supplies a selectionBar slot, it owns selection state.
230
- // Fall back to the built-in bar only when no external bar is given.
231
- const inBuiltinSelection =
232
- !selectionBar &&
233
- checkedIds.size > 0 &&
234
- (desktopSelectable || (touchTriage && selectionMode));
235
-
236
206
  return (
237
207
  <section className="relative flex h-full w-full flex-col bg-surface">
238
208
  {selectionBar ??
239
- (inBuiltinSelection ? (
240
- <SelectionTopBar
241
- title={listTitle}
242
- count={checkedIds.size}
243
- selectAll={desktopSelectable ? selectAll : undefined}
244
- onCancel={cancelSelection}
245
- onMarkRead={markCheckedRead}
246
- onDelete={trashChecked}
247
- />
248
- ) : hideHeader ? null : (
209
+ (hideHeader ? null : (
249
210
  <header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-row-inset">
250
211
  {onOpenNav && (
251
212
  <Button
@@ -304,13 +265,13 @@ export function MessageListPane({
304
265
  listBody
305
266
  ) : touchTriage ? (
306
267
  <TouchListBody
307
- sections={touchSections}
268
+ sections={sections}
308
269
  selectedThreadId={selectedThreadId}
309
270
  selectionMode={selectionMode}
310
- checkedIds={checkedIds}
271
+ checkedIds={selectedIds}
311
272
  initialPeek={initialPeek}
312
- onToggleCheck={toggleCheck}
313
- onEnterSelection={enterSelection}
273
+ onToggleCheck={(id) => toggleSelected?.(id)}
274
+ onEnterSelection={(id) => toggleSelected?.(id)}
314
275
  onOpenThread={(id) => onSelectThread?.(id)}
315
276
  onRefresh={refresh}
316
277
  refreshing={refreshing}
@@ -338,7 +299,10 @@ export function MessageListPane({
338
299
  thread={thread}
339
300
  active={thread.id === selectedThreadId}
340
301
  selection={rowSelection(thread.id)}
341
- onClick={() => onSelectThread?.(thread.id)}
302
+ onClick={(event) => {
303
+ if (takeRowSelect(thread.id, event)) return;
304
+ onSelectThread?.(thread.id);
305
+ }}
342
306
  />
343
307
  ))}
344
308
  </div>
@@ -1,5 +1,10 @@
1
1
  import { Check, Paperclip, ShieldAlert, Star } from "lucide-react";
2
- import type { ComponentType, ReactNode, SyntheticEvent } from "react";
2
+ import type {
3
+ ComponentType,
4
+ MouseEvent,
5
+ ReactNode,
6
+ SyntheticEvent,
7
+ } from "react";
3
8
  import { cn } from "../lib/cn.js";
4
9
  import { LIST_ROW_ATTRIBUTE } from "../lib/roving-focus.js";
5
10
  import { categoryTone, type ThreadRowData } from "./app-shell-types.js";
@@ -328,7 +333,7 @@ export function CompactRow({
328
333
  thread: ThreadRowData;
329
334
  active?: boolean;
330
335
  focused?: boolean;
331
- onClick?: () => void;
336
+ onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
332
337
  }) {
333
338
  return (
334
339
  <button
@@ -353,7 +358,7 @@ export function ComfortableRow({
353
358
  active?: boolean;
354
359
  focused?: boolean;
355
360
  selection?: RowSelection;
356
- onClick?: () => void;
361
+ onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
357
362
  }) {
358
363
  return (
359
364
  <button
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import { createElement } from "react";
4
4
  import { renderToString } from "react-dom/server";
5
+ import { FilterPanelProvider } from "./filter-sheet.js";
5
6
  import { MobileSearchView } from "./mobile-search-view.js";
6
7
  import type { SearchResult } from "./search-result-row.js";
7
8
 
@@ -66,6 +67,26 @@ describe("MobileSearchView filter chrome", () => {
66
67
  );
67
68
  assert.match(html, /Expand filters/);
68
69
  });
70
+
71
+ // The takeover covers the list whose header carries the caret, so it cannot
72
+ // borrow that caret: it renders over the header, not under it. Without the
73
+ // boundary the sheet reads the list's panel and drops its own trigger row,
74
+ // leaving the takeover with no way to open its filters at all.
75
+ it("keeps its own filter row over a list that has a filter panel", () => {
76
+ const html = renderToString(
77
+ createElement(
78
+ FilterPanelProvider,
79
+ { hasSheet: true },
80
+ createElement(MobileSearchView, {
81
+ ...base,
82
+ value: "",
83
+ sections: [],
84
+ filter,
85
+ }),
86
+ ),
87
+ );
88
+ assert.match(html, /Expand filters/);
89
+ });
69
90
  });
70
91
 
71
92
  describe("MobileSearchView search scope", () => {
@@ -1,7 +1,11 @@
1
1
  import { X } from "lucide-react";
2
2
  import type { ReactNode } from "react";
3
3
  import { Button } from "./button.js";
4
- import { FilterSheet, type FilterSheetProps } from "./filter-sheet.js";
4
+ import {
5
+ FilterPanelBoundary,
6
+ FilterSheet,
7
+ type FilterSheetProps,
8
+ } from "./filter-sheet.js";
5
9
  import { SearchBar } from "./search-bar.js";
6
10
  import type { SearchChip, SearchFieldSuggest } from "./search-chip-input.js";
7
11
  import type { SearchResult } from "./search-result-row.js";
@@ -146,7 +150,9 @@ export function MobileSearchView({
146
150
  {suggestList}
147
151
 
148
152
  {filter && value.trim().length === 0 ? (
149
- <FilterSheet {...filter}>{body}</FilterSheet>
153
+ <FilterPanelBoundary>
154
+ <FilterSheet {...filter}>{body}</FilterSheet>
155
+ </FilterPanelBoundary>
150
156
  ) : (
151
157
  <div className="flex-1 overflow-y-auto">{body}</div>
152
158
  )}
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The touch list renders the `sections` it is handed, on every render.
3
+ *
4
+ * It used to copy them into state on mount, so a consumer whose verbs act on
5
+ * its own rows — the selection bar's Trash — cleared the bar and left every
6
+ * row on screen. The swipe mock still has to work, so what it does is held as
7
+ * ids over whatever the consumer passes rather than as a copy of the rows.
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
12
+ import type { JSDOM } from "jsdom";
13
+ import { act, createElement } from "react";
14
+ import { createRoot, type Root } from "react-dom/client";
15
+ import type { ThreadSection } from "./app-shell-types.js";
16
+ import type { SwipePeek } from "./swipeable-row.js";
17
+ import { TouchListBody } from "./touch-list.js";
18
+
19
+ let dom: JSDOM;
20
+ let container: HTMLElement;
21
+ let root: Root;
22
+
23
+ const row = (id: string, fromName: string, isRead = false) => ({
24
+ id,
25
+ accountId: "account-1",
26
+ fromName,
27
+ fromEmail: `${id}@example.com`,
28
+ subject: `Subject ${id}`,
29
+ snippet: "…",
30
+ timeLabel: "9:42",
31
+ isRead,
32
+ });
33
+
34
+ const sectionsOf = (...ids: string[]): ThreadSection[] => [
35
+ { id: "inbox", threads: ids.map((id) => row(id, `Sender ${id}`)) },
36
+ ];
37
+
38
+ const readOf = (...ids: string[]): ThreadSection[] => [
39
+ { id: "inbox", threads: ids.map((id) => row(id, `Sender ${id}`, true)) },
40
+ ];
41
+
42
+ const render = (sections: ThreadSection[], initialPeek?: SwipePeek) => {
43
+ act(() => {
44
+ root.render(
45
+ createElement(TouchListBody, {
46
+ sections,
47
+ initialPeek,
48
+ selectionMode: false,
49
+ checkedIds: new Set<string>(),
50
+ onToggleCheck: () => undefined,
51
+ onEnterSelection: () => undefined,
52
+ onOpenThread: () => undefined,
53
+ onRefresh: () => undefined,
54
+ refreshing: false,
55
+ }),
56
+ );
57
+ });
58
+ };
59
+
60
+ /** The unread dot each row draws while it is unread, and only then. */
61
+ const unreadRows = () =>
62
+ container.querySelectorAll("span.rounded-full.bg-accent").length;
63
+
64
+ const click = (label: string) => {
65
+ const button = container.querySelector(`button[aria-label="${label}"]`);
66
+ assert.ok(button, `no "${label}" control on the page`);
67
+ act(() => {
68
+ button.dispatchEvent(
69
+ new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }),
70
+ );
71
+ });
72
+ };
73
+
74
+ before(async () => {
75
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
76
+ dom = new JSDOMCtor(
77
+ "<!doctype html><html><body><div id=root></div></body></html>",
78
+ { url: "http://localhost/", pretendToBeVisual: true },
79
+ );
80
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
81
+ globalThis.document = dom.window.document;
82
+ globalThis.HTMLElement = dom.window.HTMLElement;
83
+ globalThis.Element = dom.window.Element;
84
+ globalThis.SVGElement = dom.window.SVGElement;
85
+ (
86
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
87
+ ).IS_REACT_ACT_ENVIRONMENT = true;
88
+ });
89
+
90
+ after(() => {
91
+ dom.window.close();
92
+ });
93
+
94
+ beforeEach(() => {
95
+ container = dom.window.document.getElementById(
96
+ "root",
97
+ ) as unknown as HTMLElement;
98
+ container.innerHTML = "";
99
+ root = createRoot(container);
100
+ });
101
+
102
+ afterEach(() => {
103
+ act(() => {
104
+ root.unmount();
105
+ });
106
+ });
107
+
108
+ describe("TouchListBody", () => {
109
+ it("drops the rows a consumer's verb removed", () => {
110
+ render(sectionsOf("a", "b", "c"));
111
+ assert.match(container.textContent ?? "", /Sender a/);
112
+
113
+ render(sectionsOf("c"));
114
+ assert.doesNotMatch(container.textContent ?? "", /Sender a/);
115
+ assert.doesNotMatch(container.textContent ?? "", /Sender b/);
116
+ assert.match(container.textContent ?? "", /Sender c/);
117
+ });
118
+
119
+ it("shows the rows a consumer added after mount", () => {
120
+ render(sectionsOf("a"));
121
+ render(sectionsOf("a", "b"));
122
+ assert.match(container.textContent ?? "", /Sender b/);
123
+ });
124
+ });
125
+
126
+ describe("TouchListBody swipe-to-toggle-read", () => {
127
+ it("leaves the row at the state the swipe landed on", () => {
128
+ render(sectionsOf("a", "b"), "leading");
129
+ assert.equal(unreadRows(), 2, "both rows start unread");
130
+ click("Mark as read");
131
+ assert.equal(unreadRows(), 1, "the swiped row is read");
132
+ });
133
+
134
+ it("does not invert a row the consumer has since marked read", () => {
135
+ render(sectionsOf("a", "b"), "leading");
136
+ click("Mark as read");
137
+ render(readOf("a", "b"));
138
+ assert.equal(unreadRows(), 0, "a row the consumer marked read stays read");
139
+ });
140
+ });
@@ -33,11 +33,20 @@ export function TouchListBody({
33
33
  */
34
34
  busy?: boolean;
35
35
  }) {
36
- // Local copy so the mock can act on a swipe: delete removes the row,
37
- // toggle-read flips its state. The live client owns real mutation.
38
- const [items, setItems] = useState(() =>
39
- sections.flatMap((section) => section.threads),
36
+ // What a swipe has left a row at the state it landed on, not a flip of
37
+ // whatever the prop says so the list still follows the `sections` its
38
+ // consumer passes. The live client owns real mutation.
39
+ const [swipedAway, setSwipedAway] = useState<ReadonlySet<string>>(new Set());
40
+ const [swipedRead, setSwipedRead] = useState<ReadonlyMap<string, boolean>>(
41
+ new Map(),
40
42
  );
43
+ const items = sections
44
+ .flatMap((section) => section.threads)
45
+ .filter((thread) => !swipedAway.has(thread.id))
46
+ .map((thread) => {
47
+ const read = swipedRead.get(thread.id);
48
+ return read === undefined ? thread : { ...thread, isRead: read };
49
+ });
41
50
  const [peek, setPeek] = useState<{ id: string; side: SwipePeek } | null>(
42
51
  initialPeek && initialPeek !== "none" && items[1]
43
52
  ? { id: items[1].id, side: initialPeek }
@@ -45,11 +54,10 @@ export function TouchListBody({
45
54
  );
46
55
  const act = (id: string, side: "leading" | "trailing") => {
47
56
  if (side === "trailing") {
48
- setItems((prev) => prev.filter((t) => t.id !== id));
57
+ setSwipedAway((prev) => new Set(prev).add(id));
49
58
  } else {
50
- setItems((prev) =>
51
- prev.map((t) => (t.id === id ? { ...t, isRead: !t.isRead } : t)),
52
- );
59
+ const shown = items.find((thread) => thread.id === id);
60
+ setSwipedRead((prev) => new Map(prev).set(id, !shown?.isRead));
53
61
  }
54
62
  setPeek(null);
55
63
  };
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export {
29
29
  categoryTone,
30
30
  type Density,
31
31
  INTELLIGENCE_MIN_WIDTH,
32
+ type MessageListSelection,
32
33
  type NarrowView,
33
34
  type NavAccount,
34
35
  type NavAccountStatus,
@@ -86,8 +87,10 @@ export {
86
87
  SECTION_ROW_CAP,
87
88
  } from "./components/brief-section.js";
88
89
  export {
90
+ type BriefFilterId,
89
91
  BriefSections,
90
92
  type BriefSectionsProps,
93
+ matchesBriefFilters,
91
94
  } from "./components/brief-sections.js";
92
95
  export {
93
96
  Button,
@@ -699,6 +702,20 @@ export {
699
702
  type UseLongPressResult,
700
703
  useLongPress,
701
704
  } from "./lib/use-long-press.js";
705
+ export {
706
+ computeRange,
707
+ deriveIsMultiSelectMode,
708
+ intersectSelectedIds,
709
+ isModified,
710
+ modifiersOf,
711
+ nextFocusId,
712
+ type RowSelectIntent,
713
+ resolveRangeAnchor,
714
+ rowSelectIntent,
715
+ type SelectionModifiers,
716
+ type UseSelectionOptions,
717
+ useSelection,
718
+ } from "./lib/use-selection.js";
702
719
  export {
703
720
  type ComboboxProps,
704
721
  type SuggestListState,