@remit/web-client 0.0.13 → 0.0.15
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 +1 -1
- package/src/auth/better-auth-config.ts +4 -1
- package/src/components/compose/ComposeForm.tsx +3 -0
- package/src/components/mail/IntelligencePane.tsx +10 -8
- package/src/components/mail/MailboxPane.tsx +53 -2
- package/src/components/mail/MessageActionMenu.tsx +18 -25
- package/src/components/mail/MessageDetail.tsx +1 -0
- package/src/components/mail/MessageList.tsx +196 -78
- package/src/components/mail/MessageListItem.tsx +60 -13
- package/src/components/mail/SwipeableMessageRow.tsx +8 -0
- package/src/components/ui/ConfirmDialog.tsx +9 -7
- package/src/components/ui/ErrorBanner.tsx +6 -3
- package/src/components/ui/ErrorBannerProvider.render.test.ts +85 -0
- package/src/components/ui/ErrorBannerProvider.tsx +18 -0
- package/src/components/ui/error-banners.ts +9 -0
- package/src/hooks/useDeleteMessages.ts +14 -21
- package/src/hooks/useIntelligenceData.ts +14 -3
- package/src/hooks/useMarkAsRead.ts +14 -23
- package/src/hooks/useMessageBodyContent.ts +2 -1
- package/src/hooks/useMoveMessages.ts +14 -21
- package/src/hooks/useStaleAccountSync.test.ts +22 -1
- package/src/hooks/useToggleStar.test.ts +31 -0
- package/src/hooks/useToggleStar.ts +27 -31
- package/src/hooks/useTriageKeyboard.ts +13 -8
- package/src/hooks/useUpdateAddressFlags.ts +3 -1
- package/src/lib/api.ts +3 -1
- package/src/lib/client.ts +3 -0
- package/src/lib/error-classifier.test.ts +156 -8
- package/src/lib/error-classifier.ts +49 -9
- package/src/lib/keymap-dispatch.test.ts +78 -1
- package/src/lib/keymap-dispatch.ts +64 -7
- package/src/lib/keymap.ts +23 -0
- package/src/lib/list-focus.test.ts +25 -0
- package/src/lib/list-focus.ts +24 -0
- package/src/lib/network-error.ts +58 -0
- package/src/lib/query-error-handler.test.ts +6 -2
- package/src/lib/query-escalation.integration.test.ts +3 -2
- package/src/lib/sender-address.test.ts +60 -0
- package/src/lib/sender-address.ts +37 -0
- package/src/lib/thread-cache.test.ts +65 -0
- package/src/lib/thread-cache.ts +76 -0
package/src/lib/api.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { taggedFetch } from "./network-error";
|
|
2
|
+
|
|
1
3
|
export class ApiError extends Error {
|
|
2
4
|
constructor(
|
|
3
5
|
message: string,
|
|
@@ -38,7 +40,7 @@ const request = async <T>(
|
|
|
38
40
|
|
|
39
41
|
const url = buildUrl(path, params);
|
|
40
42
|
|
|
41
|
-
const response = await
|
|
43
|
+
const response = await taggedFetch(url, {
|
|
42
44
|
method,
|
|
43
45
|
headers: {
|
|
44
46
|
"Content-Type": "application/json",
|
package/src/lib/client.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { client } from "@remit/api-http-client/client.gen.ts";
|
|
2
2
|
import { getRuntimeConfig } from "../runtime-config";
|
|
3
3
|
import { ApiError } from "./api";
|
|
4
|
+
import { taggedFetch } from "./network-error";
|
|
4
5
|
|
|
5
6
|
// In production, the config.js apiUrl points at the deployed API Gateway.
|
|
6
7
|
// In local dev, the Vite proxy forwards /api -> localhost:4321 (see vite.config.ts).
|
|
@@ -8,6 +9,8 @@ const baseUrl = getRuntimeConfig().apiUrl;
|
|
|
8
9
|
|
|
9
10
|
client.setConfig({
|
|
10
11
|
baseUrl,
|
|
12
|
+
// Transport failures are tagged where they happen; see `network-error.ts`.
|
|
13
|
+
fetch: taggedFetch,
|
|
11
14
|
});
|
|
12
15
|
|
|
13
16
|
/**
|
|
@@ -5,10 +5,18 @@ import {
|
|
|
5
5
|
getErrorStatus,
|
|
6
6
|
hasHttpStatus,
|
|
7
7
|
isAbortError,
|
|
8
|
+
isClientBug,
|
|
8
9
|
isNetworkError,
|
|
9
10
|
isServerError,
|
|
10
11
|
shouldEscalate,
|
|
11
12
|
} from "./error-classifier";
|
|
13
|
+
import { NetworkError, taggedFetch } from "./network-error";
|
|
14
|
+
|
|
15
|
+
const timeoutError = (): Error => {
|
|
16
|
+
const error = new Error("The operation timed out.");
|
|
17
|
+
error.name = "TimeoutError";
|
|
18
|
+
return error;
|
|
19
|
+
};
|
|
12
20
|
|
|
13
21
|
const abortError = (): Error => {
|
|
14
22
|
const error = new Error("aborted");
|
|
@@ -75,9 +83,31 @@ describe("isAbortError", () => {
|
|
|
75
83
|
});
|
|
76
84
|
|
|
77
85
|
describe("isNetworkError", () => {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
86
|
+
// The boundary tags the failure, so the browser's wording never matters.
|
|
87
|
+
// These are the real rejections `fetch` produces, across engines — every one
|
|
88
|
+
// of them has to stay soft, whatever it happens to say.
|
|
89
|
+
const transportFailures = [
|
|
90
|
+
new TypeError("Failed to fetch"), // Chrome, Edge
|
|
91
|
+
new TypeError("NetworkError when attempting to fetch resource."), // Firefox
|
|
92
|
+
new TypeError("Load failed"), // WebKit
|
|
93
|
+
new TypeError("The network connection was lost."), // WebKit, wifi dropped
|
|
94
|
+
new TypeError("The request timed out."), // WebKit
|
|
95
|
+
new TypeError("fetch failed"), // undici
|
|
96
|
+
new Error("something no engine has said yet"),
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
it("is true for anything the fetch boundary tagged, whatever it says", () => {
|
|
100
|
+
for (const failure of transportFailures) {
|
|
101
|
+
assert.equal(
|
|
102
|
+
isNetworkError(new NetworkError(failure)),
|
|
103
|
+
true,
|
|
104
|
+
`"${failure.message}" must stay soft`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("is true for a timeout — a timeout is a transport failure", () => {
|
|
110
|
+
assert.equal(isNetworkError(new NetworkError(timeoutError())), true);
|
|
81
111
|
});
|
|
82
112
|
|
|
83
113
|
it("is false for an aborted request (its own category)", () => {
|
|
@@ -88,6 +118,108 @@ describe("isNetworkError", () => {
|
|
|
88
118
|
assert.equal(isNetworkError(new ApiError("boom", 500)), false);
|
|
89
119
|
assert.equal(isNetworkError(new ApiError("not found", 404)), false);
|
|
90
120
|
});
|
|
121
|
+
|
|
122
|
+
it("is false for an exception thrown by our own code", () => {
|
|
123
|
+
assert.equal(
|
|
124
|
+
isNetworkError(
|
|
125
|
+
new TypeError('can\'t access property "map", x is undefined'),
|
|
126
|
+
),
|
|
127
|
+
false,
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("does not mistake an untagged error for transport just because it reads like one", () => {
|
|
132
|
+
// A bug whose message happens to contain a fetch-failure phrase is still a
|
|
133
|
+
// bug. Only the boundary gets to say otherwise.
|
|
134
|
+
assert.equal(isNetworkError(new TypeError("Failed to fetch")), false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("taggedFetch — where the decision is actually made", () => {
|
|
139
|
+
const withFetch = async (
|
|
140
|
+
impl: typeof fetch,
|
|
141
|
+
run: () => Promise<void>,
|
|
142
|
+
): Promise<void> => {
|
|
143
|
+
const original = globalThis.fetch;
|
|
144
|
+
globalThis.fetch = impl;
|
|
145
|
+
try {
|
|
146
|
+
await run();
|
|
147
|
+
} finally {
|
|
148
|
+
globalThis.fetch = original;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
it("tags every transport rejection, regardless of its message", async () => {
|
|
153
|
+
for (const message of [
|
|
154
|
+
"Failed to fetch",
|
|
155
|
+
"The network connection was lost.",
|
|
156
|
+
"fetch failed",
|
|
157
|
+
"anything at all",
|
|
158
|
+
]) {
|
|
159
|
+
await withFetch(
|
|
160
|
+
() => Promise.reject(new TypeError(message)),
|
|
161
|
+
async () => {
|
|
162
|
+
const error = await taggedFetch("/x").catch((e: unknown) => e);
|
|
163
|
+
assert.ok(error instanceof NetworkError);
|
|
164
|
+
assert.equal(isNetworkError(error), true);
|
|
165
|
+
assert.equal(shouldEscalate(error), false);
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("tags a timeout, because a timeout never reached a server", async () => {
|
|
172
|
+
await withFetch(
|
|
173
|
+
() => Promise.reject(timeoutError()),
|
|
174
|
+
async () => {
|
|
175
|
+
const error = await taggedFetch("/x").catch((e: unknown) => e);
|
|
176
|
+
assert.ok(error instanceof NetworkError);
|
|
177
|
+
assert.equal(shouldEscalate(error), false);
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("leaves a deliberate abort untagged — it is not a failure", async () => {
|
|
183
|
+
await withFetch(
|
|
184
|
+
() => Promise.reject(abortError()),
|
|
185
|
+
async () => {
|
|
186
|
+
const error = await taggedFetch("/x").catch((e: unknown) => e);
|
|
187
|
+
assert.ok(!(error instanceof NetworkError));
|
|
188
|
+
assert.equal(isAbortError(error), true);
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("passes a response through untouched, including an error status", async () => {
|
|
194
|
+
const response = new Response("nope", { status: 500 });
|
|
195
|
+
await withFetch(
|
|
196
|
+
() => Promise.resolve(response),
|
|
197
|
+
async () => {
|
|
198
|
+
assert.equal(await taggedFetch("/x"), response);
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("isClientBug", () => {
|
|
205
|
+
it("is true for an exception raised inside our own code", () => {
|
|
206
|
+
assert.equal(
|
|
207
|
+
isClientBug(
|
|
208
|
+
new TypeError('can\'t access property "map", x is undefined'),
|
|
209
|
+
),
|
|
210
|
+
true,
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("is false for a request that reached, or failed to reach, a server", () => {
|
|
215
|
+
assert.equal(isClientBug(new ApiError("boom", 500)), false);
|
|
216
|
+
assert.equal(isClientBug(new ApiError("not found", 404)), false);
|
|
217
|
+
assert.equal(
|
|
218
|
+
isClientBug(new NetworkError(new TypeError("Failed to fetch"))),
|
|
219
|
+
false,
|
|
220
|
+
);
|
|
221
|
+
assert.equal(isClientBug(abortError()), false);
|
|
222
|
+
});
|
|
91
223
|
});
|
|
92
224
|
|
|
93
225
|
describe("shouldEscalate (the fail-fast decision table — #1059)", () => {
|
|
@@ -119,15 +251,31 @@ describe("shouldEscalate (the fail-fast decision table — #1059)", () => {
|
|
|
119
251
|
assert.equal(shouldEscalate(abortError()), false);
|
|
120
252
|
});
|
|
121
253
|
|
|
122
|
-
it("does NOT escalate a
|
|
123
|
-
assert.equal(
|
|
124
|
-
|
|
254
|
+
it("does NOT escalate a network/offline blip", () => {
|
|
255
|
+
assert.equal(
|
|
256
|
+
shouldEscalate(new NetworkError(new TypeError("Failed to fetch"))),
|
|
257
|
+
false,
|
|
258
|
+
);
|
|
259
|
+
assert.equal(
|
|
260
|
+
shouldEscalate(
|
|
261
|
+
new NetworkError(new TypeError("The network connection was lost.")),
|
|
262
|
+
),
|
|
263
|
+
false,
|
|
264
|
+
);
|
|
125
265
|
});
|
|
126
266
|
|
|
127
|
-
it("a
|
|
267
|
+
it("a network error is soft regardless of meta", () => {
|
|
128
268
|
assert.equal(
|
|
129
|
-
shouldEscalate(new TypeError("Failed to fetch"), {
|
|
269
|
+
shouldEscalate(new NetworkError(new TypeError("Failed to fetch")), {
|
|
270
|
+
softError: false,
|
|
271
|
+
}),
|
|
130
272
|
false,
|
|
131
273
|
);
|
|
132
274
|
});
|
|
275
|
+
|
|
276
|
+
it("escalates an exception thrown by our own code, softError or not", () => {
|
|
277
|
+
const bug = new TypeError('can\'t access property "map", x is undefined');
|
|
278
|
+
assert.equal(shouldEscalate(bug), true);
|
|
279
|
+
assert.equal(shouldEscalate(bug, { softError: true }), true);
|
|
280
|
+
});
|
|
133
281
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ApiError } from "./api";
|
|
2
|
+
import { NetworkError } from "./network-error";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Extract an HTTP status code from a thrown error, regardless of which client
|
|
@@ -55,14 +56,50 @@ export const isAbortError = (error: unknown): boolean => {
|
|
|
55
56
|
return false;
|
|
56
57
|
};
|
|
57
58
|
|
|
59
|
+
const isOffline = (): boolean =>
|
|
60
|
+
typeof navigator !== "undefined" && navigator.onLine === false;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A transport failure from a wifi drop, tab wake, captive portal, or a timeout:
|
|
64
|
+
* the request never reached a server, so it is environmental, not a proven
|
|
65
|
+
* first-party failure.
|
|
66
|
+
*
|
|
67
|
+
* This is decided at the fetch boundary, not inferred here. Every app-owned
|
|
68
|
+
* request goes through `taggedFetch`, which knows a `fetch()` rejection is
|
|
69
|
+
* transport-level because `fetch` rejects for no other reason, and tags it a
|
|
70
|
+
* `NetworkError`. Reading that tag is exact; matching browser failure strings
|
|
71
|
+
* was not — the list is open-ended (WebKit alone has four, undici another) and
|
|
72
|
+
* anything missed would put a full-screen fatal page in front of someone who
|
|
73
|
+
* had merely walked out of wifi range.
|
|
74
|
+
*
|
|
75
|
+
* `navigator.onLine === false` is kept as a second signal for an error that
|
|
76
|
+
* reached us from outside that boundary while the browser reports no
|
|
77
|
+
* connectivity at all.
|
|
78
|
+
*/
|
|
79
|
+
export const isNetworkError = (error: unknown): boolean => {
|
|
80
|
+
if (isAbortError(error)) return false;
|
|
81
|
+
if (hasHttpStatus(error)) return false;
|
|
82
|
+
if (error instanceof NetworkError) return true;
|
|
83
|
+
return isOffline();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* An exception raised by our own client code rather than by a request: it
|
|
88
|
+
* carries no HTTP status, is not an abort, and was not tagged as transport.
|
|
89
|
+
* That leaves a programming error — the one class the user can do nothing
|
|
90
|
+
* about and must never be asked to shrug off.
|
|
91
|
+
*/
|
|
92
|
+
export const isClientBug = (error: unknown): boolean =>
|
|
93
|
+
!hasHttpStatus(error) && !isAbortError(error) && !isNetworkError(error);
|
|
94
|
+
|
|
58
95
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
96
|
+
* Fatal with no opt-out: our API answered "I'm broken", or our own code threw.
|
|
97
|
+
* Neither is something a call site can reclassify as soft, and neither may be
|
|
98
|
+
* reduced to a dismissible banner — both belong on the full-screen page, which
|
|
99
|
+
* offers a way forward and a bug report (issue #55).
|
|
63
100
|
*/
|
|
64
|
-
export const
|
|
65
|
-
|
|
101
|
+
export const isAlwaysFatal = (error: unknown): boolean =>
|
|
102
|
+
isServerError(error) || isClientBug(error);
|
|
66
103
|
|
|
67
104
|
const isSoftErrorMeta = (meta: Record<string, unknown> | undefined): boolean =>
|
|
68
105
|
meta?.softError === true;
|
|
@@ -76,10 +113,12 @@ const isSoftErrorMeta = (meta: Record<string, unknown> | undefined): boolean =>
|
|
|
76
113
|
* 2. A 5xx (500–599) ALWAYS escalates — no opt-out, even on a background
|
|
77
114
|
* refetch, even when the call site marked `meta.softError`. Our API
|
|
78
115
|
* answered "I'm broken"; that is never benign.
|
|
79
|
-
* 3.
|
|
116
|
+
* 3. A client-side exception ALWAYS escalates, on the same terms. It is our
|
|
117
|
+
* bug; there is nothing for the user to retry and nothing to dismiss.
|
|
118
|
+
* 4. The ONLY soft (do-NOT-escalate) exemptions:
|
|
80
119
|
* a. aborts / cancellations — never a failure;
|
|
81
|
-
* b.
|
|
82
|
-
*
|
|
120
|
+
* b. network/offline errors — environmental, recovered by React Query's
|
|
121
|
+
* reconnect/retry;
|
|
83
122
|
* c. a non-5xx error on a query/mutation that opted out via
|
|
84
123
|
* `meta.softError === true` — the call site owns that error's UX
|
|
85
124
|
* (e.g. a 404 empty state, a 4xx "Reconnect" banner).
|
|
@@ -91,6 +130,7 @@ export const shouldEscalate = (
|
|
|
91
130
|
if (isServerError(error)) return true;
|
|
92
131
|
if (isAbortError(error)) return false;
|
|
93
132
|
if (isNetworkError(error)) return false;
|
|
133
|
+
if (isAlwaysFatal(error)) return true;
|
|
94
134
|
if (isSoftErrorMeta(meta)) return false;
|
|
95
135
|
return true;
|
|
96
136
|
};
|
|
@@ -12,6 +12,7 @@ const stroke = (partial: Partial<KeyStroke> & { key: string }): KeyStroke => ({
|
|
|
12
12
|
ctrlKey: false,
|
|
13
13
|
altKey: false,
|
|
14
14
|
inEditable: false,
|
|
15
|
+
onControl: false,
|
|
15
16
|
...partial,
|
|
16
17
|
});
|
|
17
18
|
|
|
@@ -61,6 +62,82 @@ describe("dispatchKey — plain bindings", () => {
|
|
|
61
62
|
});
|
|
62
63
|
});
|
|
63
64
|
|
|
65
|
+
describe("dispatchKey — list navigation", () => {
|
|
66
|
+
const cases: Array<[string, string]> = [
|
|
67
|
+
["ArrowDown", "focusNext"],
|
|
68
|
+
["ArrowUp", "focusPrevious"],
|
|
69
|
+
["Home", "focusFirst"],
|
|
70
|
+
["End", "focusLast"],
|
|
71
|
+
[" ", "toggleSelect"],
|
|
72
|
+
["Delete", "delete"],
|
|
73
|
+
["Backspace", "delete"],
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
for (const [key, action] of cases) {
|
|
77
|
+
test(`'${key}' → ${action}`, () => {
|
|
78
|
+
const result = run({ key });
|
|
79
|
+
assert.strictEqual(result.action, action);
|
|
80
|
+
assert.strictEqual(result.preventDefault, true);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
test("Shift+Arrow extends the selection instead of moving the cursor", () => {
|
|
85
|
+
assert.strictEqual(
|
|
86
|
+
run({ key: "ArrowDown", shiftKey: true }).action,
|
|
87
|
+
"extendSelectDown",
|
|
88
|
+
);
|
|
89
|
+
assert.strictEqual(
|
|
90
|
+
run({ key: "ArrowUp", shiftKey: true }).action,
|
|
91
|
+
"extendSelectUp",
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("⌘A / Ctrl+A selects every loaded row", () => {
|
|
96
|
+
assert.strictEqual(run({ key: "a", metaKey: true }).action, "selectAll");
|
|
97
|
+
assert.strictEqual(run({ key: "a", ctrlKey: true }).action, "selectAll");
|
|
98
|
+
assert.strictEqual(
|
|
99
|
+
run({ key: "a", metaKey: true }).preventDefault,
|
|
100
|
+
true,
|
|
101
|
+
"the browser's select-all-text default must be replaced",
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("dispatchKey — controls keep their activation keys", () => {
|
|
107
|
+
// The regression behind #43: a global Enter binding cancelled the default
|
|
108
|
+
// action of whatever the user had tabbed to, so no button in the app could
|
|
109
|
+
// be activated from the keyboard.
|
|
110
|
+
test("Enter on a focused control is left to the control", () => {
|
|
111
|
+
const result = run({ key: "Enter", onControl: true });
|
|
112
|
+
assert.strictEqual(result.action, null);
|
|
113
|
+
assert.strictEqual(result.preventDefault, false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("Space on a focused control is left to the control", () => {
|
|
117
|
+
const result = run({ key: " ", onControl: true });
|
|
118
|
+
assert.strictEqual(result.action, null);
|
|
119
|
+
assert.strictEqual(result.preventDefault, false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("Enter and Space still drive the list away from a control", () => {
|
|
123
|
+
assert.strictEqual(run({ key: "Enter" }).action, "openFocused");
|
|
124
|
+
assert.strictEqual(run({ key: " " }).action, "toggleSelect");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("non-activation keys still fire from a focused control", () => {
|
|
128
|
+
// Tabbing to a toolbar button must not strand the rest of the keymap.
|
|
129
|
+
assert.strictEqual(run({ key: "j", onControl: true }).action, "focusNext");
|
|
130
|
+
assert.strictEqual(run({ key: "r", onControl: true }).action, "reply");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("an Enter released to a control still cancels a pending g prefix", () => {
|
|
134
|
+
assert.strictEqual(
|
|
135
|
+
run({ key: "Enter", onControl: true }, "g").nextPrefix,
|
|
136
|
+
null,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
64
141
|
describe("dispatchKey — input suppression", () => {
|
|
65
142
|
test("plain keys are inert in an editable surface", () => {
|
|
66
143
|
assert.strictEqual(run({ key: "j", inEditable: true }).action, null);
|
|
@@ -98,8 +175,8 @@ describe("dispatchKey — modifiers", () => {
|
|
|
98
175
|
});
|
|
99
176
|
|
|
100
177
|
test("other meta combos are left to the browser", () => {
|
|
101
|
-
assert.strictEqual(run({ key: "a", metaKey: true }).action, null);
|
|
102
178
|
assert.strictEqual(run({ key: "c", metaKey: true }).action, null);
|
|
179
|
+
assert.strictEqual(run({ key: "f", metaKey: true }).action, null);
|
|
103
180
|
});
|
|
104
181
|
|
|
105
182
|
test("Shift+J / Shift+K extend the selection", () => {
|
|
@@ -19,6 +19,15 @@ export interface KeyStroke {
|
|
|
19
19
|
altKey: boolean;
|
|
20
20
|
/** Whether the event originated inside an editable surface. */
|
|
21
21
|
inEditable: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Whether the event originated on an activatable control that is not a
|
|
24
|
+
* message-list row — a button, link, `role="button"`, `<summary>`. Enter and
|
|
25
|
+
* Space are that control's activation keys, so the layer releases them; every
|
|
26
|
+
* other binding still fires. Without this a global Enter binding cancels the
|
|
27
|
+
* default action of whatever the user tabbed to, and no button in the app can
|
|
28
|
+
* be activated from the keyboard.
|
|
29
|
+
*/
|
|
30
|
+
onControl: boolean;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
/** Pending sequence-prefix state. `"g"` means a `g` was pressed recently. */
|
|
@@ -69,7 +78,14 @@ interface PlainBinding {
|
|
|
69
78
|
const PLAIN_BINDINGS: Record<string, PlainBinding> = {
|
|
70
79
|
j: { action: "focusNext" },
|
|
71
80
|
k: { action: "focusPrevious" },
|
|
81
|
+
arrowdown: { action: "focusNext" },
|
|
82
|
+
arrowup: { action: "focusPrevious" },
|
|
83
|
+
home: { action: "focusFirst" },
|
|
84
|
+
end: { action: "focusLast" },
|
|
72
85
|
enter: { action: "openFocused" },
|
|
86
|
+
" ": { action: "toggleSelect" },
|
|
87
|
+
delete: { action: "delete" },
|
|
88
|
+
backspace: { action: "delete" },
|
|
73
89
|
u: { action: "toggleRead" },
|
|
74
90
|
x: { action: "toggleSelect" },
|
|
75
91
|
r: { action: "reply" },
|
|
@@ -118,12 +134,26 @@ export function dispatchKey(
|
|
|
118
134
|
return { ...NONE, nextPrefix: prefix === "g" ? null : prefix };
|
|
119
135
|
}
|
|
120
136
|
|
|
137
|
+
// Enter / Space belong to whatever control has focus. Releasing them here is
|
|
138
|
+
// what keeps Tab-to-a-button-then-Enter working anywhere in the app; the
|
|
139
|
+
// list's own rows are excluded from `onControl`, so the roving cursor still
|
|
140
|
+
// opens and selects.
|
|
141
|
+
if (stroke.onControl && (lower === "enter" || lower === " ")) {
|
|
142
|
+
return { ...NONE, nextPrefix: prefix === "g" ? null : prefix };
|
|
143
|
+
}
|
|
144
|
+
|
|
121
145
|
// ⌘N / Ctrl+N → compose. Checked before the prefix/plain tables so the
|
|
122
146
|
// browser's "new window" is the only thing we intercept among meta combos.
|
|
123
147
|
if (meta && lower === "n") {
|
|
124
148
|
return { action: "compose", nextPrefix: null, preventDefault: true };
|
|
125
149
|
}
|
|
126
150
|
|
|
151
|
+
// ⌘A / Ctrl+A → select every loaded row, replacing the browser's
|
|
152
|
+
// select-all-text default.
|
|
153
|
+
if (meta && lower === "a") {
|
|
154
|
+
return { action: "selectAll", nextPrefix: null, preventDefault: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
127
157
|
// Any other meta/ctrl combo is left to the browser/OS.
|
|
128
158
|
if (meta) return { ...NONE, nextPrefix: prefix === "g" ? null : prefix };
|
|
129
159
|
|
|
@@ -145,13 +175,17 @@ export function dispatchKey(
|
|
|
145
175
|
return { action: null, nextPrefix: "g", preventDefault: true };
|
|
146
176
|
}
|
|
147
177
|
|
|
148
|
-
// Shift+J
|
|
149
|
-
if (stroke.shiftKey
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
178
|
+
// Shift+J/K and Shift+Arrow extend the selection.
|
|
179
|
+
if (stroke.shiftKey) {
|
|
180
|
+
const down = lower === "j" || lower === "arrowdown";
|
|
181
|
+
const up = lower === "k" || lower === "arrowup";
|
|
182
|
+
if (down || up) {
|
|
183
|
+
return {
|
|
184
|
+
action: down ? "extendSelectDown" : "extendSelectUp",
|
|
185
|
+
nextPrefix: null,
|
|
186
|
+
preventDefault: true,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
155
189
|
}
|
|
156
190
|
|
|
157
191
|
// Plain single-key bindings.
|
|
@@ -185,3 +219,26 @@ export function isEditableTarget(target: EventTarget | null): boolean {
|
|
|
185
219
|
target.isContentEditable
|
|
186
220
|
);
|
|
187
221
|
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Marks an element as a message-list row. Rows are anchors, so they would
|
|
225
|
+
* otherwise read as ordinary controls; the list owns Enter/Space on them.
|
|
226
|
+
*/
|
|
227
|
+
export const ROW_ATTRIBUTE = "data-message-row";
|
|
228
|
+
|
|
229
|
+
const CONTROL_SELECTOR =
|
|
230
|
+
'button, a[href], summary, [role="button"], [role="menuitem"], [role="tab"], [role="switch"], [role="checkbox"]';
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Whether the event target sits inside an activatable control that is not a
|
|
234
|
+
* message-list row. See {@link KeyStroke.onControl}.
|
|
235
|
+
*/
|
|
236
|
+
export function isControlTarget(target: EventTarget | null): boolean {
|
|
237
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
238
|
+
const control = target.closest(CONTROL_SELECTOR);
|
|
239
|
+
if (!control) return false;
|
|
240
|
+
// The nearest control wins. A row is an anchor and so matches the selector,
|
|
241
|
+
// but the list owns Enter/Space on its rows; a control nested *inside* a row
|
|
242
|
+
// (the select checkbox) is a control like any other and keeps its own keys.
|
|
243
|
+
return !control.hasAttribute(ROW_ATTRIBUTE);
|
|
244
|
+
}
|
package/src/lib/keymap.ts
CHANGED
|
@@ -13,12 +13,15 @@ export type TriageAction =
|
|
|
13
13
|
// list navigation / focus model
|
|
14
14
|
| "focusNext"
|
|
15
15
|
| "focusPrevious"
|
|
16
|
+
| "focusFirst"
|
|
17
|
+
| "focusLast"
|
|
16
18
|
| "openFocused"
|
|
17
19
|
| "back"
|
|
18
20
|
// selection
|
|
19
21
|
| "toggleSelect"
|
|
20
22
|
| "extendSelectDown"
|
|
21
23
|
| "extendSelectUp"
|
|
24
|
+
| "selectAll"
|
|
22
25
|
// message verbs (focused row / selection)
|
|
23
26
|
| "reply"
|
|
24
27
|
| "replyAll"
|
|
@@ -76,6 +79,14 @@ export const KEY_HINT_GROUPS: KeyHintGroup[] = [
|
|
|
76
79
|
keys: ["k"],
|
|
77
80
|
description: "Focus previous message",
|
|
78
81
|
},
|
|
82
|
+
{ action: "focusNext", keys: ["↓"], description: "Focus next message" },
|
|
83
|
+
{
|
|
84
|
+
action: "focusPrevious",
|
|
85
|
+
keys: ["↑"],
|
|
86
|
+
description: "Focus previous message",
|
|
87
|
+
},
|
|
88
|
+
{ action: "focusFirst", keys: ["Home"], description: "Focus first" },
|
|
89
|
+
{ action: "focusLast", keys: ["End"], description: "Focus last" },
|
|
79
90
|
{
|
|
80
91
|
action: "openFocused",
|
|
81
92
|
keys: ["Enter"],
|
|
@@ -88,6 +99,7 @@ export const KEY_HINT_GROUPS: KeyHintGroup[] = [
|
|
|
88
99
|
title: "Selection",
|
|
89
100
|
hints: [
|
|
90
101
|
{ action: "toggleSelect", keys: ["x"], description: "Toggle select" },
|
|
102
|
+
{ action: "toggleSelect", keys: ["Space"], description: "Toggle select" },
|
|
91
103
|
{
|
|
92
104
|
action: "extendSelectDown",
|
|
93
105
|
keys: ["Shift", "j"],
|
|
@@ -98,6 +110,17 @@ export const KEY_HINT_GROUPS: KeyHintGroup[] = [
|
|
|
98
110
|
keys: ["Shift", "k"],
|
|
99
111
|
description: "Extend selection up",
|
|
100
112
|
},
|
|
113
|
+
{
|
|
114
|
+
action: "extendSelectDown",
|
|
115
|
+
keys: ["Shift", "↓"],
|
|
116
|
+
description: "Extend selection down",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
action: "extendSelectUp",
|
|
120
|
+
keys: ["Shift", "↑"],
|
|
121
|
+
description: "Extend selection up",
|
|
122
|
+
},
|
|
123
|
+
{ action: "selectAll", keys: ["⌘", "A"], description: "Select all" },
|
|
101
124
|
],
|
|
102
125
|
},
|
|
103
126
|
{
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { tabStopId } from "./list-focus.ts";
|
|
4
|
+
|
|
5
|
+
const ids = ["a", "b", "c"];
|
|
6
|
+
|
|
7
|
+
describe("tabStopId", () => {
|
|
8
|
+
test("the cursor row holds the tab stop", () => {
|
|
9
|
+
assert.strictEqual(tabStopId(ids, "b"), "b");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("an untouched list puts the tab stop on the first row", () => {
|
|
13
|
+
assert.strictEqual(tabStopId(ids, undefined), "a");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("a cursor that no longer exists falls back to the first row", () => {
|
|
17
|
+
// After a delete or a refetch the cursor can name a row that is gone; the
|
|
18
|
+
// list must keep a tab stop or Tab skips over it entirely.
|
|
19
|
+
assert.strictEqual(tabStopId(ids, "zzz"), "a");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("an empty list has no tab stop", () => {
|
|
23
|
+
assert.strictEqual(tabStopId([], "a"), undefined);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roving-tabindex math for the message list.
|
|
3
|
+
*
|
|
4
|
+
* A virtualized list of hundreds of rows must expose exactly one tab stop:
|
|
5
|
+
* Tab then moves focus into the list at the keyboard cursor and Shift+Tab moves
|
|
6
|
+
* back out to the side panel, instead of walking every row. Pure so the rule is
|
|
7
|
+
* testable without a DOM.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The id of the row that holds the list's tab stop. The keyboard cursor owns it
|
|
12
|
+
* while it points at a loaded row; otherwise the first row does, so an untouched
|
|
13
|
+
* list is still reachable with one Tab.
|
|
14
|
+
*/
|
|
15
|
+
export function tabStopId(
|
|
16
|
+
orderedIds: string[],
|
|
17
|
+
focusedId: string | undefined,
|
|
18
|
+
): string | undefined {
|
|
19
|
+
if (orderedIds.length === 0) return undefined;
|
|
20
|
+
if (focusedId !== undefined && orderedIds.includes(focusedId)) {
|
|
21
|
+
return focusedId;
|
|
22
|
+
}
|
|
23
|
+
return orderedIds[0];
|
|
24
|
+
}
|