@remit/ui 0.0.54 → 0.0.56
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/components/filter-sheet.stories.tsx +2 -6
- package/src/components/message-list-pane.render.test.ts +31 -0
- package/src/components/message-list-pane.stories.tsx +30 -0
- package/src/components/message-list-state.stories.tsx +3 -3
- package/src/components/swipeable-row.gesture.test.ts +128 -2
- package/src/components/swipeable-row.stories.tsx +54 -0
- package/src/components/swipeable-row.tsx +60 -31
- package/src/filter-presets.test.ts +16 -11
- package/src/filter-presets.ts +5 -20
- package/src/index.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
-
import { UNCLASSIFIED_CATEGORY } from "../filter-presets.js";
|
|
4
3
|
import {
|
|
5
4
|
FilterSheet,
|
|
6
5
|
type FilterSheetCategory,
|
|
@@ -11,7 +10,7 @@ import {
|
|
|
11
10
|
const CATEGORIES: FilterSheetCategory[] = [
|
|
12
11
|
{ id: "all", label: "All", tone: "neutral" },
|
|
13
12
|
{ id: "personal", label: "Personal", tone: "positive" },
|
|
14
|
-
|
|
13
|
+
{ id: "uncategorized", label: "Unclassified", tone: "neutral" },
|
|
15
14
|
{ id: "newsletters", label: "Newsletters", tone: "accent" },
|
|
16
15
|
{ id: "marketing", label: "Marketing", tone: "warning" },
|
|
17
16
|
{ id: "automated", label: "Automated", tone: "neutral" },
|
|
@@ -136,10 +135,7 @@ export const CollapsedWithActiveFilters: Story = {
|
|
|
136
135
|
*/
|
|
137
136
|
export const CollapsedWithUnclassified: Story = {
|
|
138
137
|
render: () => (
|
|
139
|
-
<ControlledShell
|
|
140
|
-
initialExpanded={false}
|
|
141
|
-
initialCategory={UNCLASSIFIED_CATEGORY.id}
|
|
142
|
-
/>
|
|
138
|
+
<ControlledShell initialExpanded={false} initialCategory="uncategorized" />
|
|
143
139
|
),
|
|
144
140
|
};
|
|
145
141
|
|
|
@@ -121,6 +121,37 @@ describe("MessageListPane", () => {
|
|
|
121
121
|
assert.match(html, /Report a problem/);
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
it("forwards the filter to the empty state so a narrowed list says so (#306)", () => {
|
|
125
|
+
const html = renderToString(
|
|
126
|
+
createElement(MessageListPane, {
|
|
127
|
+
...baseProps,
|
|
128
|
+
isDesktop: true,
|
|
129
|
+
listState: "empty",
|
|
130
|
+
listFilter: {
|
|
131
|
+
label: "Personal",
|
|
132
|
+
reach: "whole-folder",
|
|
133
|
+
onClear: () => undefined,
|
|
134
|
+
},
|
|
135
|
+
listScopeLabel: "Inbox",
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
assert.match(html, /No Personal mail in Inbox/);
|
|
139
|
+
assert.match(html, /Every message in this folder was checked\./);
|
|
140
|
+
assert.doesNotMatch(html, /No messages in this mailbox/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("keeps the plain empty copy when nothing narrows the list", () => {
|
|
144
|
+
const html = renderToString(
|
|
145
|
+
createElement(MessageListPane, {
|
|
146
|
+
...baseProps,
|
|
147
|
+
isDesktop: true,
|
|
148
|
+
listState: "empty",
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
assert.match(html, /No messages in this mailbox/);
|
|
152
|
+
assert.doesNotMatch(html, /Every message in this folder was checked\./);
|
|
153
|
+
});
|
|
154
|
+
|
|
124
155
|
it("renders the selectionBar slot instead of the pane header when provided", () => {
|
|
125
156
|
const html = renderToString(
|
|
126
157
|
createElement(MessageListPane, {
|
|
@@ -258,6 +258,36 @@ export const NarrowExternalSelectionBar: Story = {
|
|
|
258
258
|
decorators: [narrowFrame],
|
|
259
259
|
};
|
|
260
260
|
|
|
261
|
+
/** An empty mailbox, unfiltered: one plain line and no completeness claim. */
|
|
262
|
+
export const EmptyState: Story = {
|
|
263
|
+
args: {
|
|
264
|
+
isDesktop: true,
|
|
265
|
+
flatList: true,
|
|
266
|
+
listState: "empty",
|
|
267
|
+
},
|
|
268
|
+
decorators: [desktopFrame],
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The same pane under a category filter — the composition the inbox ships
|
|
273
|
+
* (#306). The pane forwards the filter and the scope to the empty state, so a
|
|
274
|
+
* narrowed list states what was read instead of reading as an empty mailbox.
|
|
275
|
+
*/
|
|
276
|
+
export const FilteredEmptyState: Story = {
|
|
277
|
+
args: {
|
|
278
|
+
isDesktop: true,
|
|
279
|
+
flatList: true,
|
|
280
|
+
listState: "empty",
|
|
281
|
+
listFilter: {
|
|
282
|
+
label: "Personal",
|
|
283
|
+
reach: "whole-folder",
|
|
284
|
+
onClear: () => undefined,
|
|
285
|
+
},
|
|
286
|
+
listScopeLabel: "Inbox",
|
|
287
|
+
},
|
|
288
|
+
decorators: [desktopFrame],
|
|
289
|
+
};
|
|
290
|
+
|
|
261
291
|
/** Fail-loud error state — the specific failure detail is surfaced under the
|
|
262
292
|
* headline (not a bare "something went wrong"), with a way back (Retry) and a
|
|
263
293
|
* place for the failure to go (Report a problem). */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Decorator, Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
|
-
import { inboxFilterConfig
|
|
3
|
+
import { inboxFilterConfig } from "../filter-presets.js";
|
|
4
4
|
import type { ThreadRowData } from "./app-shell-types.js";
|
|
5
5
|
import { FilterSheet } from "./filter-sheet.js";
|
|
6
6
|
import {
|
|
@@ -89,7 +89,7 @@ function FilteredShell({
|
|
|
89
89
|
return (
|
|
90
90
|
<div className="flex h-full flex-col">
|
|
91
91
|
<FilterSheet
|
|
92
|
-
categories={
|
|
92
|
+
categories={preset.categories}
|
|
93
93
|
filters={preset.filters}
|
|
94
94
|
selectedCategory={category}
|
|
95
95
|
activeFilters={new Set<string>()}
|
|
@@ -220,7 +220,7 @@ export const FilteredEmptyUnclassified: Story = {
|
|
|
220
220
|
scopeLabel: "Inbox",
|
|
221
221
|
},
|
|
222
222
|
render: (args) => (
|
|
223
|
-
<FilteredShell category=
|
|
223
|
+
<FilteredShell category="uncategorized">
|
|
224
224
|
<MessageListEmpty {...args} />
|
|
225
225
|
</FilteredShell>
|
|
226
226
|
),
|
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
* The fix tags SwipeableRow's own axis-abort cancel so it can tell the two
|
|
12
12
|
* apart; these tests exercise both paths against the real hook, not a
|
|
13
13
|
* description of the fix.
|
|
14
|
+
*
|
|
15
|
+
* The second: a finger held on glass drifts, and it drifts past the distance
|
|
16
|
+
* at which the drag starts tracking. Cancelling the press there meant a real
|
|
17
|
+
* hold on a phone never entered selection mode — the row followed the drift
|
|
18
|
+
* and snapped back, and nothing else happened. The drift cases below run
|
|
19
|
+
* against the anchor (`linkComponent`) row the mailbox list actually renders,
|
|
20
|
+
* which is also where the native link menu has to stay suppressed.
|
|
14
21
|
*/
|
|
15
22
|
|
|
16
23
|
import assert from "node:assert/strict";
|
|
@@ -116,6 +123,45 @@ function mount(handlers: Handlers) {
|
|
|
116
123
|
return row;
|
|
117
124
|
}
|
|
118
125
|
|
|
126
|
+
// The mailbox list renders the open affordance as a real anchor via
|
|
127
|
+
// `linkComponent`; the native long-press menu only bites on an `<a href>`.
|
|
128
|
+
function mountAnchor(handlers: Handlers) {
|
|
129
|
+
act(() => {
|
|
130
|
+
root.render(
|
|
131
|
+
createElement(SwipeableRow, {
|
|
132
|
+
thread,
|
|
133
|
+
selectionMode: false,
|
|
134
|
+
checked: false,
|
|
135
|
+
active: false,
|
|
136
|
+
peek: "none",
|
|
137
|
+
onPeek: handlers.onPeek,
|
|
138
|
+
onToggleCheck: () => undefined,
|
|
139
|
+
onLongPress: handlers.onLongPress,
|
|
140
|
+
onOpen: handlers.onOpen,
|
|
141
|
+
onAct: () => undefined,
|
|
142
|
+
linkComponent: ({ onOpenClick, children, ...rowProps }) =>
|
|
143
|
+
createElement(
|
|
144
|
+
"a",
|
|
145
|
+
{ ...rowProps, id: "row", href: "/thread/1", onClick: onOpenClick },
|
|
146
|
+
children,
|
|
147
|
+
),
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
const row = dom.window.document.getElementById("row");
|
|
152
|
+
assert.ok(row, "anchor row did not mount");
|
|
153
|
+
return row;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function contextMenu(row: Element) {
|
|
157
|
+
const event = new dom.window.MouseEvent("contextmenu", {
|
|
158
|
+
bubbles: true,
|
|
159
|
+
cancelable: true,
|
|
160
|
+
});
|
|
161
|
+
row.dispatchEvent(event);
|
|
162
|
+
return event;
|
|
163
|
+
}
|
|
164
|
+
|
|
119
165
|
// Each dispatch is wrapped in a synchronous act() so React commits the
|
|
120
166
|
// resulting state update before the next call reads it. Without this, a
|
|
121
167
|
// handler in the next dispatch can close over a stale pre-commit value (a
|
|
@@ -195,7 +241,7 @@ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)
|
|
|
195
241
|
);
|
|
196
242
|
});
|
|
197
243
|
|
|
198
|
-
it("a horizontal drag past the
|
|
244
|
+
it("a horizontal drag past the escape distance cancels the long press and commits a swipe peek, not onOpen", async () => {
|
|
199
245
|
let longPressed = 0;
|
|
200
246
|
let opened = 0;
|
|
201
247
|
let committed: SwipePeek | undefined;
|
|
@@ -221,7 +267,7 @@ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)
|
|
|
221
267
|
assert.equal(committed, "leading");
|
|
222
268
|
});
|
|
223
269
|
|
|
224
|
-
it("a vertical drag past the
|
|
270
|
+
it("a vertical drag past the escape distance cancels the long press and lets scroll win (no peek, no open)", async () => {
|
|
225
271
|
let longPressed = 0;
|
|
226
272
|
let opened = 0;
|
|
227
273
|
let peeked = 0;
|
|
@@ -255,4 +301,84 @@ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)
|
|
|
255
301
|
|
|
256
302
|
assert.equal(longPressed, 1);
|
|
257
303
|
});
|
|
304
|
+
|
|
305
|
+
it("a hold that drifts past the axis threshold still enters selection mode", async () => {
|
|
306
|
+
// The reported phone bug: the row tracked the drift (the visible shiver)
|
|
307
|
+
// and the press died, but the drift was too short to commit a peek, so
|
|
308
|
+
// the whole gesture produced nothing.
|
|
309
|
+
let longPressed = 0;
|
|
310
|
+
let opened = 0;
|
|
311
|
+
const peeks: SwipePeek[] = [];
|
|
312
|
+
const row = mountAnchor({
|
|
313
|
+
onLongPress: () => longPressed++,
|
|
314
|
+
onOpen: () => opened++,
|
|
315
|
+
onPeek: (next) => peeks.push(next),
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
pointerDown(row, 100, 200);
|
|
319
|
+
pointerMove(row, 103, 201);
|
|
320
|
+
pointerMove(row, 106, 203);
|
|
321
|
+
pointerMove(row, 109, 205);
|
|
322
|
+
pointerMove(row, 112, 206); // dx=12 — past the axis threshold, under the escape
|
|
323
|
+
await wait(THRESHOLD_WAIT);
|
|
324
|
+
pointerUp(row);
|
|
325
|
+
|
|
326
|
+
assert.equal(longPressed, 1);
|
|
327
|
+
assert.equal(opened, 0);
|
|
328
|
+
assert.deepEqual(peeks, [], "drift must not commit or reset a peek");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("a hold that drifts vertically past the axis threshold still enters selection mode", async () => {
|
|
332
|
+
let longPressed = 0;
|
|
333
|
+
const peeks: SwipePeek[] = [];
|
|
334
|
+
const row = mountAnchor({
|
|
335
|
+
onLongPress: () => longPressed++,
|
|
336
|
+
onOpen: () => undefined,
|
|
337
|
+
onPeek: (next) => peeks.push(next),
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
pointerDown(row, 100, 200);
|
|
341
|
+
pointerMove(row, 101, 206);
|
|
342
|
+
pointerMove(row, 102, 213); // dy=13 — past the axis threshold, under the escape
|
|
343
|
+
await wait(THRESHOLD_WAIT);
|
|
344
|
+
pointerUp(row);
|
|
345
|
+
|
|
346
|
+
assert.equal(longPressed, 1);
|
|
347
|
+
assert.deepEqual(peeks, []);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("an aborted short drag opens nothing on release", async () => {
|
|
351
|
+
// The drag claimed the axis but never reached the escape distance, so the
|
|
352
|
+
// press was still live. react-aria synthesizes a click for an unresolved
|
|
353
|
+
// press, which on the anchor row is a navigation.
|
|
354
|
+
let clicks = 0;
|
|
355
|
+
const row = mountAnchor({
|
|
356
|
+
onLongPress: () => undefined,
|
|
357
|
+
onOpen: () => undefined,
|
|
358
|
+
onPeek: () => undefined,
|
|
359
|
+
});
|
|
360
|
+
row.addEventListener("click", () => clicks++);
|
|
361
|
+
|
|
362
|
+
pointerDown(row, 100, 200);
|
|
363
|
+
pointerMove(row, 120, 201); // dx=20 — past the axis threshold, under the escape
|
|
364
|
+
pointerUp(row);
|
|
365
|
+
await wait(200); // react-aria synthesizes its click 80ms after release
|
|
366
|
+
|
|
367
|
+
assert.equal(clicks, 0);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("suppresses the native link menu raised over a drifting hold", async () => {
|
|
371
|
+
// Android Chrome raises the anchor's context menu at its own threshold,
|
|
372
|
+
// which can land mid-drift and ahead of the app's long press.
|
|
373
|
+
const row = mountAnchor({
|
|
374
|
+
onLongPress: () => undefined,
|
|
375
|
+
onOpen: () => undefined,
|
|
376
|
+
onPeek: () => undefined,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
pointerDown(row, 100, 200);
|
|
380
|
+
pointerMove(row, 112, 206);
|
|
381
|
+
|
|
382
|
+
assert.equal(contextMenu(row).defaultPrevented, true);
|
|
383
|
+
});
|
|
258
384
|
});
|
|
@@ -192,6 +192,60 @@ export const LongPressToSelect: Story = {
|
|
|
192
192
|
},
|
|
193
193
|
};
|
|
194
194
|
|
|
195
|
+
/**
|
|
196
|
+
* The same gesture with a real finger's drift. A hand holding still on glass
|
|
197
|
+
* wanders several pixels over the 500ms hold — further than the distance at
|
|
198
|
+
* which the drag starts tracking the row, which is why a hold used to nudge
|
|
199
|
+
* the row and then do nothing. The swipe only takes the gesture once it has
|
|
200
|
+
* travelled far enough to commit a peek, so the drifting hold below still ends
|
|
201
|
+
* in selection mode. The play step drives the press, the drift and the hold.
|
|
202
|
+
*/
|
|
203
|
+
export const LongPressWithDrift: Story = {
|
|
204
|
+
name: "Long press with finger drift (interactive)",
|
|
205
|
+
render: () => {
|
|
206
|
+
const [selected, setSelected] = useState(false);
|
|
207
|
+
return (
|
|
208
|
+
<div className="space-y-2">
|
|
209
|
+
<AnchorRow
|
|
210
|
+
selectionMode={selected}
|
|
211
|
+
checked={selected}
|
|
212
|
+
onLongPress={() => setSelected(true)}
|
|
213
|
+
/>
|
|
214
|
+
<p data-testid="drift-outcome" className="text-xs text-fg-muted">
|
|
215
|
+
{selected
|
|
216
|
+
? "Drifting hold entered selection mode."
|
|
217
|
+
: "Waiting for the drifting hold…"}
|
|
218
|
+
</p>
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
},
|
|
222
|
+
play: async ({ canvasElement }) => {
|
|
223
|
+
const anchor = canvasElement.querySelector<HTMLAnchorElement>("a[href]");
|
|
224
|
+
if (!anchor) return;
|
|
225
|
+
const touch = (type: string, clientX: number, clientY: number) =>
|
|
226
|
+
anchor.dispatchEvent(
|
|
227
|
+
new PointerEvent(type, {
|
|
228
|
+
bubbles: true,
|
|
229
|
+
pointerType: "touch",
|
|
230
|
+
pointerId: 1,
|
|
231
|
+
clientX,
|
|
232
|
+
clientY,
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
touch("pointerdown", 100, 200);
|
|
236
|
+
for (const [x, y] of [
|
|
237
|
+
[103, 201],
|
|
238
|
+
[106, 203],
|
|
239
|
+
[109, 205],
|
|
240
|
+
[112, 206],
|
|
241
|
+
]) {
|
|
242
|
+
touch("pointermove", x, y);
|
|
243
|
+
}
|
|
244
|
+
await new Promise((resolve) => setTimeout(resolve, 700));
|
|
245
|
+
touch("pointerup", 112, 206);
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
195
249
|
/**
|
|
196
250
|
* A touch long press over a link row normally raises the browser's own link
|
|
197
251
|
* context menu ("Open in new tab / Copy link address"), which collides with the
|
|
@@ -16,20 +16,36 @@ export type SwipePeek = "none" | "leading" | "trailing";
|
|
|
16
16
|
/** Width a peeked row settles at to reveal its action; also the drag distance
|
|
17
17
|
* past which a release commits the peek rather than snapping back. */
|
|
18
18
|
const SWIPE_ACTION_WIDTH = 72;
|
|
19
|
-
/** Movement (px) before a pointer drag claims
|
|
20
|
-
*
|
|
19
|
+
/** Movement (px) before a pointer drag claims an axis; below this a press is
|
|
20
|
+
* still a tap / long-press and vertical scroll wins. Claiming an axis only
|
|
21
|
+
* decides what the row tracks — it does not decide the long press. */
|
|
21
22
|
const SWIPE_AXIS_THRESHOLD = 10;
|
|
23
|
+
/**
|
|
24
|
+
* Movement (px) along the claimed axis before a drag takes the gesture away
|
|
25
|
+
* from a still-pending long press.
|
|
26
|
+
*
|
|
27
|
+
* A finger resting on glass for the 500ms hold drifts, and it drifts further
|
|
28
|
+
* than the axis threshold — that threshold is calibrated for a drag starting
|
|
29
|
+
* to track, which has to feel immediate. Killing the press at that distance
|
|
30
|
+
* meant a real hold read as a swipe: the row followed the drift, the press
|
|
31
|
+
* died, and on release the drift was too short to commit a peek, so the
|
|
32
|
+
* gesture produced nothing at all.
|
|
33
|
+
*
|
|
34
|
+
* The escape distance is the commit distance, so the swipe only takes the
|
|
35
|
+
* gesture once it has moved far enough to actually produce a peek. Below it a
|
|
36
|
+
* release snaps back, which is to say the swipe was never worth the press.
|
|
37
|
+
*/
|
|
38
|
+
const LONG_PRESS_ESCAPE = SWIPE_ACTION_WIDTH / 2;
|
|
22
39
|
|
|
23
40
|
/**
|
|
24
|
-
* Tags a pointercancel dispatched by this component's own
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* firing a spurious onOpen/onToggleCheck.
|
|
41
|
+
* Tags a pointercancel dispatched by this component's own arbitration (see
|
|
42
|
+
* `cancelLongPress` below) so `onPointerCancel` can tell it apart from one
|
|
43
|
+
* react-aria dispatches itself when its own long press fires, or a genuine
|
|
44
|
+
* browser-triggered cancel. Both of those end the gesture and must reset it
|
|
45
|
+
* silently; a tagged one arrives *because* `onPointerMove`/`onPointerUp` is
|
|
46
|
+
* already handling that gesture inline, so `onPointerCancel` must ignore it —
|
|
47
|
+
* otherwise it drops the drag and the release reads as a tap, firing a
|
|
48
|
+
* spurious onOpen/onToggleCheck.
|
|
33
49
|
*/
|
|
34
50
|
const AXIS_CANCEL = "__swipeableRowAxisCancel";
|
|
35
51
|
|
|
@@ -104,7 +120,8 @@ export function SwipeableRow({
|
|
|
104
120
|
startX: number;
|
|
105
121
|
startY: number;
|
|
106
122
|
axis: "none" | "horizontal" | "vertical";
|
|
107
|
-
|
|
123
|
+
/** The drag has passed LONG_PRESS_ESCAPE and already cancelled the press. */
|
|
124
|
+
escaped: boolean;
|
|
108
125
|
} | null>(null);
|
|
109
126
|
const [dragX, setDragX] = useState<number | null>(null);
|
|
110
127
|
|
|
@@ -131,7 +148,7 @@ export function SwipeableRow({
|
|
|
131
148
|
startX: e.clientX,
|
|
132
149
|
startY: e.clientY,
|
|
133
150
|
axis: "none",
|
|
134
|
-
|
|
151
|
+
escaped: false,
|
|
135
152
|
};
|
|
136
153
|
};
|
|
137
154
|
|
|
@@ -143,19 +160,25 @@ export function SwipeableRow({
|
|
|
143
160
|
const dy = e.clientY - g.startY;
|
|
144
161
|
if (g.axis === "none") {
|
|
145
162
|
if (Math.abs(dy) > SWIPE_AXIS_THRESHOLD && Math.abs(dy) > Math.abs(dx)) {
|
|
146
|
-
// vertical scroll wins:
|
|
163
|
+
// vertical scroll wins: stop tracking the swipe, let the list scroll
|
|
147
164
|
g.axis = "vertical";
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (Math.abs(dx) > SWIPE_AXIS_THRESHOLD) {
|
|
165
|
+
} else if (
|
|
166
|
+
Math.abs(dx) > SWIPE_AXIS_THRESHOLD &&
|
|
167
|
+
Math.abs(dx) > Math.abs(dy)
|
|
168
|
+
) {
|
|
153
169
|
g.axis = "horizontal";
|
|
154
|
-
g.moved = true;
|
|
155
|
-
cancelLongPress(e);
|
|
156
170
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
157
171
|
}
|
|
158
172
|
}
|
|
173
|
+
if (g.axis === "none") return;
|
|
174
|
+
// The drag only takes the gesture from a pending long press once it has
|
|
175
|
+
// travelled the commit distance along the axis it claimed (see
|
|
176
|
+
// LONG_PRESS_ESCAPE). Latched so the cancel is dispatched once.
|
|
177
|
+
const travel = g.axis === "horizontal" ? Math.abs(dx) : Math.abs(dy);
|
|
178
|
+
if (!g.escaped && travel > LONG_PRESS_ESCAPE) {
|
|
179
|
+
g.escaped = true;
|
|
180
|
+
cancelLongPress(e);
|
|
181
|
+
}
|
|
159
182
|
if (g.axis !== "horizontal") return;
|
|
160
183
|
const base = peekOffset(peek);
|
|
161
184
|
const next = Math.max(
|
|
@@ -165,18 +188,25 @@ export function SwipeableRow({
|
|
|
165
188
|
setDragX(next);
|
|
166
189
|
};
|
|
167
190
|
|
|
168
|
-
const onPointerUp = () => {
|
|
191
|
+
const onPointerUp = (e: React.PointerEvent) => {
|
|
169
192
|
const g = gesture.current;
|
|
170
193
|
gesture.current = null;
|
|
171
194
|
const offset = dragX;
|
|
172
195
|
setDragX(null);
|
|
173
|
-
// g is null when the gesture was already consumed (long
|
|
174
|
-
// a
|
|
196
|
+
// g is null when the gesture was already consumed (the long press fired,
|
|
197
|
+
// or a real cancel arrived) — those handle themselves, so do nothing.
|
|
175
198
|
if (!g) return;
|
|
176
|
-
|
|
177
|
-
|
|
199
|
+
// A drag that claimed an axis but never escaped left react-aria's press
|
|
200
|
+
// live, and an unresolved press synthesizes a click on release — which on
|
|
201
|
+
// the anchor row means navigating. The drag was neither a swipe nor a
|
|
202
|
+
// tap, so end the press with it.
|
|
203
|
+
if (g.axis !== "none" && !g.escaped) cancelLongPress(e);
|
|
204
|
+
if (g.axis === "horizontal") {
|
|
205
|
+
if (offset !== null) onPeek(commitPeek(offset));
|
|
178
206
|
return;
|
|
179
207
|
}
|
|
208
|
+
// The list scrolled under the finger; the release is not a tap.
|
|
209
|
+
if (g.axis === "vertical") return;
|
|
180
210
|
// a tap: no axis claimed
|
|
181
211
|
if (selectionMode) {
|
|
182
212
|
onToggleCheck();
|
|
@@ -192,11 +222,10 @@ export function SwipeableRow({
|
|
|
192
222
|
};
|
|
193
223
|
|
|
194
224
|
// A genuine cancel — react-aria's own dispatch when its long press fires,
|
|
195
|
-
// or a real browser-triggered interruption
|
|
196
|
-
// as a tap-to-open/toggle/peek
|
|
197
|
-
// AXIS_CANCEL) is a no-op
|
|
198
|
-
//
|
|
199
|
-
// (vertical) — see the comment there.
|
|
225
|
+
// or a real browser-triggered interruption (the browser taking over the
|
|
226
|
+
// pan, say) — always resets silently, never as a tap-to-open/toggle/peek
|
|
227
|
+
// -commit. This component's own cancel (tagged, see AXIS_CANCEL) is a no-op
|
|
228
|
+
// here: the handler that dispatched it owns that gesture's state.
|
|
200
229
|
const onPointerCancel = (e: React.PointerEvent) => {
|
|
201
230
|
const tagged = (e.nativeEvent as unknown as Record<string, boolean>)[
|
|
202
231
|
AXIS_CANCEL
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
type FilterAccount,
|
|
6
6
|
flaggedFilterConfig,
|
|
7
7
|
inboxFilterConfig,
|
|
8
|
-
UNCLASSIFIED_CATEGORY,
|
|
9
8
|
} from "./filter-presets.js";
|
|
10
9
|
|
|
11
10
|
const accounts: FilterAccount[] = [
|
|
@@ -23,6 +22,7 @@ describe("briefFilterConfig", () => {
|
|
|
23
22
|
[
|
|
24
23
|
"all",
|
|
25
24
|
"personal",
|
|
25
|
+
"uncategorized",
|
|
26
26
|
"transactional",
|
|
27
27
|
"newsletter",
|
|
28
28
|
"marketing",
|
|
@@ -100,26 +100,31 @@ describe("flaggedFilterConfig", () => {
|
|
|
100
100
|
});
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
-
describe("
|
|
104
|
-
it("is
|
|
103
|
+
describe("the Unclassified category", () => {
|
|
104
|
+
it("is offered by every shipped preset", () => {
|
|
105
105
|
for (const preset of [
|
|
106
106
|
briefFilterConfig(),
|
|
107
107
|
inboxFilterConfig(),
|
|
108
108
|
flaggedFilterConfig(),
|
|
109
109
|
]) {
|
|
110
110
|
assert.equal(
|
|
111
|
-
preset.categories.some((c) => c.id ===
|
|
112
|
-
|
|
111
|
+
preset.categories.some((c) => c.id === "uncategorized"),
|
|
112
|
+
true,
|
|
113
113
|
);
|
|
114
114
|
}
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
it("follows Personal, the order the brief already uses", () => {
|
|
118
|
+
const ids = inboxFilterConfig().categories.map((c) => c.id);
|
|
119
|
+
assert.equal(ids.indexOf("uncategorized"), ids.indexOf("personal") + 1);
|
|
120
|
+
});
|
|
121
|
+
|
|
117
122
|
it("carries its own label and tone, never personal's (#45)", () => {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
);
|
|
121
|
-
assert.equal(
|
|
122
|
-
assert.notEqual(
|
|
123
|
-
assert.notEqual(
|
|
123
|
+
const categories = briefFilterConfig().categories;
|
|
124
|
+
const unclassified = categories.find((c) => c.id === "uncategorized");
|
|
125
|
+
const personal = categories.find((c) => c.id === "personal");
|
|
126
|
+
assert.equal(unclassified?.label, "Unclassified");
|
|
127
|
+
assert.notEqual(unclassified?.label, personal?.label);
|
|
128
|
+
assert.notEqual(unclassified?.tone, personal?.tone);
|
|
124
129
|
});
|
|
125
130
|
});
|
package/src/filter-presets.ts
CHANGED
|
@@ -27,35 +27,20 @@ export interface FilterPreset {
|
|
|
27
27
|
sources?: FilterSheetSource[];
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
* The Unclassified chip D6 requires, built and storied but not offered yet.
|
|
32
|
-
*
|
|
33
|
-
* This module is a runtime dependency of three live surfaces that filter over
|
|
34
|
-
* the loaded window, so a chip in the list below is a chip a user can click
|
|
35
|
-
* today. Offering it before #306 makes the predicate a server-side `where`
|
|
36
|
-
* would add a fresh instance of the bug this epic exists to fix: a category
|
|
37
|
-
* whose mail sits below the newest page reads as a category with no mail.
|
|
38
|
-
*
|
|
39
|
-
* #306 splices this entry into `MESSAGE_CATEGORIES` after `Personal` — matching
|
|
40
|
-
* `briefCategories`' order — and deletes this constant.
|
|
41
|
-
*/
|
|
42
|
-
export const UNCLASSIFIED_CATEGORY: FilterSheetCategory = {
|
|
43
|
-
id: "uncategorized",
|
|
44
|
-
label: "Unclassified",
|
|
45
|
-
tone: "neutral",
|
|
46
|
-
};
|
|
47
|
-
|
|
48
30
|
/**
|
|
49
31
|
* Content-type categories, mirroring the `MessageCategory` enum
|
|
50
32
|
* (@remit/remit-imap) by value. The leading "all" clears the category. Per
|
|
51
33
|
* message, not per mailbox — so they apply in the brief and an inbox alike.
|
|
52
34
|
*
|
|
53
|
-
* `uncategorized` is
|
|
54
|
-
* (issue #45)
|
|
35
|
+
* `uncategorized` is a category of its own here, never folded into `personal`
|
|
36
|
+
* (issue #45): mail the classifier has not reached is a pending state with a
|
|
37
|
+
* name, and it carries its own label and tone so the two can never read alike.
|
|
38
|
+
* It sits after `Personal` to match `briefCategories`' order.
|
|
55
39
|
*/
|
|
56
40
|
const MESSAGE_CATEGORIES: FilterSheetCategory[] = [
|
|
57
41
|
{ id: "all", label: "All", tone: "neutral" },
|
|
58
42
|
{ id: "personal", label: "Personal", tone: "accent" },
|
|
43
|
+
{ id: "uncategorized", label: "Unclassified", tone: "neutral" },
|
|
59
44
|
{ id: "transactional", label: "Transactional", tone: "positive" },
|
|
60
45
|
{ id: "newsletter", label: "Newsletter", tone: "neutral" },
|
|
61
46
|
{ id: "marketing", label: "Marketing", tone: "warning" },
|
package/src/index.ts
CHANGED
|
@@ -249,6 +249,7 @@ export {
|
|
|
249
249
|
} from "./components/message-header.js";
|
|
250
250
|
export { MessageListPane } from "./components/message-list-pane.js";
|
|
251
251
|
export {
|
|
252
|
+
type FilterReach,
|
|
252
253
|
type ListState,
|
|
253
254
|
MessageListEmpty,
|
|
254
255
|
type MessageListEmptyProps,
|
|
@@ -545,7 +546,6 @@ export {
|
|
|
545
546
|
type FilterPreset,
|
|
546
547
|
flaggedFilterConfig,
|
|
547
548
|
inboxFilterConfig,
|
|
548
|
-
UNCLASSIFIED_CATEGORY,
|
|
549
549
|
} from "./filter-presets.js";
|
|
550
550
|
export {
|
|
551
551
|
buildCidResolver,
|