@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.
@@ -0,0 +1,334 @@
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
+ /** The selection modifiers a mouse event carried, on their own. */
14
+ export const modifiersOf = (event: SelectionModifiers): SelectionModifiers => ({
15
+ shiftKey: event.shiftKey,
16
+ metaKey: event.metaKey,
17
+ ctrlKey: event.ctrlKey,
18
+ });
19
+
20
+ export const isModified = (modifiers: SelectionModifiers): boolean =>
21
+ modifiers.shiftKey || modifiers.metaKey || modifiers.ctrlKey;
22
+
23
+ /**
24
+ * What a click on a row asks for, given the modifiers it carried — the Apple
25
+ * Mail / Gmail model. One definition, so every surface that offers multi-select
26
+ * reads a modifier the same way.
27
+ */
28
+ export type RowSelectIntent = "range" | "toggle" | "open";
29
+
30
+ export const rowSelectIntent = (
31
+ modifiers: SelectionModifiers,
32
+ ): RowSelectIntent => {
33
+ if (modifiers.shiftKey) return "range";
34
+ if (modifiers.metaKey || modifiers.ctrlKey) return "toggle";
35
+ return "open";
36
+ };
37
+
38
+ /**
39
+ * Whether the list is in multi-select mode: a function of the selection count,
40
+ * never a stored flag, so the two can never disagree. Multi-select is the touch
41
+ * affordance (long press, always-visible checkboxes, the selection top bar); on
42
+ * desktop a selection drives the toolbar instead and rows keep their ordinary
43
+ * hover behaviour.
44
+ */
45
+ export const deriveIsMultiSelectMode = (
46
+ selectedCount: number,
47
+ isDesktop: boolean,
48
+ ): boolean => !isDesktop && selectedCount > 0;
49
+
50
+ export interface UseSelectionOptions {
51
+ /**
52
+ * Rows ticked on first render, for a surface that opens with a selection
53
+ * already made — a story or an SSR'd selection state.
54
+ */
55
+ initialSelectedIds?: readonly string[];
56
+ }
57
+
58
+ interface UseSelectionReturn {
59
+ /** Set of currently selected item IDs */
60
+ selectedIds: Set<string>;
61
+ /** Number of selected items */
62
+ selectedCount: number;
63
+ /** Whether any items are selected */
64
+ hasSelection: boolean;
65
+ /** Check if a specific item is selected */
66
+ isSelected: (id: string) => boolean;
67
+ /** Toggle selection for a single item (updates the range anchor) */
68
+ toggle: (id: string) => void;
69
+ /** Select a single item (adds to selection, updates the range anchor) */
70
+ select: (id: string) => void;
71
+ /** Deselect a single item */
72
+ deselect: (id: string) => void;
73
+ /** Select all items */
74
+ selectAll: (ids: string[]) => void;
75
+ /** Clear all selections (also clears the range anchor) */
76
+ clearSelection: () => void;
77
+ /** Toggle selection for all items */
78
+ toggleAll: (ids: string[]) => void;
79
+ /**
80
+ * Add the contiguous range of ids from the anchor to `targetId` (inclusive)
81
+ * to the selection, using `orderedIds` for display order. The anchor is the
82
+ * stored one when it is still visible in `orderedIds`; otherwise
83
+ * `fallbackAnchor` when that is visible (the open/focused row); otherwise
84
+ * `targetId`. Whatever anchors the range becomes the new stored anchor, so a
85
+ * filtered or search-narrowed list can still build a range within what's
86
+ * visible (#142, #144).
87
+ */
88
+ selectRange: (
89
+ orderedIds: string[],
90
+ targetId: string,
91
+ fallbackAnchor?: string,
92
+ ) => void;
93
+ /**
94
+ * Set the range anchor without changing the selection set. Used by a plain
95
+ * click that navigates but should seed the anchor for a later shift-click.
96
+ */
97
+ setAnchor: (id: string) => void;
98
+ /**
99
+ * The id of the row that anchors shift-range selection. `undefined` when
100
+ * nothing has been selected yet.
101
+ */
102
+ anchorId: string | undefined;
103
+ /**
104
+ * Narrows the selection to whatever in it is still present in `currentIds`
105
+ * — drops ids that left, keeps every survivor. Never adds anything, and
106
+ * never clears the selection just because one id is gone (#111).
107
+ */
108
+ intersectWith: (currentIds: readonly string[]) => void;
109
+ }
110
+
111
+ /**
112
+ * Compute the inclusive slice of ids spanning from `anchorId` to `targetId`
113
+ * in `orderedIds`. Pure so it can be unit-tested without a DOM.
114
+ *
115
+ * - Direction-agnostic: works whether the target sits above or below the anchor.
116
+ * - Missing anchor (or anchor not in the list): returns just `[targetId]`.
117
+ * - Target not in the list: returns `[]` (nothing to select).
118
+ */
119
+ export const computeRange = (
120
+ orderedIds: string[],
121
+ anchorId: string | undefined,
122
+ targetId: string,
123
+ ): string[] => {
124
+ const targetIndex = orderedIds.indexOf(targetId);
125
+ if (targetIndex === -1) return [];
126
+
127
+ const anchorIndex =
128
+ anchorId === undefined ? -1 : orderedIds.indexOf(anchorId);
129
+ if (anchorIndex === -1) return [targetId];
130
+
131
+ const start = Math.min(anchorIndex, targetIndex);
132
+ const end = Math.max(anchorIndex, targetIndex);
133
+ return orderedIds.slice(start, end + 1);
134
+ };
135
+
136
+ /**
137
+ * Resolve which id a shift-range selection anchors from, given the stored
138
+ * anchor and the ids currently visible (`orderedIds`). Pure so the
139
+ * filtered/search anchor behavior can be unit-tested without a DOM.
140
+ *
141
+ * - The stored anchor wins while it is still visible — consecutive shift-clicks
142
+ * keep extending from the same origin (Apple Mail / Gmail).
143
+ * - Once the stored anchor leaves the visible set (filtered out, or a search
144
+ * changed the list), it can't anchor a range in that set, so fall back to
145
+ * `fallbackAnchor` (the open/focused row) when it is visible.
146
+ * - With neither available, the target anchors itself: the clicked row is
147
+ * selected alone and becomes the origin for the next shift-click.
148
+ */
149
+ export const resolveRangeAnchor = (
150
+ orderedIds: string[],
151
+ storedAnchor: string | undefined,
152
+ fallbackAnchor: string | undefined,
153
+ targetId: string,
154
+ ): string => {
155
+ if (storedAnchor !== undefined && orderedIds.includes(storedAnchor)) {
156
+ return storedAnchor;
157
+ }
158
+ if (fallbackAnchor !== undefined && orderedIds.includes(fallbackAnchor)) {
159
+ return fallbackAnchor;
160
+ }
161
+ return targetId;
162
+ };
163
+
164
+ /**
165
+ * The ids from `selectedIds` that are still present in `currentIds` — the
166
+ * survivor set after a list refresh. Only ever narrows: an id absent from
167
+ * `selectedIds` is never added just because it's in `currentIds`. Pure so the
168
+ * "drop what left, keep the rest" behavior (K-9's `selected.intersect
169
+ * (uniqueIds)`, cited by #92 D2) can be unit-tested without a DOM.
170
+ */
171
+ export const intersectSelectedIds = (
172
+ selectedIds: ReadonlySet<string>,
173
+ currentIds: readonly string[],
174
+ ): Set<string> => {
175
+ const present = new Set(currentIds);
176
+ const next = new Set<string>();
177
+ for (const id of selectedIds) {
178
+ if (present.has(id)) next.add(id);
179
+ }
180
+ return next;
181
+ };
182
+
183
+ /**
184
+ * Compute the id one step from `focusId` in `orderedIds`, clamped at the ends.
185
+ * Pure so the shift-arrow range-extend math can be unit-tested without a DOM.
186
+ *
187
+ * - `direction` is -1 for up (previous) or +1 for down (next).
188
+ * - Missing focus (or focus not in the list): returns the first id for down,
189
+ * the last id for up, or `undefined` when the list is empty.
190
+ * - At a boundary: returns the same `focusId` (no wrap).
191
+ */
192
+ export const nextFocusId = (
193
+ orderedIds: string[],
194
+ focusId: string | undefined,
195
+ direction: -1 | 1,
196
+ ): string | undefined => {
197
+ if (orderedIds.length === 0) return undefined;
198
+
199
+ const currentIndex = focusId === undefined ? -1 : orderedIds.indexOf(focusId);
200
+ if (currentIndex === -1) {
201
+ return direction > 0 ? orderedIds[0] : orderedIds[orderedIds.length - 1];
202
+ }
203
+
204
+ const nextIndex = Math.min(
205
+ Math.max(currentIndex + direction, 0),
206
+ orderedIds.length - 1,
207
+ );
208
+ return orderedIds[nextIndex];
209
+ };
210
+
211
+ /**
212
+ * Hook for managing selection state in lists.
213
+ * Supports single and multi-select operations.
214
+ */
215
+ export const useSelection = (
216
+ options?: UseSelectionOptions,
217
+ ): UseSelectionReturn => {
218
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(
219
+ () => new Set(options?.initialSelectedIds),
220
+ );
221
+ const [anchorId, setAnchorId] = useState<string | undefined>(undefined);
222
+
223
+ const isSelected = useCallback(
224
+ (id: string) => selectedIds.has(id),
225
+ [selectedIds],
226
+ );
227
+
228
+ const toggle = useCallback((id: string) => {
229
+ setAnchorId(id);
230
+ setSelectedIds((prev) => {
231
+ const next = new Set(prev);
232
+ if (next.has(id)) {
233
+ next.delete(id);
234
+ } else {
235
+ next.add(id);
236
+ }
237
+ return next;
238
+ });
239
+ }, []);
240
+
241
+ const select = useCallback((id: string) => {
242
+ setAnchorId(id);
243
+ setSelectedIds((prev) => {
244
+ if (prev.has(id)) return prev;
245
+ const next = new Set(prev);
246
+ next.add(id);
247
+ return next;
248
+ });
249
+ }, []);
250
+
251
+ const deselect = useCallback((id: string) => {
252
+ setSelectedIds((prev) => {
253
+ if (!prev.has(id)) return prev;
254
+ const next = new Set(prev);
255
+ next.delete(id);
256
+ return next;
257
+ });
258
+ }, []);
259
+
260
+ const selectAll = useCallback((ids: string[]) => {
261
+ setSelectedIds(new Set(ids));
262
+ }, []);
263
+
264
+ const clearSelection = useCallback(() => {
265
+ setAnchorId(undefined);
266
+ setSelectedIds(new Set());
267
+ }, []);
268
+
269
+ const toggleAll = useCallback((ids: string[]) => {
270
+ setSelectedIds((prev) => {
271
+ const allSelected = ids.every((id) => prev.has(id));
272
+ return allSelected ? new Set() : new Set(ids);
273
+ });
274
+ }, []);
275
+
276
+ const setAnchor = useCallback((id: string) => {
277
+ setAnchorId(id);
278
+ }, []);
279
+
280
+ // Bails out to the same `prev` reference when nothing was dropped, so a
281
+ // caller can run this on every list refresh (e.g. an effect keyed on
282
+ // `threads`) without forcing a render each time.
283
+ const intersectWith = useCallback((currentIds: readonly string[]) => {
284
+ setSelectedIds((prev) => {
285
+ if (prev.size === 0) return prev;
286
+ const next = intersectSelectedIds(prev, currentIds);
287
+ return next.size === prev.size ? prev : next;
288
+ });
289
+ }, []);
290
+
291
+ const selectRange = useCallback(
292
+ (orderedIds: string[], targetId: string, fallbackAnchor?: string) => {
293
+ const effectiveAnchor = resolveRangeAnchor(
294
+ orderedIds,
295
+ anchorId,
296
+ fallbackAnchor,
297
+ targetId,
298
+ );
299
+ setSelectedIds((prev) => {
300
+ const range = computeRange(orderedIds, effectiveAnchor, targetId);
301
+ if (range.length === 0) return prev;
302
+ const next = new Set(prev);
303
+ for (const id of range) {
304
+ next.add(id);
305
+ }
306
+ return next;
307
+ });
308
+ // Whatever anchored the range becomes the stored anchor. A still-visible
309
+ // stored anchor resolves to itself (unchanged), so consecutive
310
+ // shift-clicks keep extending from the same origin; a stored anchor that
311
+ // left the visible set is replaced by the row the range actually used, so
312
+ // a filtered/search-narrowed list can build a range within what's visible.
313
+ setAnchorId(effectiveAnchor);
314
+ },
315
+ [anchorId],
316
+ );
317
+
318
+ return {
319
+ selectedIds,
320
+ selectedCount: selectedIds.size,
321
+ hasSelection: selectedIds.size > 0,
322
+ isSelected,
323
+ toggle,
324
+ select,
325
+ deselect,
326
+ selectAll,
327
+ clearSelection,
328
+ toggleAll,
329
+ selectRange,
330
+ setAnchor,
331
+ anchorId,
332
+ intersectWith,
333
+ };
334
+ };