@remit/web-client 0.0.35 → 0.0.37
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.
|
|
3
|
+
"version": "0.0.37",
|
|
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": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createAuthClient } from "better-auth/react";
|
|
2
|
-
import { taggedFetch } from "../lib/network-error";
|
|
2
|
+
import { NetworkError, taggedFetch } from "../lib/network-error";
|
|
3
3
|
import { getRuntimeConfig } from "../runtime-config";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -185,18 +185,47 @@ const isFresh = (token: CachedToken): boolean =>
|
|
|
185
185
|
const isUsable = (token: CachedToken): boolean =>
|
|
186
186
|
token.expiresAt > nowSeconds();
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* A mint failure the held token is allowed to ride out: a request timeout (408),
|
|
190
|
+
* throttling (429), a server-side fault (5xx), or a transport failure that never
|
|
191
|
+
* reached the server. These say nothing about the session's validity, so keeping
|
|
192
|
+
* the token the tab already holds is correct. A 4xx that is not 408 or 429 does
|
|
193
|
+
* not qualify — least of all a 401/403, which is the server revoking the session.
|
|
194
|
+
*/
|
|
195
|
+
const isTransientMintFailure = (error: unknown): boolean => {
|
|
196
|
+
if (error instanceof NetworkError) return true;
|
|
197
|
+
if (error instanceof AuthTokenError) {
|
|
198
|
+
return (
|
|
199
|
+
error.status === 408 || error.status === 429 || (error.status ?? 0) >= 500
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The mint was refused because the session is no longer valid. The held token is
|
|
207
|
+
* signed for that same revoked session, so it must be discarded rather than
|
|
208
|
+
* served until it expires.
|
|
209
|
+
*/
|
|
210
|
+
const isRevocation = (error: unknown): boolean =>
|
|
211
|
+
error instanceof AuthTokenError &&
|
|
212
|
+
(error.status === 401 || error.status === 403);
|
|
213
|
+
|
|
188
214
|
/**
|
|
189
215
|
* Return a valid RS256 JWT for the current session, minting a fresh one only
|
|
190
216
|
* when the cached token is missing or within the skew window of expiry. The API
|
|
191
217
|
* interceptor attaches it as a Bearer token; the backend verifies it against the
|
|
192
218
|
* JWKS.
|
|
193
219
|
*
|
|
194
|
-
* A throttled or
|
|
195
|
-
* request keeps the session it already holds and the endpoint
|
|
196
|
-
* until the backoff clears.
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
220
|
+
* A throttled, faulted, or unreachable refresh never discards a token that is
|
|
221
|
+
* still usable: the request keeps the session it already holds and the endpoint
|
|
222
|
+
* is left alone until the backoff clears. A revocation (401/403) is the opposite
|
|
223
|
+
* — the session behind the held token is gone, so it is cleared and the failure
|
|
224
|
+
* propagates to re-authentication rather than serving a dead token until expiry.
|
|
225
|
+
* Only a mint with nothing usable to fall back to throws otherwise — returning
|
|
226
|
+
* null would put the request on the wire without an Authorization header, and the
|
|
227
|
+
* backend answers that with a 401 that names a missing token rather than the mint
|
|
228
|
+
* that failed.
|
|
200
229
|
*/
|
|
201
230
|
export const fetchBetterAuthToken = async (): Promise<string> => {
|
|
202
231
|
const current = currentCache();
|
|
@@ -212,7 +241,11 @@ export const fetchBetterAuthToken = async (): Promise<string> => {
|
|
|
212
241
|
try {
|
|
213
242
|
return await mint();
|
|
214
243
|
} catch (error) {
|
|
215
|
-
if (
|
|
244
|
+
if (isRevocation(error)) {
|
|
245
|
+
resetBetterAuthTokenCache();
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
if (current && isUsable(current) && isTransientMintFailure(error)) {
|
|
216
249
|
backoffUntil = nowSeconds() + REFRESH_BACKOFF_SECONDS;
|
|
217
250
|
return current.value;
|
|
218
251
|
}
|
|
@@ -197,6 +197,134 @@ describe("fetchBetterAuthToken", () => {
|
|
|
197
197
|
assert.equal(second, held);
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
+
test("a network failure keeps the still-valid token instead of discarding it", async () => {
|
|
201
|
+
const seed = stubTokenEndpoint(() =>
|
|
202
|
+
tokenResponse(nearExpiryJwt("still-valid")),
|
|
203
|
+
);
|
|
204
|
+
seed.release();
|
|
205
|
+
const held = await fetchBetterAuthToken();
|
|
206
|
+
|
|
207
|
+
globalThis.fetch = (async () => {
|
|
208
|
+
throw new TypeError("Failed to fetch");
|
|
209
|
+
}) as typeof fetch;
|
|
210
|
+
const afterNetworkFailure = await fetchBetterAuthToken();
|
|
211
|
+
|
|
212
|
+
assert.equal(afterNetworkFailure, held);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("a 5xx refresh keeps the still-valid token instead of discarding it", async () => {
|
|
216
|
+
const seed = stubTokenEndpoint(() =>
|
|
217
|
+
tokenResponse(nearExpiryJwt("still-valid")),
|
|
218
|
+
);
|
|
219
|
+
seed.release();
|
|
220
|
+
const held = await fetchBetterAuthToken();
|
|
221
|
+
|
|
222
|
+
const faulted = stubTokenEndpoint(
|
|
223
|
+
() =>
|
|
224
|
+
new Response("", { status: 503, statusText: "Service Unavailable" }),
|
|
225
|
+
);
|
|
226
|
+
faulted.release();
|
|
227
|
+
const afterFault = await fetchBetterAuthToken();
|
|
228
|
+
|
|
229
|
+
assert.equal(faulted.calls, 1);
|
|
230
|
+
assert.equal(afterFault, held);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("a 408 refresh keeps the still-valid token instead of discarding it", async () => {
|
|
234
|
+
const seed = stubTokenEndpoint(() =>
|
|
235
|
+
tokenResponse(nearExpiryJwt("still-valid")),
|
|
236
|
+
);
|
|
237
|
+
seed.release();
|
|
238
|
+
const held = await fetchBetterAuthToken();
|
|
239
|
+
|
|
240
|
+
const timedOut = stubTokenEndpoint(
|
|
241
|
+
() => new Response("", { status: 408, statusText: "Request Timeout" }),
|
|
242
|
+
);
|
|
243
|
+
timedOut.release();
|
|
244
|
+
const afterTimeout = await fetchBetterAuthToken();
|
|
245
|
+
|
|
246
|
+
assert.equal(timedOut.calls, 1);
|
|
247
|
+
assert.equal(afterTimeout, held);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("an unclassified 4xx refresh throws without serving the held token", async () => {
|
|
251
|
+
const seed = stubTokenEndpoint(() =>
|
|
252
|
+
tokenResponse(nearExpiryJwt("still-valid")),
|
|
253
|
+
);
|
|
254
|
+
seed.release();
|
|
255
|
+
await fetchBetterAuthToken();
|
|
256
|
+
|
|
257
|
+
const rejected = stubTokenEndpoint(
|
|
258
|
+
() => new Response("", { status: 400, statusText: "Bad Request" }),
|
|
259
|
+
);
|
|
260
|
+
rejected.release();
|
|
261
|
+
|
|
262
|
+
await assert.rejects(
|
|
263
|
+
() => fetchBetterAuthToken(),
|
|
264
|
+
(error: unknown) => {
|
|
265
|
+
assert.ok(error instanceof AuthTokenError);
|
|
266
|
+
assert.equal(error.status, 400);
|
|
267
|
+
return true;
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
assert.equal(rejected.calls, 1);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("a revoked mint (401) clears the held token and re-authenticates", async () => {
|
|
274
|
+
const seed = stubTokenEndpoint(() =>
|
|
275
|
+
tokenResponse(nearExpiryJwt("revoked-soon")),
|
|
276
|
+
);
|
|
277
|
+
seed.release();
|
|
278
|
+
const held = await fetchBetterAuthToken();
|
|
279
|
+
|
|
280
|
+
const revoked = stubTokenEndpoint(
|
|
281
|
+
() => new Response("", { status: 401, statusText: "Unauthorized" }),
|
|
282
|
+
);
|
|
283
|
+
revoked.release();
|
|
284
|
+
await assert.rejects(
|
|
285
|
+
() => fetchBetterAuthToken(),
|
|
286
|
+
(error: unknown) => {
|
|
287
|
+
assert.ok(error instanceof AuthTokenError);
|
|
288
|
+
assert.equal(error.status, 401);
|
|
289
|
+
return true;
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
assert.equal(revoked.calls, 1);
|
|
293
|
+
|
|
294
|
+
const reissued = stubTokenEndpoint(() => tokenResponse(jwt("reissued")));
|
|
295
|
+
reissued.release();
|
|
296
|
+
const afterReauth = await fetchBetterAuthToken();
|
|
297
|
+
|
|
298
|
+
assert.equal(reissued.calls, 1);
|
|
299
|
+
assert.notEqual(afterReauth, held);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("a revoked mint (403) clears the held token and re-authenticates", async () => {
|
|
303
|
+
const seed = stubTokenEndpoint(() =>
|
|
304
|
+
tokenResponse(nearExpiryJwt("forbidden-soon")),
|
|
305
|
+
);
|
|
306
|
+
seed.release();
|
|
307
|
+
await fetchBetterAuthToken();
|
|
308
|
+
|
|
309
|
+
const forbidden = stubTokenEndpoint(
|
|
310
|
+
() => new Response("", { status: 403, statusText: "Forbidden" }),
|
|
311
|
+
);
|
|
312
|
+
forbidden.release();
|
|
313
|
+
await assert.rejects(
|
|
314
|
+
() => fetchBetterAuthToken(),
|
|
315
|
+
(error: unknown) => {
|
|
316
|
+
assert.ok(error instanceof AuthTokenError);
|
|
317
|
+
assert.equal(error.status, 403);
|
|
318
|
+
return true;
|
|
319
|
+
},
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const reissued = stubTokenEndpoint(() => tokenResponse(jwt("reissued")));
|
|
323
|
+
reissued.release();
|
|
324
|
+
assert.ok(await fetchBetterAuthToken());
|
|
325
|
+
assert.equal(reissued.calls, 1);
|
|
326
|
+
});
|
|
327
|
+
|
|
200
328
|
test("a throttled refresh with no usable token to fall back on still throws", async () => {
|
|
201
329
|
const seed = stubTokenEndpoint(() =>
|
|
202
330
|
tokenResponse(jwtExpiringIn("already-expired", -10)),
|
|
@@ -423,7 +423,10 @@ export const MessageList = ({
|
|
|
423
423
|
const handleRowSelect = useCallback(
|
|
424
424
|
(messageId: string, modifiers: SelectionModifiers): boolean => {
|
|
425
425
|
if (modifiers.shiftKey) {
|
|
426
|
-
|
|
426
|
+
// The open/focused row is the fallback origin when the stored anchor
|
|
427
|
+
// has been filtered or searched out of the visible list, so the first
|
|
428
|
+
// shift-click still ranges from where the user is (#142, #144).
|
|
429
|
+
selectRange(orderedIds, messageId, focusedMessageId);
|
|
427
430
|
return true;
|
|
428
431
|
}
|
|
429
432
|
if (modifiers.metaKey || modifiers.ctrlKey) {
|
|
@@ -438,7 +441,14 @@ export const MessageList = ({
|
|
|
438
441
|
setAnchor(messageId);
|
|
439
442
|
return false;
|
|
440
443
|
},
|
|
441
|
-
[
|
|
444
|
+
[
|
|
445
|
+
orderedIds,
|
|
446
|
+
focusedMessageId,
|
|
447
|
+
selectRange,
|
|
448
|
+
toggleCheck,
|
|
449
|
+
clearSelection,
|
|
450
|
+
setAnchor,
|
|
451
|
+
],
|
|
442
452
|
);
|
|
443
453
|
|
|
444
454
|
// Open the delete confirmation for an explicit set of ids. All delete
|
|
@@ -4,10 +4,43 @@ import {
|
|
|
4
4
|
computeRange,
|
|
5
5
|
intersectSelectedIds,
|
|
6
6
|
nextFocusId,
|
|
7
|
+
resolveRangeAnchor,
|
|
7
8
|
} from "./useSelection.js";
|
|
8
9
|
|
|
9
10
|
const ids = ["a", "b", "c", "d", "e"];
|
|
10
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
|
+
|
|
11
44
|
describe("computeRange", () => {
|
|
12
45
|
test("forward range: anchor above target selects the inclusive slice", () => {
|
|
13
46
|
assert.deepStrictEqual(computeRange(ids, "b", "d"), ["b", "c", "d"]);
|
|
@@ -44,6 +77,81 @@ describe("computeRange", () => {
|
|
|
44
77
|
});
|
|
45
78
|
});
|
|
46
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
|
+
|
|
47
155
|
describe("nextFocusId", () => {
|
|
48
156
|
test("moves down one row", () => {
|
|
49
157
|
assert.strictEqual(nextFocusId(ids, "b", 1), "c");
|
|
@@ -37,11 +37,19 @@ interface UseSelectionReturn {
|
|
|
37
37
|
/** Toggle selection for all items */
|
|
38
38
|
toggleAll: (ids: string[]) => void;
|
|
39
39
|
/**
|
|
40
|
-
* Add the contiguous range of ids from the
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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).
|
|
43
47
|
*/
|
|
44
|
-
selectRange: (
|
|
48
|
+
selectRange: (
|
|
49
|
+
orderedIds: string[],
|
|
50
|
+
targetId: string,
|
|
51
|
+
fallbackAnchor?: string,
|
|
52
|
+
) => void;
|
|
45
53
|
/**
|
|
46
54
|
* Set the range anchor without changing the selection set. Used by a plain
|
|
47
55
|
* click that navigates but should seed the anchor for a later shift-click.
|
|
@@ -85,6 +93,34 @@ export const computeRange = (
|
|
|
85
93
|
return orderedIds.slice(start, end + 1);
|
|
86
94
|
};
|
|
87
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
|
+
|
|
88
124
|
/**
|
|
89
125
|
* The ids from `selectedIds` that are still present in `currentIds` — the
|
|
90
126
|
* survivor set after a list refresh. Only ever narrows: an id absent from
|
|
@@ -211,9 +247,15 @@ export const useSelection = <T>(
|
|
|
211
247
|
}, []);
|
|
212
248
|
|
|
213
249
|
const selectRange = useCallback(
|
|
214
|
-
(orderedIds: string[], targetId: string) => {
|
|
250
|
+
(orderedIds: string[], targetId: string, fallbackAnchor?: string) => {
|
|
251
|
+
const effectiveAnchor = resolveRangeAnchor(
|
|
252
|
+
orderedIds,
|
|
253
|
+
anchorId,
|
|
254
|
+
fallbackAnchor,
|
|
255
|
+
targetId,
|
|
256
|
+
);
|
|
215
257
|
setSelectedIds((prev) => {
|
|
216
|
-
const range = computeRange(orderedIds,
|
|
258
|
+
const range = computeRange(orderedIds, effectiveAnchor, targetId);
|
|
217
259
|
if (range.length === 0) return prev;
|
|
218
260
|
const next = new Set(prev);
|
|
219
261
|
for (const id of range) {
|
|
@@ -221,10 +263,12 @@ export const useSelection = <T>(
|
|
|
221
263
|
}
|
|
222
264
|
return next;
|
|
223
265
|
});
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
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);
|
|
228
272
|
},
|
|
229
273
|
[anchorId],
|
|
230
274
|
);
|