@remit/ui 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/components/button.render.test.ts +29 -0
- package/src/components/button.tsx +5 -1
- package/src/components/folder-role.test.ts +72 -0
- package/src/components/folder-role.tsx +59 -0
- package/src/components/mobile-search-view.render.test.ts +78 -0
- package/src/components/mobile-search-view.stories.tsx +117 -2
- package/src/components/mobile-search-view.tsx +20 -1
- package/src/components/primitives.stories.tsx +6 -0
- package/src/components/progress-bar.render.test.ts +46 -0
- package/src/components/progress-bar.stories.tsx +35 -0
- package/src/components/progress-bar.tsx +62 -0
- package/src/components/search-result-row.tsx +23 -0
- package/src/components/search-results.render.test.ts +181 -0
- package/src/components/search-results.stories.tsx +165 -28
- package/src/components/search-results.tsx +101 -5
- package/src/components/selection-top-bar.render.test.ts +167 -17
- package/src/components/selection-top-bar.stories.tsx +106 -16
- package/src/components/selection-top-bar.tsx +117 -64
- package/src/components/spam-results-offer.render.test.ts +26 -0
- package/src/components/spam-results-offer.stories.tsx +41 -0
- package/src/components/spam-results-offer.tsx +52 -0
- package/src/components/swipeable-row.render.test.ts +29 -0
- package/src/components/swipeable-row.tsx +42 -2
- package/src/components/touch-list-body.render.test.ts +25 -0
- package/src/components/touch-list-body.stories.tsx +15 -0
- package/src/components/touch-list.tsx +26 -15
- package/src/index.ts +15 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { SpamResultsOffer } from "./spam-results-offer.js";
|
|
6
|
+
|
|
7
|
+
const noop = () => {};
|
|
8
|
+
|
|
9
|
+
describe("SpamResultsOffer", () => {
|
|
10
|
+
it("states the count and offers a way into Spam", () => {
|
|
11
|
+
const html = renderToString(
|
|
12
|
+
createElement(SpamResultsOffer, { count: 3, onScopeToSpam: noop }),
|
|
13
|
+
);
|
|
14
|
+
assert.match(html, />3</);
|
|
15
|
+
assert.match(html, /results from Spam/);
|
|
16
|
+
assert.match(html, /View them/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("reads in the singular for one match", () => {
|
|
20
|
+
const html = renderToString(
|
|
21
|
+
createElement(SpamResultsOffer, { count: 1, onScopeToSpam: noop }),
|
|
22
|
+
);
|
|
23
|
+
assert.match(html, /result from Spam/);
|
|
24
|
+
assert.doesNotMatch(html, /results from Spam/);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Decorator, Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { SpamResultsOffer } from "./spam-results-offer.js";
|
|
3
|
+
|
|
4
|
+
/** Frames the offer at the width of the list pane it sits at the top of. */
|
|
5
|
+
const listPaneFrame: Decorator = (Story) => (
|
|
6
|
+
<div
|
|
7
|
+
className="overflow-hidden rounded-lg border border-line bg-canvas"
|
|
8
|
+
style={{ width: 360 }}
|
|
9
|
+
>
|
|
10
|
+
<Story />
|
|
11
|
+
</div>
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const meta: Meta<typeof SpamResultsOffer> = {
|
|
15
|
+
title: "Components/SpamResultsOffer",
|
|
16
|
+
component: SpamResultsOffer,
|
|
17
|
+
parameters: { layout: "centered" },
|
|
18
|
+
decorators: [listPaneFrame],
|
|
19
|
+
};
|
|
20
|
+
export default meta;
|
|
21
|
+
|
|
22
|
+
type Story = StoryObj<typeof SpamResultsOffer>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Spam matches a global search held out of its results. Deliberately quiet —
|
|
26
|
+
* an offer, not a warning — and the action scopes the search to Spam rather
|
|
27
|
+
* than opening a view of its own.
|
|
28
|
+
*/
|
|
29
|
+
export const Default: Story = {
|
|
30
|
+
args: { count: 3, onScopeToSpam: () => {} },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** One match reads in the singular. */
|
|
34
|
+
export const SingleResult: Story = {
|
|
35
|
+
args: { count: 1, onScopeToSpam: () => {} },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Large counts stay on one line; the figure is tabular so it does not jitter. */
|
|
39
|
+
export const ManyResults: Story = {
|
|
40
|
+
args: { count: 128, onScopeToSpam: () => {} },
|
|
41
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Banner } from "./banner.js";
|
|
2
|
+
import { Button } from "./button.js";
|
|
3
|
+
|
|
4
|
+
export interface SpamResultsOfferProps {
|
|
5
|
+
/** How many matches the search found in Spam. */
|
|
6
|
+
count: number;
|
|
7
|
+
/**
|
|
8
|
+
* Scope the search to Spam. This is a shortcut to the state reached by
|
|
9
|
+
* navigating to Spam with the query carried over — the same scoped search,
|
|
10
|
+
* with the same `in:spam` chip — not a separate result mode.
|
|
11
|
+
*/
|
|
12
|
+
onScopeToSpam: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const plural = (n: number): string => (n === 1 ? "result" : "results");
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Spam matches held out of a global search, offered rather than mixed in. Spam
|
|
19
|
+
* is the one folder a search that reaches everywhere does not inline, because
|
|
20
|
+
* the whole point of the folder is that its contents are unwanted until asked
|
|
21
|
+
* for. Taking the offer scopes the search to Spam.
|
|
22
|
+
*
|
|
23
|
+
* Quiet on purpose: this is an offer, not a warning. Presentational — the
|
|
24
|
+
* caller owns what "scope to spam" does.
|
|
25
|
+
*/
|
|
26
|
+
export function SpamResultsOffer({
|
|
27
|
+
count,
|
|
28
|
+
onScopeToSpam,
|
|
29
|
+
}: SpamResultsOfferProps) {
|
|
30
|
+
return (
|
|
31
|
+
<Banner
|
|
32
|
+
tone="info"
|
|
33
|
+
variant="soft"
|
|
34
|
+
className="items-center justify-between gap-3 rounded-none border-b border-line"
|
|
35
|
+
>
|
|
36
|
+
<div className="flex items-center justify-between gap-3">
|
|
37
|
+
<p className="min-w-0 text-xs text-fg-muted">
|
|
38
|
+
<span className="font-semibold text-fg tabular-nums">{count}</span>{" "}
|
|
39
|
+
{`${plural(count)} from Spam`}
|
|
40
|
+
</p>
|
|
41
|
+
<Button
|
|
42
|
+
variant="ghost"
|
|
43
|
+
size="sm"
|
|
44
|
+
onClick={onScopeToSpam}
|
|
45
|
+
className="shrink-0 text-accent"
|
|
46
|
+
>
|
|
47
|
+
View them
|
|
48
|
+
</Button>
|
|
49
|
+
</div>
|
|
50
|
+
</Banner>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -103,4 +103,33 @@ describe("SwipeableRow", () => {
|
|
|
103
103
|
assert.doesNotMatch(html, /<a /);
|
|
104
104
|
assert.match(html, /<button[^>]*>/);
|
|
105
105
|
});
|
|
106
|
+
|
|
107
|
+
it("gives the row checkbox semantics while in selection mode", () => {
|
|
108
|
+
const checked = render("none", { selectionMode: true, checked: true });
|
|
109
|
+
assert.match(checked, /role="checkbox"/);
|
|
110
|
+
assert.match(checked, /aria-checked="true"/);
|
|
111
|
+
|
|
112
|
+
const unchecked = render("none", { selectionMode: true, checked: false });
|
|
113
|
+
assert.match(unchecked, /aria-checked="false"/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("does not put checkbox semantics on the outer row outside selection mode", () => {
|
|
117
|
+
// Outside selection mode the row's own open control (the outer button)
|
|
118
|
+
// stays a plain button — only the nested leading-avatar toggle carries
|
|
119
|
+
// checkbox semantics, asserted separately below.
|
|
120
|
+
const html = render("none");
|
|
121
|
+
const outerTag = html.match(
|
|
122
|
+
/^<div class="relative overflow-hidden"><button type="button"[^>]*>/,
|
|
123
|
+
)?.[0];
|
|
124
|
+
assert.ok(outerTag, "outer row button found");
|
|
125
|
+
assert.doesNotMatch(outerTag as string, /role="checkbox"/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("renders the leading avatar as a focusable checkbox-role toggle outside selection mode", () => {
|
|
129
|
+
const html = render("none");
|
|
130
|
+
assert.match(
|
|
131
|
+
html,
|
|
132
|
+
/role="checkbox"[^>]*aria-label="Select message from Alex Rivera"/,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
106
135
|
});
|
|
@@ -2,8 +2,8 @@ import { Check, Mail, MailOpen, Trash2 } from "lucide-react";
|
|
|
2
2
|
import { useRef, useState } from "react";
|
|
3
3
|
import { cn } from "../lib/cn.js";
|
|
4
4
|
import type { ThreadRowData } from "./app-shell-types.js";
|
|
5
|
+
import { Avatar } from "./avatar.js";
|
|
5
6
|
import {
|
|
6
|
-
ComfortableRowBody,
|
|
7
7
|
ComfortableRowTextContent,
|
|
8
8
|
comfortableRowClass,
|
|
9
9
|
} from "./message-row.js";
|
|
@@ -192,6 +192,14 @@ export function SwipeableRow({
|
|
|
192
192
|
transition: dragX === null ? "transform 150ms ease" : "none",
|
|
193
193
|
minHeight: 44,
|
|
194
194
|
};
|
|
195
|
+
const unread = !thread.isRead;
|
|
196
|
+
|
|
197
|
+
// Stops the row's own pointer-gesture handlers (long-press, swipe axis
|
|
198
|
+
// detection) from also firing for a tap that started on the nested avatar
|
|
199
|
+
// toggle — the row and the toggle are two separate controls sharing the
|
|
200
|
+
// same leading 28px slot.
|
|
201
|
+
const stopRowGesture = (e: React.PointerEvent) => e.stopPropagation();
|
|
202
|
+
|
|
195
203
|
const body = selectionMode ? (
|
|
196
204
|
<>
|
|
197
205
|
<span
|
|
@@ -207,7 +215,36 @@ export function SwipeableRow({
|
|
|
207
215
|
<ComfortableRowTextContent thread={thread} />
|
|
208
216
|
</>
|
|
209
217
|
) : (
|
|
210
|
-
|
|
218
|
+
<>
|
|
219
|
+
{unread && (
|
|
220
|
+
<span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
|
|
221
|
+
)}
|
|
222
|
+
{/*
|
|
223
|
+
* Tappable, focusable entry point into selection mode — long-press is
|
|
224
|
+
* never the only way in. Nested inside the row's own open control
|
|
225
|
+
* (button or, via linkComponent, an anchor); mirrors the leading-slot
|
|
226
|
+
* toggle already shipped in the web client's row.
|
|
227
|
+
*/}
|
|
228
|
+
{/* biome-ignore lint/a11y/useSemanticElements: a native <input type="checkbox"> can't host the Avatar as its visible content; role="checkbox" on a button mirrors the row-checkbox pattern already shipped in MessageListItem.tsx */}
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
role="checkbox"
|
|
232
|
+
aria-checked={checked}
|
|
233
|
+
aria-label={`Select message from ${thread.fromName}`}
|
|
234
|
+
onPointerDown={stopRowGesture}
|
|
235
|
+
onPointerMove={stopRowGesture}
|
|
236
|
+
onPointerUp={stopRowGesture}
|
|
237
|
+
onClick={(e) => {
|
|
238
|
+
e.preventDefault();
|
|
239
|
+
e.stopPropagation();
|
|
240
|
+
onLongPress();
|
|
241
|
+
}}
|
|
242
|
+
className="inline-flex size-7 shrink-0 items-center justify-center rounded-full"
|
|
243
|
+
>
|
|
244
|
+
<Avatar name={thread.fromName} email={thread.fromEmail} size="sm" />
|
|
245
|
+
</button>
|
|
246
|
+
<ComfortableRowTextContent thread={thread} />
|
|
247
|
+
</>
|
|
211
248
|
);
|
|
212
249
|
|
|
213
250
|
return (
|
|
@@ -251,8 +288,11 @@ export function SwipeableRow({
|
|
|
251
288
|
children: body,
|
|
252
289
|
})
|
|
253
290
|
) : (
|
|
291
|
+
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: role is only ever "checkbox" (aria-checked's owning role) when selectionMode is true; the ternaries are linked, biome can't see that statically
|
|
254
292
|
<button
|
|
255
293
|
type="button"
|
|
294
|
+
role={selectionMode ? "checkbox" : undefined}
|
|
295
|
+
aria-checked={selectionMode ? checked : undefined}
|
|
256
296
|
onPointerDown={onPointerDown}
|
|
257
297
|
onPointerMove={onPointerMove}
|
|
258
298
|
onPointerUp={onPointerUp}
|
|
@@ -63,4 +63,29 @@ describe("TouchListBody", () => {
|
|
|
63
63
|
const html = renderToString(createElement(TouchListBody, baseProps));
|
|
64
64
|
assert.match(html, /Pull to refresh/);
|
|
65
65
|
});
|
|
66
|
+
|
|
67
|
+
it("dims and suppresses taps on every row while busy", () => {
|
|
68
|
+
const html = renderToString(
|
|
69
|
+
createElement(TouchListBody, {
|
|
70
|
+
...baseProps,
|
|
71
|
+
selectionMode: true,
|
|
72
|
+
checkedIds: new Set(["t1", "t2"]),
|
|
73
|
+
busy: true,
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
const dimmed = (html.match(/pointer-events-none opacity-50/g) ?? []).length;
|
|
77
|
+
assert.equal(dimmed, 2, "both seeded rows are dimmed");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("hides pull to refresh while busy", () => {
|
|
81
|
+
const html = renderToString(
|
|
82
|
+
createElement(TouchListBody, { ...baseProps, busy: true }),
|
|
83
|
+
);
|
|
84
|
+
assert.doesNotMatch(html, /Pull to refresh/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("renders rows undimmed when not busy", () => {
|
|
88
|
+
const html = renderToString(createElement(TouchListBody, baseProps));
|
|
89
|
+
assert.doesNotMatch(html, /pointer-events-none/);
|
|
90
|
+
});
|
|
66
91
|
});
|
|
@@ -93,3 +93,18 @@ export const SelectionModeAllChecked: Story = {
|
|
|
93
93
|
export const SelectionModeNoneChecked: Story = {
|
|
94
94
|
args: { selectionMode: true, checkedIds: new Set<string>() },
|
|
95
95
|
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A bulk delete is running against the checked rows: they stay checked but
|
|
99
|
+
* dim, and stop responding to taps — no more opening a message that's
|
|
100
|
+
* mid-delete. Pairs with `SelectionTopBar`'s `DeletingWithProgress` story.
|
|
101
|
+
*/
|
|
102
|
+
export const SelectionModeBusy: Story = {
|
|
103
|
+
args: {
|
|
104
|
+
selectionMode: true,
|
|
105
|
+
checkedIds: new Set(
|
|
106
|
+
sections.flatMap((section) => section.threads.map((t) => t.id)),
|
|
107
|
+
),
|
|
108
|
+
busy: true,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
@@ -14,6 +14,7 @@ export function TouchListBody({
|
|
|
14
14
|
onOpenThread,
|
|
15
15
|
onRefresh,
|
|
16
16
|
refreshing,
|
|
17
|
+
busy = false,
|
|
17
18
|
}: {
|
|
18
19
|
sections: ThreadSection[];
|
|
19
20
|
selectedThreadId?: string;
|
|
@@ -25,6 +26,12 @@ export function TouchListBody({
|
|
|
25
26
|
onOpenThread: (id: string) => void;
|
|
26
27
|
onRefresh: () => void;
|
|
27
28
|
refreshing: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* A bulk operation (e.g. delete) is running against the checked set. Rows
|
|
31
|
+
* dim and stop responding to taps instead of sitting normal, undimmed and
|
|
32
|
+
* still tappable while a count above them claims they're being deleted.
|
|
33
|
+
*/
|
|
34
|
+
busy?: boolean;
|
|
28
35
|
}) {
|
|
29
36
|
// Local copy so the mock can act on a swipe: delete removes the row,
|
|
30
37
|
// toggle-read flips its state. The live client owns real mutation.
|
|
@@ -56,24 +63,28 @@ export function TouchListBody({
|
|
|
56
63
|
)}
|
|
57
64
|
<div className="divide-y divide-line">
|
|
58
65
|
{items.map((thread) => (
|
|
59
|
-
<
|
|
66
|
+
<div
|
|
60
67
|
key={thread.id}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
className={busy ? "pointer-events-none opacity-50" : undefined}
|
|
69
|
+
>
|
|
70
|
+
<SwipeableRow
|
|
71
|
+
thread={thread}
|
|
72
|
+
selectionMode={selectionMode}
|
|
73
|
+
checked={checkedIds.has(thread.id)}
|
|
74
|
+
active={thread.id === selectedThreadId}
|
|
75
|
+
peek={peek?.id === thread.id ? peek.side : "none"}
|
|
76
|
+
onPeek={(next) =>
|
|
77
|
+
setPeek(next === "none" ? null : { id: thread.id, side: next })
|
|
78
|
+
}
|
|
79
|
+
onToggleCheck={() => onToggleCheck(thread.id)}
|
|
80
|
+
onLongPress={() => onEnterSelection(thread.id)}
|
|
81
|
+
onOpen={() => onOpenThread(thread.id)}
|
|
82
|
+
onAct={(side) => act(thread.id, side)}
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
74
85
|
))}
|
|
75
86
|
</div>
|
|
76
|
-
{!selectionMode && !refreshing && (
|
|
87
|
+
{!selectionMode && !refreshing && !busy && (
|
|
77
88
|
<button
|
|
78
89
|
type="button"
|
|
79
90
|
onClick={onRefresh}
|
package/src/index.ts
CHANGED
|
@@ -121,7 +121,10 @@ export {
|
|
|
121
121
|
export {
|
|
122
122
|
canonicalRoleLabel,
|
|
123
123
|
type FolderRole,
|
|
124
|
+
isVirtualFolderRole,
|
|
125
|
+
provenanceFolderLabel,
|
|
124
126
|
providerLeaf,
|
|
127
|
+
type ResultFolder,
|
|
125
128
|
roleIcon,
|
|
126
129
|
} from "./components/folder-role.js";
|
|
127
130
|
export {
|
|
@@ -232,6 +235,10 @@ export {
|
|
|
232
235
|
type PopoverMenuItem,
|
|
233
236
|
type PopoverMenuProps,
|
|
234
237
|
} from "./components/popover-menu.js";
|
|
238
|
+
export {
|
|
239
|
+
ProgressBar,
|
|
240
|
+
type ProgressBarProps,
|
|
241
|
+
} from "./components/progress-bar.js";
|
|
235
242
|
export {
|
|
236
243
|
PullToRefresh,
|
|
237
244
|
type PullToRefreshProps,
|
|
@@ -304,9 +311,11 @@ export {
|
|
|
304
311
|
type SearchResultTone,
|
|
305
312
|
} from "./components/search-result-row.js";
|
|
306
313
|
export {
|
|
314
|
+
partitionSpamResults,
|
|
307
315
|
type SearchResultSection,
|
|
308
316
|
SearchResults,
|
|
309
317
|
type SearchResultsProps,
|
|
318
|
+
type SearchScope,
|
|
310
319
|
} from "./components/search-results.js";
|
|
311
320
|
export {
|
|
312
321
|
SearchChipRow,
|
|
@@ -331,6 +340,8 @@ export {
|
|
|
331
340
|
export { Select, type SelectProps } from "./components/select.js";
|
|
332
341
|
export {
|
|
333
342
|
SelectionTopBar,
|
|
343
|
+
type SelectionTopBarNotice,
|
|
344
|
+
type SelectionTopBarNoticeAction,
|
|
334
345
|
type SelectionTopBarProps,
|
|
335
346
|
} from "./components/selection-top-bar.js";
|
|
336
347
|
export {
|
|
@@ -358,6 +369,10 @@ export {
|
|
|
358
369
|
SlidePanel,
|
|
359
370
|
type SlidePanelProps,
|
|
360
371
|
} from "./components/slide-panel.js";
|
|
372
|
+
export {
|
|
373
|
+
SpamResultsOffer,
|
|
374
|
+
type SpamResultsOfferProps,
|
|
375
|
+
} from "./components/spam-results-offer.js";
|
|
361
376
|
export {
|
|
362
377
|
commitPeek,
|
|
363
378
|
SwipeableRow,
|