@remit/web-client 0.0.109 → 0.0.111

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/web-client",
3
- "version": "0.0.109",
3
+ "version": "0.0.111",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -7,13 +7,13 @@
7
7
  * brief defaults to the cross-account aggregate, and `MailListHeader` provides
8
8
  * the title, unread count, and search.
9
9
  *
10
- * The list's filter control (categories + attribute chips + the account source
11
- * group) is hidden the brief is being tried without it. The kit
12
- * `BriefSections` renders that control above the list body, so the body is
13
- * rendered here by `BriefListBody` instead; every input the control consumed is
14
- * still computed below, so bringing it back is swapping `BriefListBody` for
15
- * `<BriefSections />`. Account switching also lives in the nav sidebar, and
16
- * the phone search takeover keeps its own copy of the filter sheet.
10
+ * The list's filter surface is the kit `BriefSections`: categories, attribute
11
+ * chips and the account source group, in a panel the list header's caret opens
12
+ * over the rows. `FilterPanelProvider` shares that panel's open state between
13
+ * the caret and the sheet, the same shape `MailViewChrome` gives the mailbox and
14
+ * Starred views. The category and the chips narrow the grouped sections
15
+ * themselves, and the phone search takeover reads the same selection, so a
16
+ * filter set on one surface holds on the other.
17
17
  *
18
18
  * Multi-select is the mailbox list's, not a copy of it: the same
19
19
  * `ThreadListInteraction` cursor and `useSelection` state, and the same
@@ -37,11 +37,14 @@ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.
37
37
  import {
38
38
  type BriefCategoryFilter,
39
39
  BriefEmpty,
40
- BriefSection,
40
+ type BriefFilterId,
41
+ BriefSections,
41
42
  briefFilterConfig,
43
+ FilterPanelProvider,
42
44
  type FilterSheetProps,
43
45
  type FilterSheetSource,
44
46
  KeyboardHintBar,
47
+ matchesBriefFilters,
45
48
  partitionSpamResults,
46
49
  type SearchResult,
47
50
  SelectionTopBar,
@@ -102,21 +105,10 @@ import {
102
105
  useThreadListSelection,
103
106
  } from "./ThreadListInteraction";
104
107
 
105
- /* The brief's attribute chips as predicates (mirrors the kit `briefFilterChips`
106
- ids) so the phone search takeover narrows results the same way the list does. */
107
108
  /* Page size for the unscoped cross-folder search. One page is what the takeover
108
109
  and the "Top matches" list render; the server caps it at 500. */
109
110
  const UNSCOPED_SEARCH_PAGE_SIZE = 200;
110
111
 
111
- const BRIEF_SEARCH_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
112
- unread: (t) => !t.isRead,
113
- attachment: (t) => t.hasAttachment === true,
114
- contacts: (t) => t.trust === "vip" || t.trust === "wellknown",
115
- today: (t) =>
116
- t.sentDate != null &&
117
- new Date(t.sentDate).toDateString() === new Date().toDateString(),
118
- };
119
-
120
112
  // ---------------------------------------------------------------------------
121
113
  // Skeleton
122
114
  // ---------------------------------------------------------------------------
@@ -169,73 +161,6 @@ const ErrorBanner = ({ accountEmail }: ErrorBannerProps) => {
169
161
  );
170
162
  };
171
163
 
172
- // ---------------------------------------------------------------------------
173
- // List body (the kit `BriefSections` body without its filter control)
174
- // ---------------------------------------------------------------------------
175
-
176
- interface BriefListBodyProps {
177
- sections: ThreadSection[];
178
- /** Category scope. "all" keeps the per-category sections; anything else flattens. */
179
- briefCategory: BriefCategoryFilter;
180
- selectedThreadId?: string;
181
- onSelectThread?: (id: string) => void;
182
- }
183
-
184
- /**
185
- * The brief's list body: one capped section per category at the "all" scope, a
186
- * headerless flat list once narrowed to a single category (the section headers
187
- * are redundant there).
188
- *
189
- * This is the kit `BriefSections` body minus the filter control it renders above
190
- * it. The category scope still arrives from the brief — the phone search
191
- * takeover's filter sheet sets it — so the flatten path stays reachable; the
192
- * attribute chips are part of the hidden control and narrow nothing here.
193
- */
194
- function BriefListBody({
195
- sections,
196
- briefCategory,
197
- selectedThreadId,
198
- onSelectThread,
199
- }: BriefListBodyProps) {
200
- const showSections = briefCategory === "all";
201
- const flatRows = sections
202
- .flatMap((section) => section.threads)
203
- .filter((thread) => thread.category === briefCategory);
204
- const empty = showSections ? sections.length === 0 : flatRows.length === 0;
205
-
206
- return (
207
- <div className="h-full overflow-y-auto">
208
- {showSections ? (
209
- sections.map((section) => (
210
- <BriefSection
211
- key={section.id}
212
- section={section}
213
- Row={MessageRow}
214
- selectedThreadId={selectedThreadId}
215
- onSelectThread={onSelectThread}
216
- />
217
- ))
218
- ) : (
219
- <div className="divide-y divide-line">
220
- {flatRows.map((thread) => (
221
- <MessageRow
222
- key={thread.id}
223
- thread={thread}
224
- active={thread.id === selectedThreadId}
225
- onClick={() => onSelectThread?.(thread.id)}
226
- />
227
- ))}
228
- </div>
229
- )}
230
- {empty && (
231
- <div className="px-row-inset py-6 text-center text-2xs text-fg-subtle">
232
- No threads match these filters.
233
- </div>
234
- )}
235
- </div>
236
- );
237
- }
238
-
239
164
  // ---------------------------------------------------------------------------
240
165
  // Selection surface (the mailbox list's, mounted on the brief)
241
166
  // ---------------------------------------------------------------------------
@@ -462,7 +387,7 @@ export function DailyBrief({
462
387
  onTriageContextChange,
463
388
  onDeleteMessages,
464
389
  }: DailyBriefProps) {
465
- const { searchQuery, resultFolderIndex } = useMailContext();
390
+ const { searchQuery, searchInput, resultFolderIndex } = useMailContext();
466
391
  const tokenContext = useSearchTokenContext();
467
392
  const isDesktop = useIsDesktop();
468
393
  const wizard = useSelectionWizard();
@@ -474,29 +399,37 @@ export function DailyBrief({
474
399
  );
475
400
 
476
401
  // "all" = the cross-account aggregate (the brief's default), and "all"
477
- // categories = the full set of sections. Both stay at their default while the
478
- // list's filter control is hidden; the phone search takeover still drives them
479
- // (the category also drives the flatten-when-filtered path), and account
480
- // switching lives in the nav sidebar.
402
+ // categories = the full set of sections. Account switching also lives in the
403
+ // nav sidebar; the category also drives the flatten-when-filtered path.
481
404
  const [selectedAccountId, setSelectedAccountId] = useState("all");
482
405
  const [selectedCategory, setSelectedCategory] =
483
406
  useState<BriefCategoryFilter>("all");
484
407
 
485
- // Attribute chips for the phone search takeover a separate surface from the
486
- // list, so it carries its own additive set (category + account are shared
487
- // above) and keeps its filter sheet while the list's control is hidden.
488
- const [searchAttributes, setSearchAttributes] = useState<ReadonlySet<string>>(
489
- new Set(),
490
- );
491
- const [searchExpanded, setSearchExpanded] = useState(false);
492
- const toggleSearchAttribute = useCallback((id: string) => {
493
- setSearchAttributes((prev) => {
408
+ // Held here rather than inside the list's filter sheet: the phone search
409
+ // takeover narrows the same rows by the same chips, and the body is unmounted
410
+ // and remounted around a query being typed, which would take a set living
411
+ // below with it.
412
+ const [activeFilters, setActiveFilters] = useState<
413
+ ReadonlySet<BriefFilterId>
414
+ >(new Set());
415
+ const [filterExpanded, setFilterExpanded] = useState(false);
416
+ const toggleFilter = useCallback((id: BriefFilterId) => {
417
+ setActiveFilters((prev) => {
494
418
  const next = new Set(prev);
495
419
  if (next.has(id)) next.delete(id);
496
420
  else next.add(id);
497
421
  return next;
498
422
  });
499
423
  }, []);
424
+ const clearFilters = useCallback(() => setActiveFilters(new Set()), []);
425
+
426
+ // A query owns the pane: the filter panel and the search's own affordance
427
+ // narrow the same list from the same place, so the panel stands down for as
428
+ // long as something is being searched. Its state survives, so clearing the
429
+ // query brings it back with the same category and chips. The header caret is
430
+ // gone under a query, and a panel left open with nothing to collapse it is
431
+ // what makes this load-bearing rather than tidy.
432
+ const searching = searchInput.trim().length > 0;
500
433
 
501
434
  // --- Unified threads query ---
502
435
  const {
@@ -649,19 +582,18 @@ export function DailyBrief({
649
582
  );
650
583
 
651
584
  // The phone search takeover renders the account/free-text-narrowed rows,
652
- // further narrowed by the shared category and the takeover's attribute chips.
653
- const searchResults = useMemo<SearchResult[]>(() => {
654
- const predicates = Array.from(searchAttributes)
655
- .map((id) => BRIEF_SEARCH_PREDICATES[id])
656
- .filter((p): p is (t: ThreadRowData) => boolean => p != null);
657
- return filteredRows
658
- .filter(
659
- (t) =>
660
- (selectedCategory === "all" || t.category === selectedCategory) &&
661
- predicates.every((p) => p(t)),
662
- )
663
- .map((row) => rowToSearchResult(row, resultFolderIndex));
664
- }, [filteredRows, selectedCategory, searchAttributes, resultFolderIndex]);
585
+ // further narrowed by the same category and attribute chips the list applies.
586
+ const searchResults = useMemo<SearchResult[]>(
587
+ () =>
588
+ filteredRows
589
+ .filter(
590
+ (t) =>
591
+ (selectedCategory === "all" || t.category === selectedCategory) &&
592
+ matchesBriefFilters(t, activeFilters),
593
+ )
594
+ .map((row) => rowToSearchResult(row, resultFolderIndex)),
595
+ [filteredRows, selectedCategory, activeFilters, resultFolderIndex],
596
+ );
665
597
 
666
598
  // "Related" (semantic) spans every account here — the brief is the
667
599
  // cross-account view, so no mailbox scope. Dedupe against the literal "Top
@@ -683,7 +615,7 @@ export function DailyBrief({
683
615
  );
684
616
  }, [semanticHits, searchResults, threadsData, resultFolderIndex]);
685
617
 
686
- const searchFilterConfig = useMemo<Omit<FilterSheetProps, "children">>(() => {
618
+ const filterConfig = useMemo<Omit<FilterSheetProps, "children">>(() => {
687
619
  const preset = briefFilterConfig(
688
620
  accountSources.map((s) => ({
689
621
  id: s.id,
@@ -698,26 +630,27 @@ export function DailyBrief({
698
630
  sources: preset.sources,
699
631
  sourcesNote: mutedCount > 0 ? `+${mutedCount} muted` : undefined,
700
632
  selectedCategory,
701
- activeFilters: searchAttributes,
702
- expanded: searchExpanded,
703
- onExpandedChange: setSearchExpanded,
633
+ activeFilters,
634
+ expanded: filterExpanded,
635
+ onExpandedChange: setFilterExpanded,
704
636
  onSelectCategory: (id: string) =>
705
637
  setSelectedCategory(id as BriefCategoryFilter),
706
638
  onSelectSource: setSelectedAccountId,
707
- onToggleFilter: toggleSearchAttribute,
639
+ onToggleFilter: (id: string) => toggleFilter(id as BriefFilterId),
708
640
  onClear: () => {
709
641
  setSelectedCategory("all");
710
642
  setSelectedAccountId("all");
711
- setSearchAttributes(new Set());
643
+ clearFilters();
712
644
  },
713
645
  };
714
646
  }, [
715
647
  accountSources,
716
648
  mutedCount,
717
649
  selectedCategory,
718
- searchAttributes,
719
- searchExpanded,
720
- toggleSearchAttribute,
650
+ activeFilters,
651
+ filterExpanded,
652
+ toggleFilter,
653
+ clearFilters,
721
654
  ]);
722
655
 
723
656
  // The brief is genuinely empty (caught up) only when nothing is narrowing the
@@ -756,34 +689,12 @@ export function DailyBrief({
756
689
  </div>
757
690
  );
758
691
 
759
- const stateBody = isLoading ? (
760
- briefSkeleton
761
- ) : isError ? (
762
- <div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-sm text-fg-muted">
763
- <AlertCircle className="size-8 text-danger" />
764
- <p>Couldn't load your messages</p>
765
- <button
766
- type="button"
767
- onClick={() => refetch()}
768
- className="flex items-center gap-1 text-accent underline text-xs"
769
- >
770
- <RefreshCw className="size-3.5" />
771
- Try again
772
- </button>
773
- </div>
774
- ) : caughtUp ? (
775
- syncProgress.resolved ? (
776
- <BriefEmpty
777
- sync={
778
- syncProgress.syncing
779
- ? { synced: syncProgress.synced, total: syncProgress.total }
780
- : undefined
781
- }
782
- />
783
- ) : (
784
- briefSkeleton
785
- )
786
- ) : (
692
+ // The filter sheet lives in the list body, so it is on screen only when the
693
+ // rows are. The caret reads the same answer and stands down everywhere else,
694
+ // rather than opening nothing over a skeleton or an empty state.
695
+ const showsRows = !isLoading && !isError && !caughtUp;
696
+
697
+ const stateBody = showsRows ? (
787
698
  <div className="flex h-full min-h-0 flex-col">
788
699
  {briefSpamOffer && (
789
700
  <SpamResultsOffer
@@ -802,61 +713,99 @@ export function DailyBrief({
802
713
  />
803
714
  )}
804
715
  <div className="min-h-0 flex-1">
805
- <BriefListBody
716
+ <BriefSections
806
717
  sections={sections}
807
718
  briefCategory={selectedCategory}
719
+ Row={MessageRow}
808
720
  selectedThreadId={selectedMessageId}
809
721
  onSelectThread={onSelectMessage}
722
+ onSelectBriefCategory={setSelectedCategory}
723
+ sources={accountSources}
724
+ sourcesNote={mutedCount > 0 ? `+${mutedCount} muted` : undefined}
725
+ onSelectSource={setSelectedAccountId}
726
+ activeFilters={activeFilters}
727
+ onToggleFilter={toggleFilter}
728
+ onClearFilters={clearFilters}
729
+ hideChrome={searching}
810
730
  />
811
731
  </div>
812
732
  </div>
733
+ ) : isLoading ? (
734
+ briefSkeleton
735
+ ) : isError ? (
736
+ <div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-sm text-fg-muted">
737
+ <AlertCircle className="size-8 text-danger" />
738
+ <p>Couldn't load your messages</p>
739
+ <button
740
+ type="button"
741
+ onClick={() => refetch()}
742
+ className="flex items-center gap-1 text-accent underline text-xs"
743
+ >
744
+ <RefreshCw className="size-3.5" />
745
+ Try again
746
+ </button>
747
+ </div>
748
+ ) : syncProgress.resolved ? (
749
+ <BriefEmpty
750
+ sync={
751
+ syncProgress.syncing
752
+ ? { synced: syncProgress.synced, total: syncProgress.total }
753
+ : undefined
754
+ }
755
+ />
756
+ ) : (
757
+ briefSkeleton
813
758
  );
814
759
 
815
760
  // The cursor and selection wrap the whole pane, not just the list body: the
816
761
  // selection toolbar takes the header's place while rows are selected, so it
817
- // has to sit inside the same provider the rows do.
762
+ // has to sit inside the same provider the rows do. The filter panel spans the
763
+ // same pane for the same reason — the caret is in the header, the panel is
764
+ // above the rows.
818
765
  return (
819
- <ThreadListInteraction
820
- selectedMessageId={selectedMessageId}
821
- onOpen={(id, options) => onSelectMessage?.(id, options)}
822
- onDeleteMessages={onDeleteMessages}
823
- onSelectionVerb={wizard.start}
824
- wizardOpen={wizard.isOpen}
825
- commandsRef={commandsRef}
826
- onTriageContextChange={onTriageContextChange}
827
- >
828
- <BriefSelectionChrome
829
- wizard={wizard}
830
- header={{
831
- title: "Daily brief",
832
- unreadCount: totalUnseen,
833
- footer: isDesktop ? <KeyboardHintBar /> : undefined,
834
- searchFilter: searchFilterConfig,
835
- searchResults,
836
- searchLoading: isLoading || searchFetching,
837
- relatedResults,
838
- relatedLoading,
839
- onSelectSearchResult,
840
- // The body already narrows to the committed query
841
- // (`matchesBriefSearch` + `matchesSearchTokens` + the server `query`,
842
- // above), so a committed search is a selectable list here exactly as
843
- // it is on the mailbox route (#212) — the two-engine panel stays for
844
- // the typing/uncommitted state only.
845
- searchResultsInBody: true,
846
- }}
847
- rows={filteredRows}
766
+ <FilterPanelProvider hasSheet={showsRows && !searching}>
767
+ <ThreadListInteraction
768
+ selectedMessageId={selectedMessageId}
769
+ onOpen={(id, options) => onSelectMessage?.(id, options)}
770
+ onDeleteMessages={onDeleteMessages}
771
+ onSelectionVerb={wizard.start}
772
+ wizardOpen={wizard.isOpen}
773
+ commandsRef={commandsRef}
774
+ onTriageContextChange={onTriageContextChange}
848
775
  >
849
- <div className="flex h-full flex-col">
850
- {failedAccounts.map((account) => (
851
- <ErrorBanner
852
- key={account.accountId}
853
- accountEmail={account.email}
854
- accountId={account.accountId}
855
- />
856
- ))}
857
- <div className="min-h-0 flex-1">{stateBody}</div>
858
- </div>
859
- </BriefSelectionChrome>
860
- </ThreadListInteraction>
776
+ <BriefSelectionChrome
777
+ wizard={wizard}
778
+ header={{
779
+ title: "Daily brief",
780
+ unreadCount: totalUnseen,
781
+ footer: isDesktop ? <KeyboardHintBar /> : undefined,
782
+ searchFilter: filterConfig,
783
+ searchResults,
784
+ searchLoading: isLoading || searchFetching,
785
+ relatedResults,
786
+ relatedLoading,
787
+ onSelectSearchResult,
788
+ // The body already narrows to the committed query
789
+ // (`matchesBriefSearch` + `matchesSearchTokens` + the server `query`,
790
+ // above), so a committed search is a selectable list here exactly as
791
+ // it is on the mailbox route (#212) — the two-engine panel stays for
792
+ // the typing/uncommitted state only.
793
+ searchResultsInBody: true,
794
+ }}
795
+ rows={filteredRows}
796
+ >
797
+ <div className="flex h-full flex-col">
798
+ {failedAccounts.map((account) => (
799
+ <ErrorBanner
800
+ key={account.accountId}
801
+ accountEmail={account.email}
802
+ accountId={account.accountId}
803
+ />
804
+ ))}
805
+ <div className="min-h-0 flex-1">{stateBody}</div>
806
+ </div>
807
+ </BriefSelectionChrome>
808
+ </ThreadListInteraction>
809
+ </FilterPanelProvider>
861
810
  );
862
811
  }
@@ -440,7 +440,7 @@ export function MailListHeader({
440
440
  <span className="shrink-0 text-2xs text-fg-subtle">
441
441
  {unreadCount.toLocaleString()} unread
442
442
  </span>
443
- {!hasQuery && <FilterToggle />}
443
+ <FilterToggle />
444
444
  </>
445
445
  ),
446
446
  searchSlot: ownsSearch && !searchExpanded && (
@@ -492,7 +492,6 @@ export function MailListHeader({
492
492
  chromeResults,
493
493
  makeFilterAction,
494
494
  searchConversion,
495
- hasQuery,
496
495
  ],
497
496
  );
498
497
 
@@ -13,9 +13,9 @@
13
13
  * time. The selection state is held here and survives, so clearing the query
14
14
  * restores the sheet exactly as it was.
15
15
  *
16
- * The daily brief no longer uses this: it composes `MailListHeader` with the kit
17
- * `BriefSections`, which owns its own filter row (so there is exactly one filter
18
- * surface and the section headers flatten correctly when filtered).
16
+ * The daily brief composes the same three parts itself provider, header,
17
+ * sheet around the kit `BriefSections`, whose sheet narrows the grouped
18
+ * sections and flattens them when scoped to one category.
19
19
  */
20
20
  import {
21
21
  FilterPanelProvider,
@@ -102,7 +102,7 @@ export function MailViewChrome({
102
102
  };
103
103
 
104
104
  return (
105
- <FilterPanelProvider>
105
+ <FilterPanelProvider hasSheet={!searching}>
106
106
  <MailListHeader
107
107
  title={title}
108
108
  unreadCount={unreadCount}
@@ -2,10 +2,14 @@ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/type
2
2
  import {
3
3
  Banner,
4
4
  type Density,
5
+ deriveIsMultiSelectMode,
5
6
  type MessageListFilter,
6
7
  MessageListLoadingMore,
7
8
  MessageListPane,
9
+ nextFocusId,
10
+ type SelectionModifiers,
8
11
  SelectionTopBar,
12
+ useSelection,
9
13
  type Verb,
10
14
  } from "@remit/ui";
11
15
  import { useBlocker, useNavigate } from "@tanstack/react-router";
@@ -26,11 +30,6 @@ import { useFollowFocusOpen } from "@/hooks/useFollowFocusOpen";
26
30
  import { useLabelList } from "@/hooks/useLabels";
27
31
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
28
32
  import { useIsDesktop } from "@/hooks/useMediaQuery";
29
- import {
30
- nextFocusId,
31
- type SelectionModifiers,
32
- useSelection,
33
- } from "@/hooks/useSelection";
34
33
  import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
35
34
  import {
36
35
  type BulkActionKind,
@@ -53,10 +52,7 @@ import {
53
52
  } from "@/lib/format";
54
53
  import { tabStopId } from "@/lib/list-focus";
55
54
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
56
- import {
57
- deriveIsMultiSelectMode,
58
- shouldExitSelectionOnNavigate,
59
- } from "@/lib/selection-mode";
55
+ import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
60
56
  import { cn } from "@/lib/utils";
61
57
  import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
62
58
  import { LabelApplyTrigger } from "./LabelApplyTrigger";
@@ -6,9 +6,13 @@
6
6
  * interactive lives in `MessageRow`, which the brief and Flagged render too.
7
7
  */
8
8
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
9
- import type { Density, SenderTrustLevel, ThreadRowData } from "@remit/ui";
9
+ import type {
10
+ Density,
11
+ SelectionModifiers,
12
+ SenderTrustLevel,
13
+ ThreadRowData,
14
+ } from "@remit/ui";
10
15
  import { memo } from "react";
11
- import type { SelectionModifiers } from "@/hooks/useSelection";
12
16
  import { toDisplayCategory } from "@/lib/display-category";
13
17
  import { formatEmailDate } from "@/lib/format";
14
18
  import { MessageRow } from "./MessageRow";
@@ -18,17 +18,18 @@ import {
18
18
  compactRowClass,
19
19
  type Density,
20
20
  mergeProps,
21
+ modifiersOf,
21
22
  type RowToggleEvent,
23
+ type SelectionModifiers,
22
24
  type ThreadRowData,
23
25
  useLongPress,
24
26
  } from "@remit/ui";
25
27
  import { useQueryClient } from "@tanstack/react-query";
26
28
  import { Link } from "@tanstack/react-router";
27
29
  import { type MouseEvent, memo, type ReactNode, useCallback } from "react";
28
- import type { SelectionModifiers } from "@/hooks/useSelection";
29
30
  import { cn } from "@/lib/utils";
30
31
  import { useThreadRowInteraction } from "./ThreadListInteraction";
31
- import { modifiersOf, useModifierSelect } from "./useModifierSelect";
32
+ import { useModifierSelect } from "./useModifierSelect";
32
33
 
33
34
  interface MailboxLinkSearch {
34
35
  selectedMessageId?: string;
@@ -1,13 +1,13 @@
1
1
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
2
2
  import {
3
3
  type Density,
4
+ type SelectionModifiers,
4
5
  SwipeableRow,
5
6
  type SwipePeek,
6
7
  type ThreadRowData,
7
8
  } from "@remit/ui";
8
9
  import { Link } from "@tanstack/react-router";
9
10
  import { useCallback, useState } from "react";
10
- import type { SelectionModifiers } from "@/hooks/useSelection";
11
11
  import { toDisplayCategory } from "@/lib/display-category";
12
12
  import { formatEmailDate } from "@/lib/format";
13
13
  import { MessageListItem } from "./MessageListItem";
@@ -14,10 +14,10 @@
14
14
 
15
15
  import assert from "node:assert/strict";
16
16
  import { afterEach, describe, it } from "node:test";
17
+ import type { SelectionModifiers } from "@remit/ui";
17
18
  import { createElement, type MouseEvent } from "react";
18
- import type { SelectionModifiers } from "@/hooks/useSelection";
19
19
  import { createDomHarness, type DomHarness } from "../../test-support/dom";
20
- import { isModified, useModifierSelect } from "./useModifierSelect";
20
+ import { useModifierSelect } from "./useModifierSelect";
21
21
 
22
22
  let harness: DomHarness | undefined;
23
23
 
@@ -221,27 +221,3 @@ describe("useModifierSelect", () => {
221
221
  assert.equal(press(dom, "contextmenu", { ctrlKey: true }), false);
222
222
  });
223
223
  });
224
-
225
- describe("isModified", () => {
226
- it("is true for any of the three keys a keyboard can add", () => {
227
- assert.equal(
228
- isModified({ shiftKey: true, metaKey: false, ctrlKey: false }),
229
- true,
230
- );
231
- assert.equal(
232
- isModified({ shiftKey: false, metaKey: true, ctrlKey: false }),
233
- true,
234
- );
235
- assert.equal(
236
- isModified({ shiftKey: false, metaKey: false, ctrlKey: true }),
237
- true,
238
- );
239
- });
240
-
241
- it("is false for the bare press a tap delivers", () => {
242
- assert.equal(
243
- isModified({ shiftKey: false, metaKey: false, ctrlKey: false }),
244
- false,
245
- );
246
- });
247
- });
@@ -16,17 +16,8 @@
16
16
  * `preventDefault` is already too late there. `claimClick` still handles the
17
17
  * click for engines that deliver it that way.
18
18
  */
19
+ import { isModified, modifiersOf, type SelectionModifiers } from "@remit/ui";
19
20
  import { type MouseEvent, useCallback, useRef } from "react";
20
- import type { SelectionModifiers } from "@/hooks/useSelection";
21
-
22
- export const modifiersOf = (e: MouseEvent): SelectionModifiers => ({
23
- shiftKey: e.shiftKey,
24
- metaKey: e.metaKey,
25
- ctrlKey: e.ctrlKey,
26
- });
27
-
28
- export const isModified = (m: SelectionModifiers): boolean =>
29
- m.shiftKey || m.metaKey || m.ctrlKey;
30
21
 
31
22
  export interface ModifierSelect {
32
23
  /** Takes a modified press for selection before the browser acts on it. */
@@ -13,13 +13,14 @@
13
13
  * `cursorMovedByPointerRef` records whether the last move came from a click, so
14
14
  * a list that scrolls its cursor into view can skip doing so for pointer moves.
15
15
  */
16
- import { useCallback, useMemo, useRef, useState } from "react";
17
16
  import {
17
+ deriveIsMultiSelectMode,
18
18
  nextFocusId,
19
+ rowSelectIntent,
19
20
  type SelectionModifiers,
20
21
  useSelection,
21
- } from "@/hooks/useSelection";
22
- import { deriveIsMultiSelectMode } from "@/lib/selection-mode";
22
+ } from "@remit/ui";
23
+ import { useCallback, useMemo, useRef, useState } from "react";
23
24
 
24
25
  interface UseListCursorOptions {
25
26
  /** Message ids in display order. */
@@ -178,14 +179,15 @@ export const useListCursor = ({
178
179
 
179
180
  const handleRowSelect = useCallback(
180
181
  (messageId: string, modifiers: SelectionModifiers): boolean => {
181
- if (modifiers.shiftKey) {
182
+ const intent = rowSelectIntent(modifiers);
183
+ if (intent === "range") {
182
184
  // The open/focused row is the fallback origin when the stored anchor
183
185
  // has been filtered or searched out of the visible list, so the first
184
186
  // shift-click still ranges from where the user is (#142, #144).
185
187
  selectRange(orderedIds, messageId, focusedMessageId);
186
188
  return true;
187
189
  }
188
- if (modifiers.metaKey || modifiers.ctrlKey) {
190
+ if (intent === "toggle") {
189
191
  toggleCheck(messageId);
190
192
  return true;
191
193
  }
@@ -1,30 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, test } from "node:test";
3
- import {
4
- deriveIsMultiSelectMode,
5
- shouldExitSelectionOnNavigate,
6
- } from "./selection-mode.js";
7
-
8
- describe("deriveIsMultiSelectMode", () => {
9
- test("no selection is not multi-select mode", () => {
10
- assert.equal(deriveIsMultiSelectMode(0, false), false);
11
- });
12
-
13
- test("any selection on touch is multi-select mode", () => {
14
- assert.equal(deriveIsMultiSelectMode(1, false), true);
15
- assert.equal(deriveIsMultiSelectMode(42, false), true);
16
- });
17
-
18
- test("desktop selection drives the desktop toolbar, not multi-select mode", () => {
19
- assert.equal(deriveIsMultiSelectMode(1, true), false);
20
- assert.equal(deriveIsMultiSelectMode(0, true), false);
21
- });
22
-
23
- test("dropping the last selected id leaves the mode in the same call", () => {
24
- assert.equal(deriveIsMultiSelectMode(1, false), true);
25
- assert.equal(deriveIsMultiSelectMode(0, false), false);
26
- });
27
- });
3
+ import { shouldExitSelectionOnNavigate } from "./selection-mode.js";
28
4
 
29
5
  describe("shouldExitSelectionOnNavigate", () => {
30
6
  test("back while selecting exits selection instead of navigating", () => {
@@ -8,18 +8,6 @@ import type { StepId } from "@remit/ui";
8
8
  /** The subset of `@tanstack/history`'s `HistoryAction` a blocker can see. */
9
9
  export type NavigationAction = "PUSH" | "REPLACE" | "FORWARD" | "BACK" | "GO";
10
10
 
11
- /**
12
- * Whether the list is in multi-select mode: a function of the selection count,
13
- * never a stored flag, so the two can never disagree. Multi-select is the
14
- * touch affordance (long press, always-visible checkboxes, the selection top
15
- * bar); on desktop a selection drives the desktop toolbar instead and rows
16
- * keep their ordinary hover behaviour.
17
- */
18
- export const deriveIsMultiSelectMode = (
19
- selectedCount: number,
20
- isDesktop: boolean,
21
- ): boolean => !isDesktop && selectedCount > 0;
22
-
23
11
  /**
24
12
  * Whether a history navigation should exit selection mode instead of leaving
25
13
  * the route. Only the back gesture is intercepted, so a navigation the app
@@ -1,244 +0,0 @@
1
- import assert from "node:assert";
2
- import { describe, test } from "node:test";
3
- import {
4
- computeRange,
5
- intersectSelectedIds,
6
- nextFocusId,
7
- resolveRangeAnchor,
8
- } from "./useSelection.js";
9
-
10
- const ids = ["a", "b", "c", "d", "e"];
11
-
12
- // Mirrors the hook's `selectRange` state transition (anchor + selection) using
13
- // the same pure helpers it runs, so the mouse/keyboard sequences below exercise
14
- // the real logic without an interactive DOM. Returns the next {selected, anchor}.
15
- const applyRange = (
16
- state: { selected: Set<string>; anchor: string | undefined },
17
- orderedIds: string[],
18
- targetId: string,
19
- fallbackAnchor?: string,
20
- ): { selected: Set<string>; anchor: string | undefined } => {
21
- const anchor = resolveRangeAnchor(
22
- orderedIds,
23
- state.anchor,
24
- fallbackAnchor,
25
- targetId,
26
- );
27
- const range = computeRange(orderedIds, anchor, targetId);
28
- const selected = new Set(state.selected);
29
- for (const id of range) selected.add(id);
30
- return { selected, anchor };
31
- };
32
-
33
- // Mirrors a cmd/ctrl-click: toggle membership and re-anchor on the clicked row.
34
- const applyToggle = (
35
- state: { selected: Set<string>; anchor: string | undefined },
36
- targetId: string,
37
- ): { selected: Set<string>; anchor: string | undefined } => {
38
- const selected = new Set(state.selected);
39
- if (selected.has(targetId)) selected.delete(targetId);
40
- else selected.add(targetId);
41
- return { selected, anchor: targetId };
42
- };
43
-
44
- describe("computeRange", () => {
45
- test("forward range: anchor above target selects the inclusive slice", () => {
46
- assert.deepStrictEqual(computeRange(ids, "b", "d"), ["b", "c", "d"]);
47
- });
48
-
49
- test("backward range: target above anchor selects the same inclusive slice", () => {
50
- assert.deepStrictEqual(computeRange(ids, "d", "b"), ["b", "c", "d"]);
51
- });
52
-
53
- test("anchor equals target selects just that one id", () => {
54
- assert.deepStrictEqual(computeRange(ids, "c", "c"), ["c"]);
55
- });
56
-
57
- test("no anchor selects just the target", () => {
58
- assert.deepStrictEqual(computeRange(ids, undefined, "c"), ["c"]);
59
- });
60
-
61
- test("anchor not present in the list selects just the target", () => {
62
- assert.deepStrictEqual(computeRange(ids, "zzz", "c"), ["c"]);
63
- });
64
-
65
- test("full span from first to last includes every id in order", () => {
66
- assert.deepStrictEqual(computeRange(ids, "a", "e"), [
67
- "a",
68
- "b",
69
- "c",
70
- "d",
71
- "e",
72
- ]);
73
- });
74
-
75
- test("target not present in the list selects nothing", () => {
76
- assert.deepStrictEqual(computeRange(ids, "b", "zzz"), []);
77
- });
78
- });
79
-
80
- describe("resolveRangeAnchor", () => {
81
- test("keeps a still-visible stored anchor so consecutive shift-clicks extend from it", () => {
82
- assert.strictEqual(resolveRangeAnchor(ids, "b", undefined, "d"), "b");
83
- });
84
-
85
- test("a stored anchor no longer visible falls back to the open/focused row", () => {
86
- // The stored anchor "z" was filtered/searched out of the visible list; the
87
- // open row "b" is still visible, so the range anchors there.
88
- assert.strictEqual(resolveRangeAnchor(ids, "z", "b", "d"), "b");
89
- });
90
-
91
- test("with neither a visible stored anchor nor a visible fallback, the target anchors itself", () => {
92
- assert.strictEqual(resolveRangeAnchor(ids, "z", "y", "d"), "d");
93
- });
94
-
95
- test("no stored anchor and no fallback selects just the target", () => {
96
- assert.strictEqual(resolveRangeAnchor(ids, undefined, undefined, "c"), "c");
97
- });
98
-
99
- test("a stored anchor beats the fallback while it stays visible", () => {
100
- assert.strictEqual(resolveRangeAnchor(ids, "a", "c", "e"), "a");
101
- });
102
- });
103
-
104
- describe("shift-click range over a filtered/search-narrowed list (#142, #144)", () => {
105
- test("shift-click range with an active filter builds the range within the visible rows", () => {
106
- // Full inbox is a..h; the "Automated" filter leaves only these rows.
107
- const filtered = ["b", "d", "f", "g"];
108
- // The open row "d" is visible; nothing selected yet, no stored anchor.
109
- let state = {
110
- selected: new Set<string>(),
111
- anchor: undefined as string | undefined,
112
- };
113
- // Shift-click "g": ranges from the open/focused row "d" to "g".
114
- state = applyRange(state, filtered, "g", "d");
115
- assert.deepStrictEqual([...state.selected], ["d", "f", "g"]);
116
- assert.strictEqual(state.anchor, "d");
117
- });
118
-
119
- test("range where the anchor left the visible set re-anchors instead of no-oping", () => {
120
- const filtered = ["b", "d", "f", "g"];
121
- // Stored anchor "a" was selected before the filter narrowed the list and is
122
- // no longer visible; there is no open/focused fallback row.
123
- let state = {
124
- selected: new Set<string>(),
125
- anchor: "a" as string | undefined,
126
- };
127
- // First shift-click adopts the clicked row as the new visible anchor.
128
- state = applyRange(state, filtered, "f");
129
- assert.deepStrictEqual([...state.selected], ["f"]);
130
- assert.strictEqual(state.anchor, "f");
131
- // Second shift-click now builds a real range from that adopted anchor.
132
- state = applyRange(state, filtered, "b");
133
- assert.deepStrictEqual([...state.selected].sort(), ["b", "d", "f"]);
134
- assert.strictEqual(state.anchor, "f");
135
- });
136
-
137
- test("multi-select across a search-results list: cmd-click then shift-click", () => {
138
- // Search "npm" yields these results; the inbox anchor is gone from this set.
139
- const results = ["m1", "m2", "m3", "m4", "m5"];
140
- let state = {
141
- selected: new Set<string>(),
142
- anchor: "inbox-row" as string | undefined,
143
- };
144
- // Cmd-click "m2": toggles it in and re-anchors on it (a visible row).
145
- state = applyToggle(state, "m2");
146
- assert.deepStrictEqual([...state.selected], ["m2"]);
147
- assert.strictEqual(state.anchor, "m2");
148
- // Shift-click "m4": ranges from the cmd-clicked anchor across the results.
149
- state = applyRange(state, results, "m4");
150
- assert.deepStrictEqual([...state.selected].sort(), ["m2", "m3", "m4"]);
151
- assert.strictEqual(state.anchor, "m2");
152
- });
153
- });
154
-
155
- describe("nextFocusId", () => {
156
- test("moves down one row", () => {
157
- assert.strictEqual(nextFocusId(ids, "b", 1), "c");
158
- });
159
-
160
- test("moves up one row", () => {
161
- assert.strictEqual(nextFocusId(ids, "c", -1), "b");
162
- });
163
-
164
- test("clamps at the bottom (no wrap)", () => {
165
- assert.strictEqual(nextFocusId(ids, "e", 1), "e");
166
- });
167
-
168
- test("clamps at the top (no wrap)", () => {
169
- assert.strictEqual(nextFocusId(ids, "a", -1), "a");
170
- });
171
-
172
- test("no focus + down starts at the first row", () => {
173
- assert.strictEqual(nextFocusId(ids, undefined, 1), "a");
174
- });
175
-
176
- test("no focus + up starts at the last row", () => {
177
- assert.strictEqual(nextFocusId(ids, undefined, -1), "e");
178
- });
179
-
180
- test("focus not in the list + down starts at the first row", () => {
181
- assert.strictEqual(nextFocusId(ids, "zzz", 1), "a");
182
- });
183
-
184
- test("empty list returns undefined", () => {
185
- assert.strictEqual(nextFocusId([], "a", 1), undefined);
186
- });
187
- });
188
-
189
- describe("intersectSelectedIds", () => {
190
- test("a refresh that drops some selected ids and adds new rows keeps only the survivors (#111)", () => {
191
- // Regression for #111: the effect used to clear the WHOLE selection the
192
- // moment any single selected id left the list. Here "b" leaves (deleted
193
- // elsewhere) while "f" arrives (new mail) — "a" and "c" must survive.
194
- const selected = new Set(["a", "b", "c"]);
195
- const refreshedThreadIds = ["a", "c", "d", "f"];
196
- assert.deepStrictEqual(
197
- intersectSelectedIds(selected, refreshedThreadIds),
198
- new Set(["a", "c"]),
199
- );
200
- });
201
-
202
- test("never adds an id that wasn't already selected", () => {
203
- const selected = new Set(["a"]);
204
- assert.deepStrictEqual(
205
- intersectSelectedIds(selected, ["a", "b", "c"]),
206
- new Set(["a"]),
207
- );
208
- });
209
-
210
- test("every selected id surviving is a no-op (same members, not just same size)", () => {
211
- const selected = new Set(["a", "b"]);
212
- assert.deepStrictEqual(
213
- intersectSelectedIds(selected, ["a", "b", "z"]),
214
- new Set(["a", "b"]),
215
- );
216
- });
217
-
218
- test("a post-delete retry selection survives a refetch that still contains it, minus what actually left", () => {
219
- // Mirrors processRunOutcome materializing the failed ids as the new
220
- // selection, then the cache-invalidation refetch running this same
221
- // intersection against the freshly reloaded `threads`. One retry id
222
- // ("fail-2") is momentarily missing from the refreshed page; the other
223
- // two must stay selected so the Retry notice (gated on
224
- // `selectedCount > 0`) doesn't disappear with it.
225
- const retrySelection = new Set(["fail-1", "fail-2", "fail-3"]);
226
- const refetchedThreadIds = ["fail-1", "fail-3", "unrelated-1"];
227
- assert.deepStrictEqual(
228
- intersectSelectedIds(retrySelection, refetchedThreadIds),
229
- new Set(["fail-1", "fail-3"]),
230
- );
231
- });
232
-
233
- test("everything in the selection leaving empties it, rather than leaving stale ids behind", () => {
234
- const selected = new Set(["a", "b"]);
235
- assert.deepStrictEqual(intersectSelectedIds(selected, ["z"]), new Set());
236
- });
237
-
238
- test("an empty selection stays empty", () => {
239
- assert.deepStrictEqual(
240
- intersectSelectedIds(new Set(), ["a", "b"]),
241
- new Set(),
242
- );
243
- });
244
- });
@@ -1,292 +0,0 @@
1
- import { useCallback, useState } from "react";
2
-
3
- /**
4
- * Mouse/keyboard modifier flags read off a row click, used to drive desktop
5
- * multi-select semantics (shift = range, cmd/ctrl = toggle, plain = open).
6
- */
7
- export interface SelectionModifiers {
8
- shiftKey: boolean;
9
- metaKey: boolean;
10
- ctrlKey: boolean;
11
- }
12
-
13
- interface UseSelectionOptions<T> {
14
- /** Function to extract ID from an item */
15
- getId: (item: T) => string;
16
- }
17
-
18
- interface UseSelectionReturn {
19
- /** Set of currently selected item IDs */
20
- selectedIds: Set<string>;
21
- /** Number of selected items */
22
- selectedCount: number;
23
- /** Whether any items are selected */
24
- hasSelection: boolean;
25
- /** Check if a specific item is selected */
26
- isSelected: (id: string) => boolean;
27
- /** Toggle selection for a single item (updates the range anchor) */
28
- toggle: (id: string) => void;
29
- /** Select a single item (adds to selection, updates the range anchor) */
30
- select: (id: string) => void;
31
- /** Deselect a single item */
32
- deselect: (id: string) => void;
33
- /** Select all items */
34
- selectAll: (ids: string[]) => void;
35
- /** Clear all selections (also clears the range anchor) */
36
- clearSelection: () => void;
37
- /** Toggle selection for all items */
38
- toggleAll: (ids: string[]) => void;
39
- /**
40
- * Add the contiguous range of ids from the anchor to `targetId` (inclusive)
41
- * to the selection, using `orderedIds` for display order. The anchor is the
42
- * stored one when it is still visible in `orderedIds`; otherwise
43
- * `fallbackAnchor` when that is visible (the open/focused row); otherwise
44
- * `targetId`. Whatever anchors the range becomes the new stored anchor, so a
45
- * filtered or search-narrowed list can still build a range within what's
46
- * visible (#142, #144).
47
- */
48
- selectRange: (
49
- orderedIds: string[],
50
- targetId: string,
51
- fallbackAnchor?: string,
52
- ) => void;
53
- /**
54
- * Set the range anchor without changing the selection set. Used by a plain
55
- * click that navigates but should seed the anchor for a later shift-click.
56
- */
57
- setAnchor: (id: string) => void;
58
- /**
59
- * The id of the row that anchors shift-range selection. `undefined` when
60
- * nothing has been selected yet.
61
- */
62
- anchorId: string | undefined;
63
- /**
64
- * Narrows the selection to whatever in it is still present in `currentIds`
65
- * — drops ids that left, keeps every survivor. Never adds anything, and
66
- * never clears the selection just because one id is gone (#111).
67
- */
68
- intersectWith: (currentIds: readonly string[]) => void;
69
- }
70
-
71
- /**
72
- * Compute the inclusive slice of ids spanning from `anchorId` to `targetId`
73
- * in `orderedIds`. Pure so it can be unit-tested without a DOM.
74
- *
75
- * - Direction-agnostic: works whether the target sits above or below the anchor.
76
- * - Missing anchor (or anchor not in the list): returns just `[targetId]`.
77
- * - Target not in the list: returns `[]` (nothing to select).
78
- */
79
- export const computeRange = (
80
- orderedIds: string[],
81
- anchorId: string | undefined,
82
- targetId: string,
83
- ): string[] => {
84
- const targetIndex = orderedIds.indexOf(targetId);
85
- if (targetIndex === -1) return [];
86
-
87
- const anchorIndex =
88
- anchorId === undefined ? -1 : orderedIds.indexOf(anchorId);
89
- if (anchorIndex === -1) return [targetId];
90
-
91
- const start = Math.min(anchorIndex, targetIndex);
92
- const end = Math.max(anchorIndex, targetIndex);
93
- return orderedIds.slice(start, end + 1);
94
- };
95
-
96
- /**
97
- * Resolve which id a shift-range selection anchors from, given the stored
98
- * anchor and the ids currently visible (`orderedIds`). Pure so the
99
- * filtered/search anchor behavior can be unit-tested without a DOM.
100
- *
101
- * - The stored anchor wins while it is still visible — consecutive shift-clicks
102
- * keep extending from the same origin (Apple Mail / Gmail).
103
- * - Once the stored anchor leaves the visible set (filtered out, or a search
104
- * changed the list), it can't anchor a range in that set, so fall back to
105
- * `fallbackAnchor` (the open/focused row) when it is visible.
106
- * - With neither available, the target anchors itself: the clicked row is
107
- * selected alone and becomes the origin for the next shift-click.
108
- */
109
- export const resolveRangeAnchor = (
110
- orderedIds: string[],
111
- storedAnchor: string | undefined,
112
- fallbackAnchor: string | undefined,
113
- targetId: string,
114
- ): string => {
115
- if (storedAnchor !== undefined && orderedIds.includes(storedAnchor)) {
116
- return storedAnchor;
117
- }
118
- if (fallbackAnchor !== undefined && orderedIds.includes(fallbackAnchor)) {
119
- return fallbackAnchor;
120
- }
121
- return targetId;
122
- };
123
-
124
- /**
125
- * The ids from `selectedIds` that are still present in `currentIds` — the
126
- * survivor set after a list refresh. Only ever narrows: an id absent from
127
- * `selectedIds` is never added just because it's in `currentIds`. Pure so the
128
- * "drop what left, keep the rest" behavior (K-9's `selected.intersect
129
- * (uniqueIds)`, cited by #92 D2) can be unit-tested without a DOM.
130
- */
131
- export const intersectSelectedIds = (
132
- selectedIds: ReadonlySet<string>,
133
- currentIds: readonly string[],
134
- ): Set<string> => {
135
- const present = new Set(currentIds);
136
- const next = new Set<string>();
137
- for (const id of selectedIds) {
138
- if (present.has(id)) next.add(id);
139
- }
140
- return next;
141
- };
142
-
143
- /**
144
- * Compute the id one step from `focusId` in `orderedIds`, clamped at the ends.
145
- * Pure so the shift-arrow range-extend math can be unit-tested without a DOM.
146
- *
147
- * - `direction` is -1 for up (previous) or +1 for down (next).
148
- * - Missing focus (or focus not in the list): returns the first id for down,
149
- * the last id for up, or `undefined` when the list is empty.
150
- * - At a boundary: returns the same `focusId` (no wrap).
151
- */
152
- export const nextFocusId = (
153
- orderedIds: string[],
154
- focusId: string | undefined,
155
- direction: -1 | 1,
156
- ): string | undefined => {
157
- if (orderedIds.length === 0) return undefined;
158
-
159
- const currentIndex = focusId === undefined ? -1 : orderedIds.indexOf(focusId);
160
- if (currentIndex === -1) {
161
- return direction > 0 ? orderedIds[0] : orderedIds[orderedIds.length - 1];
162
- }
163
-
164
- const nextIndex = Math.min(
165
- Math.max(currentIndex + direction, 0),
166
- orderedIds.length - 1,
167
- );
168
- return orderedIds[nextIndex];
169
- };
170
-
171
- /**
172
- * Hook for managing selection state in lists.
173
- * Supports single and multi-select operations.
174
- */
175
- export const useSelection = <T>(
176
- _options?: UseSelectionOptions<T>,
177
- ): UseSelectionReturn => {
178
- const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
179
- const [anchorId, setAnchorId] = useState<string | undefined>(undefined);
180
-
181
- const isSelected = useCallback(
182
- (id: string) => selectedIds.has(id),
183
- [selectedIds],
184
- );
185
-
186
- const toggle = useCallback((id: string) => {
187
- setAnchorId(id);
188
- setSelectedIds((prev) => {
189
- const next = new Set(prev);
190
- if (next.has(id)) {
191
- next.delete(id);
192
- } else {
193
- next.add(id);
194
- }
195
- return next;
196
- });
197
- }, []);
198
-
199
- const select = useCallback((id: string) => {
200
- setAnchorId(id);
201
- setSelectedIds((prev) => {
202
- if (prev.has(id)) return prev;
203
- const next = new Set(prev);
204
- next.add(id);
205
- return next;
206
- });
207
- }, []);
208
-
209
- const deselect = useCallback((id: string) => {
210
- setSelectedIds((prev) => {
211
- if (!prev.has(id)) return prev;
212
- const next = new Set(prev);
213
- next.delete(id);
214
- return next;
215
- });
216
- }, []);
217
-
218
- const selectAll = useCallback((ids: string[]) => {
219
- setSelectedIds(new Set(ids));
220
- }, []);
221
-
222
- const clearSelection = useCallback(() => {
223
- setAnchorId(undefined);
224
- setSelectedIds(new Set());
225
- }, []);
226
-
227
- const toggleAll = useCallback((ids: string[]) => {
228
- setSelectedIds((prev) => {
229
- const allSelected = ids.every((id) => prev.has(id));
230
- return allSelected ? new Set() : new Set(ids);
231
- });
232
- }, []);
233
-
234
- const setAnchor = useCallback((id: string) => {
235
- setAnchorId(id);
236
- }, []);
237
-
238
- // Bails out to the same `prev` reference when nothing was dropped, so a
239
- // caller can run this on every list refresh (e.g. an effect keyed on
240
- // `threads`) without forcing a render each time.
241
- const intersectWith = useCallback((currentIds: readonly string[]) => {
242
- setSelectedIds((prev) => {
243
- if (prev.size === 0) return prev;
244
- const next = intersectSelectedIds(prev, currentIds);
245
- return next.size === prev.size ? prev : next;
246
- });
247
- }, []);
248
-
249
- const selectRange = useCallback(
250
- (orderedIds: string[], targetId: string, fallbackAnchor?: string) => {
251
- const effectiveAnchor = resolveRangeAnchor(
252
- orderedIds,
253
- anchorId,
254
- fallbackAnchor,
255
- targetId,
256
- );
257
- setSelectedIds((prev) => {
258
- const range = computeRange(orderedIds, effectiveAnchor, targetId);
259
- if (range.length === 0) return prev;
260
- const next = new Set(prev);
261
- for (const id of range) {
262
- next.add(id);
263
- }
264
- return next;
265
- });
266
- // Whatever anchored the range becomes the stored anchor. A still-visible
267
- // stored anchor resolves to itself (unchanged), so consecutive
268
- // shift-clicks keep extending from the same origin; a stored anchor that
269
- // left the visible set is replaced by the row the range actually used, so
270
- // a filtered/search-narrowed list can build a range within what's visible.
271
- setAnchorId(effectiveAnchor);
272
- },
273
- [anchorId],
274
- );
275
-
276
- return {
277
- selectedIds,
278
- selectedCount: selectedIds.size,
279
- hasSelection: selectedIds.size > 0,
280
- isSelected,
281
- toggle,
282
- select,
283
- deselect,
284
- selectAll,
285
- clearSelection,
286
- toggleAll,
287
- selectRange,
288
- setAnchor,
289
- anchorId,
290
- intersectWith,
291
- };
292
- };