@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
package/package.json
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
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 { Button } from "./button.js";
|
|
6
|
+
|
|
7
|
+
describe("Button", () => {
|
|
8
|
+
it("defaults to the md size", () => {
|
|
9
|
+
const html = renderToString(createElement(Button, {}, "Save"));
|
|
10
|
+
assert.match(html, /h-9/);
|
|
11
|
+
assert.doesNotMatch(html, /h-11/);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("renders the sm size at 28px", () => {
|
|
15
|
+
const html = renderToString(createElement(Button, { size: "sm" }, "Save"));
|
|
16
|
+
assert.match(html, /h-7/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("renders the touch size at 44px square", () => {
|
|
20
|
+
const html = renderToString(
|
|
21
|
+
createElement(Button, {
|
|
22
|
+
size: "touch",
|
|
23
|
+
"aria-label": "Delete",
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
assert.match(html, /h-11/);
|
|
27
|
+
assert.match(html, /w-11/);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -2,7 +2,7 @@ import type { ButtonHTMLAttributes, ReactNode } from "react";
|
|
|
2
2
|
import { cn } from "../lib/cn.js";
|
|
3
3
|
|
|
4
4
|
type Variant = "primary" | "secondary" | "ghost" | "danger";
|
|
5
|
-
type Size = "sm" | "md";
|
|
5
|
+
type Size = "sm" | "md" | "touch";
|
|
6
6
|
|
|
7
7
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
8
8
|
variant?: Variant;
|
|
@@ -25,6 +25,10 @@ const variants: Record<Variant, string> = {
|
|
|
25
25
|
const sizes: Record<Size, string> = {
|
|
26
26
|
sm: "h-7 px-2.5 text-xs",
|
|
27
27
|
md: "h-9 px-3.5 text-sm",
|
|
28
|
+
/** 44px square — the touch-target floor (HIG 44 / Material 48-ish). For an
|
|
29
|
+
* icon-only control; a control carrying a text label should size itself
|
|
30
|
+
* with `md` plus an explicit `min-h-11` instead of stretching to square. */
|
|
31
|
+
touch: "h-11 w-11 text-sm",
|
|
28
32
|
};
|
|
29
33
|
|
|
30
34
|
export function Button({
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { isVirtualFolderRole, provenanceFolderLabel } from "./folder-role.js";
|
|
4
|
+
|
|
5
|
+
describe("provenanceFolderLabel", () => {
|
|
6
|
+
it("names an appointed role by its canonical label", () => {
|
|
7
|
+
assert.equal(provenanceFolderLabel({ role: "archive" }), "Archive");
|
|
8
|
+
assert.equal(provenanceFolderLabel({ role: "sent" }), "Sent");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("reads a junk appointment as Spam whatever the server calls it", () => {
|
|
12
|
+
assert.equal(
|
|
13
|
+
provenanceFolderLabel({ role: "junk", providerPath: "Bulk Mail" }),
|
|
14
|
+
"Spam",
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("falls back to the leaf of a folder nobody appointed", () => {
|
|
19
|
+
assert.equal(
|
|
20
|
+
provenanceFolderLabel({ providerPath: "Projects/Bookkeeping" }),
|
|
21
|
+
"Bookkeeping",
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("refuses to label a view rather than a place", () => {
|
|
26
|
+
assert.equal(provenanceFolderLabel({ role: "all" }), undefined);
|
|
27
|
+
assert.equal(provenanceFolderLabel({ role: "flagged" }), undefined);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("refuses to label Gmail's own reserved namespace", () => {
|
|
31
|
+
assert.equal(
|
|
32
|
+
provenanceFolderLabel({ providerPath: "[Gmail]/All Mail" }),
|
|
33
|
+
undefined,
|
|
34
|
+
);
|
|
35
|
+
assert.equal(
|
|
36
|
+
provenanceFolderLabel({ providerPath: "[Gmail]/Starred" }),
|
|
37
|
+
undefined,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("refuses the googlemail.com spelling of the same namespace", () => {
|
|
42
|
+
assert.equal(
|
|
43
|
+
provenanceFolderLabel({ providerPath: "[Google Mail]/All Mail" }),
|
|
44
|
+
undefined,
|
|
45
|
+
);
|
|
46
|
+
assert.equal(
|
|
47
|
+
provenanceFolderLabel({ providerPath: "[Google Mail]/Starred" }),
|
|
48
|
+
undefined,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("labels a user folder that merely mentions Gmail", () => {
|
|
53
|
+
assert.equal(provenanceFolderLabel({ providerPath: "Gmail" }), "Gmail");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("has nothing to say about a folder it knows nothing about", () => {
|
|
57
|
+
assert.equal(provenanceFolderLabel({}), undefined);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("isVirtualFolderRole", () => {
|
|
62
|
+
it("counts All Mail and Starred as views", () => {
|
|
63
|
+
assert.equal(isVirtualFolderRole("all"), true);
|
|
64
|
+
assert.equal(isVirtualFolderRole("flagged"), true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("counts real folders as places", () => {
|
|
68
|
+
assert.equal(isVirtualFolderRole("inbox"), false);
|
|
69
|
+
assert.equal(isVirtualFolderRole("junk"), false);
|
|
70
|
+
assert.equal(isVirtualFolderRole("trash"), false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -54,6 +54,65 @@ export function providerLeaf(providerPath: string): string {
|
|
|
54
54
|
return parts[parts.length - 1] || providerPath;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/* ------------------------------------------------------------------ */
|
|
58
|
+
/* Provenance: where a search result actually lives */
|
|
59
|
+
/* ------------------------------------------------------------------ */
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The folder a search result was read from. `role` is the account's IMAP
|
|
63
|
+
* special-use appointment (`junk` is `\Junk`); accounts that expose a folder
|
|
64
|
+
* nobody appointed carry only a `providerPath`.
|
|
65
|
+
*/
|
|
66
|
+
export interface ResultFolder {
|
|
67
|
+
role?: FolderRole;
|
|
68
|
+
/** Provider path as the server spells it, e.g. `Projects/Bookkeeping`. */
|
|
69
|
+
providerPath?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Roles that name a view rather than a place a message is filed. A message in
|
|
74
|
+
* All Mail or Starred is also somewhere real, so labelling a result with one of
|
|
75
|
+
* these says nothing about where it came from.
|
|
76
|
+
*/
|
|
77
|
+
const VIRTUAL_ROLES: ReadonlySet<FolderRole> = new Set(["all", "flagged"]);
|
|
78
|
+
|
|
79
|
+
export function isVirtualFolderRole(role: FolderRole): boolean {
|
|
80
|
+
return VIRTUAL_ROLES.has(role);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Gmail exposes its views as ordinary folders under a reserved namespace. An
|
|
85
|
+
* account that appointed no role to them leaves the path as the only signal, so
|
|
86
|
+
* the namespace is matched by name — the one place a name is the honest test,
|
|
87
|
+
* because it is the provider's own reserved prefix and not a user's folder.
|
|
88
|
+
*
|
|
89
|
+
* Accounts provisioned under googlemail.com get the same namespace spelled
|
|
90
|
+
* `[Google Mail]`, so both forms count. A user folder plainly called `Gmail`
|
|
91
|
+
* does not — the brackets are what make the prefix reserved.
|
|
92
|
+
*/
|
|
93
|
+
const GMAIL_NAMESPACES: ReadonlySet<string> = new Set([
|
|
94
|
+
"[Gmail]",
|
|
95
|
+
"[Google Mail]",
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Label for the folder a result came from, or `undefined` when that folder is a
|
|
100
|
+
* view rather than a place — in which case no label is better than a misleading
|
|
101
|
+
* one. An appointed role wins over the provider's spelling, so a folder the
|
|
102
|
+
* account calls `Junk` still reads as "Spam".
|
|
103
|
+
*/
|
|
104
|
+
export function provenanceFolderLabel(
|
|
105
|
+
folder: ResultFolder,
|
|
106
|
+
): string | undefined {
|
|
107
|
+
if (folder.role) {
|
|
108
|
+
if (isVirtualFolderRole(folder.role)) return undefined;
|
|
109
|
+
return canonicalRoleLabel(folder.role);
|
|
110
|
+
}
|
|
111
|
+
if (!folder.providerPath) return undefined;
|
|
112
|
+
if (GMAIL_NAMESPACES.has(folder.providerPath.split("/")[0])) return undefined;
|
|
113
|
+
return providerLeaf(folder.providerPath);
|
|
114
|
+
}
|
|
115
|
+
|
|
57
116
|
export function roleIcon(role: FolderRole): ReactNode {
|
|
58
117
|
if (role === "inbox") return <Inbox className="size-4" />;
|
|
59
118
|
if (role === "drafts") return <FileText className="size-4" />;
|
|
@@ -0,0 +1,78 @@
|
|
|
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 { MobileSearchView } from "./mobile-search-view.js";
|
|
6
|
+
import type { SearchResult } from "./search-result-row.js";
|
|
7
|
+
|
|
8
|
+
const noop = () => {};
|
|
9
|
+
|
|
10
|
+
const archived: SearchResult = {
|
|
11
|
+
id: "a1",
|
|
12
|
+
sender: "Mollie",
|
|
13
|
+
subject: "Invoice 2026-02",
|
|
14
|
+
snippet: "Payment already settled.",
|
|
15
|
+
date: "Feb 24",
|
|
16
|
+
folder: { role: "archive" },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const spam: SearchResult = {
|
|
20
|
+
id: "s1",
|
|
21
|
+
sender: "billing@unknown-vendor.test",
|
|
22
|
+
subject: "URGENT invoice attached",
|
|
23
|
+
snippet: "Wire the amount below.",
|
|
24
|
+
date: "Feb 11",
|
|
25
|
+
folder: { role: "junk" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const sections = [
|
|
29
|
+
{ id: "top", label: "Top matches", results: [archived, spam] },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const base = {
|
|
33
|
+
value: "invoice",
|
|
34
|
+
onChange: noop,
|
|
35
|
+
onClear: noop,
|
|
36
|
+
onCancel: noop,
|
|
37
|
+
sections,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe("MobileSearchView search scope", () => {
|
|
41
|
+
it("offers held-out spam on the phone tier too", () => {
|
|
42
|
+
const html = renderToString(
|
|
43
|
+
createElement(MobileSearchView, {
|
|
44
|
+
...base,
|
|
45
|
+
scope: { kind: "global" as const },
|
|
46
|
+
onScopeToSpam: noop,
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
assert.doesNotMatch(html, /unknown-vendor/);
|
|
50
|
+
assert.match(html, /result from Spam/);
|
|
51
|
+
assert.match(html, /Archive/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("shows neither spam nor provenance labels when scoped", () => {
|
|
55
|
+
const html = renderToString(
|
|
56
|
+
createElement(MobileSearchView, {
|
|
57
|
+
...base,
|
|
58
|
+
scope: { kind: "folder" as const, role: "inbox" as const },
|
|
59
|
+
onScopeToSpam: noop,
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
assert.doesNotMatch(html, /unknown-vendor/);
|
|
63
|
+
assert.doesNotMatch(html, /from Spam/);
|
|
64
|
+
assert.doesNotMatch(html, /Archive/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("passes a caller-supplied spam total through", () => {
|
|
68
|
+
const html = renderToString(
|
|
69
|
+
createElement(MobileSearchView, {
|
|
70
|
+
...base,
|
|
71
|
+
scope: { kind: "global" as const },
|
|
72
|
+
spamMatchCount: 42,
|
|
73
|
+
onScopeToSpam: noop,
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
assert.match(html, />42</);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { MobileSearchView } from "./mobile-search-view.js";
|
|
9
9
|
import type { SearchChip } from "./search-chip-input.js";
|
|
10
10
|
import type { SearchResult } from "./search-result-row.js";
|
|
11
|
-
import type { SearchResultSection } from "./search-results.js";
|
|
11
|
+
import type { SearchResultSection, SearchScope } from "./search-results.js";
|
|
12
12
|
|
|
13
13
|
const phoneFrame: Decorator = (Story) => (
|
|
14
14
|
<div
|
|
@@ -97,6 +97,56 @@ const emptySections: SearchResultSection[] = [
|
|
|
97
97
|
{ id: "related", label: "Related", results: [] },
|
|
98
98
|
];
|
|
99
99
|
|
|
100
|
+
/** Matches spread across ordinary folders, each carrying where it was read from. */
|
|
101
|
+
const crossFolderMatches: SearchResult[] = [
|
|
102
|
+
{ ...topMatches[0], folder: { role: "inbox" } },
|
|
103
|
+
{ ...topMatches[1], folder: { role: "inbox" } },
|
|
104
|
+
{
|
|
105
|
+
id: "x1",
|
|
106
|
+
sender: "Mollie",
|
|
107
|
+
subject: "Invoice 2026-02 — archived",
|
|
108
|
+
snippet: "Filed last month; payment already settled.",
|
|
109
|
+
date: "Feb 24",
|
|
110
|
+
folder: { role: "archive" },
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "x2",
|
|
114
|
+
sender: "Accountant",
|
|
115
|
+
subject: "Invoices for the quarter",
|
|
116
|
+
snippet: "The quarterly set, filed with the rest of the bookkeeping.",
|
|
117
|
+
date: "Jan 30",
|
|
118
|
+
folder: { providerPath: "Projects/Bookkeeping" },
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
/** Matches in the account's `\Junk` folder. */
|
|
123
|
+
const spamMatches: SearchResult[] = [
|
|
124
|
+
{
|
|
125
|
+
id: "s1",
|
|
126
|
+
sender: "billing@unknown-vendor.test",
|
|
127
|
+
subject: "URGENT invoice attached",
|
|
128
|
+
snippet: "Wire the amount below within 24 hours to avoid suspension.",
|
|
129
|
+
date: "Feb 11",
|
|
130
|
+
folder: { role: "junk" },
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: "s2",
|
|
134
|
+
sender: "invoices@pay-now.test",
|
|
135
|
+
subject: "Outstanding invoice — final notice",
|
|
136
|
+
snippet: "Your account is overdue. Settle immediately.",
|
|
137
|
+
date: "Feb 4",
|
|
138
|
+
folder: { role: "junk" },
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
const acrossFoldersSections: SearchResultSection[] = [
|
|
143
|
+
{
|
|
144
|
+
id: "top",
|
|
145
|
+
label: "Top matches",
|
|
146
|
+
results: [...crossFolderMatches, ...spamMatches],
|
|
147
|
+
},
|
|
148
|
+
];
|
|
149
|
+
|
|
100
150
|
type Preset = "brief" | "inbox";
|
|
101
151
|
|
|
102
152
|
function Harness({
|
|
@@ -105,12 +155,18 @@ function Harness({
|
|
|
105
155
|
loading,
|
|
106
156
|
sections,
|
|
107
157
|
preset,
|
|
158
|
+
scope,
|
|
159
|
+
spamMatchCount,
|
|
160
|
+
onScopeToSpam,
|
|
108
161
|
}: {
|
|
109
162
|
initialValue?: string;
|
|
110
163
|
initialChips?: SearchChip[];
|
|
111
164
|
loading?: boolean;
|
|
112
165
|
sections?: SearchResultSection[];
|
|
113
166
|
preset: Preset;
|
|
167
|
+
scope?: SearchScope;
|
|
168
|
+
spamMatchCount?: number;
|
|
169
|
+
onScopeToSpam?: () => void;
|
|
114
170
|
}) {
|
|
115
171
|
const [value, setValue] = useState(initialValue);
|
|
116
172
|
const [chips, setChips] = useState<SearchChip[]>(initialChips);
|
|
@@ -183,6 +239,9 @@ function Harness({
|
|
|
183
239
|
sections={sections}
|
|
184
240
|
loading={loading}
|
|
185
241
|
onSelectResult={setOpened}
|
|
242
|
+
scope={scope}
|
|
243
|
+
spamMatchCount={spamMatchCount}
|
|
244
|
+
onScopeToSpam={onScopeToSpam}
|
|
186
245
|
/>
|
|
187
246
|
);
|
|
188
247
|
}
|
|
@@ -257,14 +316,70 @@ export const RelatedSelectable: Story = {
|
|
|
257
316
|
* top bar uses, inside the full-screen takeover's own chrome. The chip is
|
|
258
317
|
* removable in place — backspace at the start of the text reaches it just as it
|
|
259
318
|
* does on desktop.
|
|
319
|
+
*
|
|
320
|
+
* The chip and the scope say the same thing, which is the point: an `in:spam`
|
|
321
|
+
* chip is what a Spam-scoped search looks like in the bar.
|
|
260
322
|
*/
|
|
261
323
|
export const ScopedByChip: Story = {
|
|
262
324
|
render: () => (
|
|
263
325
|
<Harness
|
|
264
326
|
initialValue="invoice"
|
|
265
327
|
initialChips={[{ id: "in:spam", label: "in:spam" }]}
|
|
266
|
-
sections={
|
|
328
|
+
sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
|
|
329
|
+
scope={{ kind: "folder", role: "junk" }}
|
|
330
|
+
preset="inbox"
|
|
331
|
+
/>
|
|
332
|
+
),
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Global search on the phone, holding spam out and offering it above the
|
|
337
|
+
* results — the same treatment the desktop list pane gives it, because both
|
|
338
|
+
* tiers render the one `SearchResults` body. Rows name the folder they came
|
|
339
|
+
* from; the two spam matches in the same data are not among them.
|
|
340
|
+
*/
|
|
341
|
+
export const GlobalAcrossFolders: Story = {
|
|
342
|
+
render: () => (
|
|
343
|
+
<Harness
|
|
344
|
+
initialValue="invoice"
|
|
345
|
+
sections={acrossFoldersSections}
|
|
346
|
+
scope={{ kind: "global" }}
|
|
347
|
+
onScopeToSpam={() => {}}
|
|
348
|
+
preset="brief"
|
|
349
|
+
/>
|
|
350
|
+
),
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The same rows scoped to the inbox. No spam, no count, no offer, and no
|
|
355
|
+
* provenance labels — the chip in the bar already says where the search is
|
|
356
|
+
* looking.
|
|
357
|
+
*/
|
|
358
|
+
export const ScopedToInbox: Story = {
|
|
359
|
+
render: () => (
|
|
360
|
+
<Harness
|
|
361
|
+
initialValue="invoice"
|
|
362
|
+
initialChips={[{ id: "in:inbox", label: "in:inbox" }]}
|
|
363
|
+
sections={acrossFoldersSections}
|
|
364
|
+
scope={{ kind: "folder", role: "inbox" }}
|
|
365
|
+
onScopeToSpam={() => {}}
|
|
267
366
|
preset="inbox"
|
|
268
367
|
/>
|
|
269
368
|
),
|
|
270
369
|
};
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* A global phone search whose only matches are in Spam: the offer stands above
|
|
373
|
+
* the empty state rather than leaving the search looking fruitless.
|
|
374
|
+
*/
|
|
375
|
+
export const GlobalOnlySpamMatches: Story = {
|
|
376
|
+
render: () => (
|
|
377
|
+
<Harness
|
|
378
|
+
initialValue="invoice"
|
|
379
|
+
sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
|
|
380
|
+
scope={{ kind: "global" }}
|
|
381
|
+
onScopeToSpam={() => {}}
|
|
382
|
+
preset="brief"
|
|
383
|
+
/>
|
|
384
|
+
),
|
|
385
|
+
};
|
|
@@ -4,7 +4,11 @@ import { FilterSheet, type FilterSheetProps } from "./filter-sheet.js";
|
|
|
4
4
|
import { SearchBar } from "./search-bar.js";
|
|
5
5
|
import type { SearchChip } from "./search-chip-input.js";
|
|
6
6
|
import type { SearchResult } from "./search-result-row.js";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type SearchResultSection,
|
|
9
|
+
SearchResults,
|
|
10
|
+
type SearchScope,
|
|
11
|
+
} from "./search-results.js";
|
|
8
12
|
|
|
9
13
|
export interface MobileSearchViewProps {
|
|
10
14
|
value: string;
|
|
@@ -40,6 +44,12 @@ export interface MobileSearchViewProps {
|
|
|
40
44
|
*/
|
|
41
45
|
chips?: readonly SearchChip[];
|
|
42
46
|
onRemoveChip?: (id: string) => void;
|
|
47
|
+
/** What the search covers; see `SearchResultsProps`. Defaults to global. */
|
|
48
|
+
scope?: SearchScope;
|
|
49
|
+
/** Total spam matches found; see `SearchResultsProps`. */
|
|
50
|
+
spamMatchCount?: number;
|
|
51
|
+
/** Scope the search to Spam; see `SearchResultsProps`. */
|
|
52
|
+
onScopeToSpam?: () => void;
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
/**
|
|
@@ -52,6 +62,9 @@ export interface MobileSearchViewProps {
|
|
|
52
62
|
* the inboxes use) so search carries identical filters; pass no `filter` to drop
|
|
53
63
|
* the chrome. Desktop reuses the same `SearchResults` body in the list pane.
|
|
54
64
|
* Presentational and prop-driven.
|
|
65
|
+
*
|
|
66
|
+
* Search scope passes straight through, so the phone tier holds spam out,
|
|
67
|
+
* offers it and labels provenance on exactly the same terms as desktop.
|
|
55
68
|
*/
|
|
56
69
|
export function MobileSearchView({
|
|
57
70
|
value,
|
|
@@ -67,6 +80,9 @@ export function MobileSearchView({
|
|
|
67
80
|
tokens,
|
|
68
81
|
chips,
|
|
69
82
|
onRemoveChip,
|
|
83
|
+
scope,
|
|
84
|
+
spamMatchCount,
|
|
85
|
+
onScopeToSpam,
|
|
70
86
|
}: MobileSearchViewProps) {
|
|
71
87
|
const body = (
|
|
72
88
|
<SearchResults
|
|
@@ -77,6 +93,9 @@ export function MobileSearchView({
|
|
|
77
93
|
loading={loading}
|
|
78
94
|
onSelectResult={onSelectResult}
|
|
79
95
|
tokens={tokens}
|
|
96
|
+
scope={scope}
|
|
97
|
+
spamMatchCount={spamMatchCount}
|
|
98
|
+
onScopeToSpam={onScopeToSpam}
|
|
80
99
|
/>
|
|
81
100
|
);
|
|
82
101
|
|
|
@@ -0,0 +1,46 @@
|
|
|
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 { ProgressBar } from "./progress-bar.js";
|
|
6
|
+
|
|
7
|
+
describe("ProgressBar", () => {
|
|
8
|
+
it("renders progressbar role with the aria value triad", () => {
|
|
9
|
+
const html = renderToString(
|
|
10
|
+
createElement(ProgressBar, { value: 1200, max: 3412 }),
|
|
11
|
+
);
|
|
12
|
+
assert.match(html, /role="progressbar"/);
|
|
13
|
+
assert.match(html, /aria-valuenow="1200"/);
|
|
14
|
+
assert.match(html, /aria-valuemin="0"/);
|
|
15
|
+
assert.match(html, /aria-valuemax="3412"/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("computes the fill width as a percentage of value/max", () => {
|
|
19
|
+
const html = renderToString(
|
|
20
|
+
createElement(ProgressBar, { value: 25, max: 100 }),
|
|
21
|
+
);
|
|
22
|
+
assert.match(html, /width:25%/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("clamps the fill at 100% when value exceeds max", () => {
|
|
26
|
+
const html = renderToString(
|
|
27
|
+
createElement(ProgressBar, { value: 500, max: 100 }),
|
|
28
|
+
);
|
|
29
|
+
assert.match(html, /width:100%/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("omits the aria value triad when indeterminate", () => {
|
|
33
|
+
const html = renderToString(
|
|
34
|
+
createElement(ProgressBar, { value: 0, max: 0, indeterminate: true }),
|
|
35
|
+
);
|
|
36
|
+
assert.doesNotMatch(html, /aria-valuenow/);
|
|
37
|
+
assert.doesNotMatch(html, /aria-valuemax/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("applies the tone's fill color", () => {
|
|
41
|
+
const html = renderToString(
|
|
42
|
+
createElement(ProgressBar, { value: 1, max: 2, tone: "danger" }),
|
|
43
|
+
);
|
|
44
|
+
assert.match(html, /bg-danger/);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { ProgressBar } from "./progress-bar.js";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof ProgressBar> = {
|
|
5
|
+
title: "Components/ProgressBar",
|
|
6
|
+
component: ProgressBar,
|
|
7
|
+
parameters: { layout: "padded" },
|
|
8
|
+
render: (args) => (
|
|
9
|
+
<div className="w-80">
|
|
10
|
+
<ProgressBar {...args} />
|
|
11
|
+
</div>
|
|
12
|
+
),
|
|
13
|
+
};
|
|
14
|
+
export default meta;
|
|
15
|
+
|
|
16
|
+
type Story = StoryObj<typeof ProgressBar>;
|
|
17
|
+
|
|
18
|
+
export const Started: Story = { args: { value: 340, max: 3412 } };
|
|
19
|
+
|
|
20
|
+
export const Halfway: Story = { args: { value: 1706, max: 3412 } };
|
|
21
|
+
|
|
22
|
+
export const NearlyDone: Story = { args: { value: 3072, max: 3412 } };
|
|
23
|
+
|
|
24
|
+
export const Success: Story = {
|
|
25
|
+
args: { value: 3412, max: 3412, tone: "success" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const Danger: Story = {
|
|
29
|
+
args: { value: 1200, max: 3412, tone: "danger" },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Total is unknown — a paging search count, before the first page resolves. */
|
|
33
|
+
export const Indeterminate: Story = {
|
|
34
|
+
args: { value: 0, max: 0, indeterminate: true },
|
|
35
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import type { BannerTone } from "./banner.js";
|
|
3
|
+
|
|
4
|
+
export interface ProgressBarProps {
|
|
5
|
+
/** Units completed so far. Ignored when `indeterminate`. */
|
|
6
|
+
value: number;
|
|
7
|
+
/** Total units. Ignored when `indeterminate`. */
|
|
8
|
+
max: number;
|
|
9
|
+
tone?: BannerTone;
|
|
10
|
+
/** Unknown total (or unknown rate) — an animated fill with no fixed end. */
|
|
11
|
+
indeterminate?: boolean;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const tones: Record<BannerTone, string> = {
|
|
16
|
+
info: "bg-accent-2",
|
|
17
|
+
success: "bg-positive",
|
|
18
|
+
warning: "bg-warning",
|
|
19
|
+
danger: "bg-danger",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Determinate (or indeterminate) progress meter for a bulk operation, e.g. a
|
|
24
|
+
* multi-thousand-message delete running in batches. A bare running count
|
|
25
|
+
* gives no sense of rate over a long operation; a filling bar answers "is
|
|
26
|
+
* this stuck?" pre-attentively.
|
|
27
|
+
*/
|
|
28
|
+
export function ProgressBar({
|
|
29
|
+
value,
|
|
30
|
+
max,
|
|
31
|
+
tone = "info",
|
|
32
|
+
indeterminate = false,
|
|
33
|
+
className,
|
|
34
|
+
}: ProgressBarProps) {
|
|
35
|
+
const pct = indeterminate
|
|
36
|
+
? 100
|
|
37
|
+
: max <= 0
|
|
38
|
+
? 0
|
|
39
|
+
: Math.min(100, Math.max(0, (value / max) * 100));
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
role="progressbar"
|
|
44
|
+
aria-valuenow={indeterminate ? undefined : value}
|
|
45
|
+
aria-valuemin={indeterminate ? undefined : 0}
|
|
46
|
+
aria-valuemax={indeterminate ? undefined : max}
|
|
47
|
+
className={cn(
|
|
48
|
+
"h-1.5 w-full overflow-hidden rounded-full bg-surface-sunken",
|
|
49
|
+
className,
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
<div
|
|
53
|
+
className={cn(
|
|
54
|
+
"h-full rounded-full transition-[width] duration-300 ease-out",
|
|
55
|
+
tones[tone],
|
|
56
|
+
indeterminate && "w-1/3 animate-pulse",
|
|
57
|
+
)}
|
|
58
|
+
style={indeterminate ? undefined : { width: `${pct}%` }}
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|