@remit/ui 0.0.14 → 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/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/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/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 +6 -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,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
|
+
}
|
|
@@ -9,9 +9,15 @@ const handlers = {
|
|
|
9
9
|
onDelete: () => undefined,
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
/** SSR interleaves `<!-- -->` markers between interpolated text nodes
|
|
13
|
-
* tags/comments
|
|
14
|
-
|
|
12
|
+
/** SSR interleaves `<!-- -->` markers between interpolated text nodes and
|
|
13
|
+
* HTML-encodes entities; strip tags/comments and decode the handful of
|
|
14
|
+
* entities copy assertions actually hit, so they match the rendered words. */
|
|
15
|
+
const text = (html: string) =>
|
|
16
|
+
html
|
|
17
|
+
.replace(/<[^>]*>/g, "")
|
|
18
|
+
.replace(/"/g, '"')
|
|
19
|
+
.replace(/'/g, "'")
|
|
20
|
+
.replace(/&/g, "&");
|
|
15
21
|
|
|
16
22
|
describe("SelectionTopBar", () => {
|
|
17
23
|
it("renders singular copy for one message", () => {
|
|
@@ -28,12 +34,41 @@ describe("SelectionTopBar", () => {
|
|
|
28
34
|
assert.match(text(html), /3 messages selected/);
|
|
29
35
|
});
|
|
30
36
|
|
|
37
|
+
it("applies thousands separators to the default count copy", () => {
|
|
38
|
+
const html = renderToString(
|
|
39
|
+
createElement(SelectionTopBar, { ...handlers, count: 3412 }),
|
|
40
|
+
);
|
|
41
|
+
assert.match(text(html), /3,412 messages selected/);
|
|
42
|
+
});
|
|
43
|
+
|
|
31
44
|
it("renders cancel and delete controls", () => {
|
|
32
45
|
const html = renderToString(
|
|
33
46
|
createElement(SelectionTopBar, { ...handlers, count: 2 }),
|
|
34
47
|
);
|
|
35
48
|
assert.match(html, /aria-label="Cancel selection"/);
|
|
36
|
-
assert.match(html, /aria-label="
|
|
49
|
+
assert.match(html, /aria-label="Move selected messages to Trash"/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("renders cancel, mark-read and delete at the 44px touch size", () => {
|
|
53
|
+
const html = renderToString(
|
|
54
|
+
createElement(SelectionTopBar, {
|
|
55
|
+
...handlers,
|
|
56
|
+
count: 2,
|
|
57
|
+
onMarkRead: () => undefined,
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
const buttonCount = (html.match(/h-11 w-11/g) ?? []).length;
|
|
61
|
+
assert.equal(buttonCount, 3, "cancel, mark-read and delete are all 44px");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("spaces delete at least 16px from its preceding sibling", () => {
|
|
65
|
+
const html = renderToString(
|
|
66
|
+
createElement(SelectionTopBar, { ...handlers, count: 2 }),
|
|
67
|
+
);
|
|
68
|
+
assert.match(
|
|
69
|
+
html,
|
|
70
|
+
/class="[^"]*\bml-4\b[^"]*"[^>]*aria-label="Move selected messages to Trash"/,
|
|
71
|
+
);
|
|
37
72
|
});
|
|
38
73
|
|
|
39
74
|
it("renders mark-read control when onMarkRead is provided", () => {
|
|
@@ -54,15 +89,27 @@ describe("SelectionTopBar", () => {
|
|
|
54
89
|
assert.doesNotMatch(html, /aria-label="Mark as read"/);
|
|
55
90
|
});
|
|
56
91
|
|
|
57
|
-
it("
|
|
92
|
+
it("hides mark-read while busy instead of disabling it", () => {
|
|
58
93
|
const html = renderToString(
|
|
59
94
|
createElement(SelectionTopBar, {
|
|
60
95
|
...handlers,
|
|
61
96
|
count: 2,
|
|
62
|
-
|
|
97
|
+
onMarkRead: () => undefined,
|
|
98
|
+
isBusy: true,
|
|
63
99
|
}),
|
|
64
100
|
);
|
|
65
|
-
assert.
|
|
101
|
+
assert.doesNotMatch(html, /aria-label="Mark as read"/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("hides delete while counting", () => {
|
|
105
|
+
const html = renderToString(
|
|
106
|
+
createElement(SelectionTopBar, {
|
|
107
|
+
...handlers,
|
|
108
|
+
count: 0,
|
|
109
|
+
isCounting: true,
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
assert.doesNotMatch(html, /aria-label="Move selected messages to Trash"/);
|
|
66
113
|
});
|
|
67
114
|
|
|
68
115
|
it("renders statusLabel in place of the count copy when provided", () => {
|
|
@@ -74,7 +121,7 @@ describe("SelectionTopBar", () => {
|
|
|
74
121
|
}),
|
|
75
122
|
);
|
|
76
123
|
assert.match(text(html), /Deleting 1,200 of 3,412…/);
|
|
77
|
-
assert.doesNotMatch(text(html), /
|
|
124
|
+
assert.doesNotMatch(text(html), /3,412 messages selected/);
|
|
78
125
|
});
|
|
79
126
|
|
|
80
127
|
it("falls back to the count copy when statusLabel is absent", () => {
|
|
@@ -84,15 +131,14 @@ describe("SelectionTopBar", () => {
|
|
|
84
131
|
assert.match(text(html), /2 messages selected/);
|
|
85
132
|
});
|
|
86
133
|
|
|
87
|
-
it("
|
|
134
|
+
it("marks the count/status line as a polite live region", () => {
|
|
88
135
|
const html = renderToString(
|
|
89
|
-
createElement(SelectionTopBar, {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
136
|
+
createElement(SelectionTopBar, { ...handlers, count: 2 }),
|
|
137
|
+
);
|
|
138
|
+
assert.match(
|
|
139
|
+
html,
|
|
140
|
+
/role="status"[^>]*aria-live="polite"[^>]*>2 messages selected/,
|
|
94
141
|
);
|
|
95
|
-
assert.match(html, /340 failed to delete — retry\?/);
|
|
96
142
|
});
|
|
97
143
|
|
|
98
144
|
it("omits the select-all control when selectAll is absent", () => {
|
|
@@ -141,7 +187,111 @@ describe("SelectionTopBar", () => {
|
|
|
141
187
|
);
|
|
142
188
|
});
|
|
143
189
|
|
|
144
|
-
it("
|
|
190
|
+
it("names the loaded scope by default once select-all is checked", () => {
|
|
191
|
+
const html = renderToString(
|
|
192
|
+
createElement(SelectionTopBar, {
|
|
193
|
+
...handlers,
|
|
194
|
+
count: 47,
|
|
195
|
+
selectAll: {
|
|
196
|
+
checked: true,
|
|
197
|
+
indeterminate: false,
|
|
198
|
+
onChange: () => undefined,
|
|
199
|
+
},
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
assert.match(text(html), /All 47 loaded selected/);
|
|
203
|
+
assert.doesNotMatch(text(html), /^47 messages selected/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("lets statusLabel override the scoped default for an escalated selection", () => {
|
|
207
|
+
const html = renderToString(
|
|
208
|
+
createElement(SelectionTopBar, {
|
|
209
|
+
...handlers,
|
|
210
|
+
count: 3412,
|
|
211
|
+
selectAll: { checked: true, onChange: () => undefined },
|
|
212
|
+
statusLabel: 'All 3,412 matching "npm" selected',
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
assert.match(text(html), /All 3,412 matching "npm" selected/);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("gives the select-all checkbox a 44px hit area", () => {
|
|
219
|
+
const html = renderToString(
|
|
220
|
+
createElement(SelectionTopBar, {
|
|
221
|
+
...handlers,
|
|
222
|
+
count: 2,
|
|
223
|
+
selectAll: {
|
|
224
|
+
checked: false,
|
|
225
|
+
onChange: () => undefined,
|
|
226
|
+
},
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
assert.match(html, /<label[^>]*size-11[^>]*>/);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("renders the notice text and tone", () => {
|
|
233
|
+
const html = renderToString(
|
|
234
|
+
createElement(SelectionTopBar, {
|
|
235
|
+
...handlers,
|
|
236
|
+
count: 2,
|
|
237
|
+
notice: {
|
|
238
|
+
tone: "warning",
|
|
239
|
+
text: "Move only works within one account",
|
|
240
|
+
},
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
assert.match(text(html), /Move only works within one account/);
|
|
244
|
+
assert.match(html, /role="status"/);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("renders the notice's action as a real button, not prose", () => {
|
|
248
|
+
const onRetry = () => undefined;
|
|
249
|
+
const html = renderToString(
|
|
250
|
+
createElement(SelectionTopBar, {
|
|
251
|
+
...handlers,
|
|
252
|
+
count: 340,
|
|
253
|
+
notice: {
|
|
254
|
+
tone: "danger",
|
|
255
|
+
text: "3,072 moved to Trash. 340 couldn't be deleted.",
|
|
256
|
+
action: { label: "Retry 340", onClick: onRetry },
|
|
257
|
+
},
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
assert.match(html, /<button[^>]*>Retry 340<\/button>/);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("omits the notice when absent", () => {
|
|
264
|
+
const html = renderToString(
|
|
265
|
+
createElement(SelectionTopBar, { ...handlers, count: 2 }),
|
|
266
|
+
);
|
|
267
|
+
// Only the count/status line carries role="status" — no second one for
|
|
268
|
+
// an absent notice.
|
|
269
|
+
const statusRoles = (html.match(/role="status"/g) ?? []).length;
|
|
270
|
+
assert.equal(statusRoles, 1);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("renders a determinate progress bar when progress is provided", () => {
|
|
274
|
+
const html = renderToString(
|
|
275
|
+
createElement(SelectionTopBar, {
|
|
276
|
+
...handlers,
|
|
277
|
+
count: 3412,
|
|
278
|
+
statusLabel: "Deleting 1,200 of 3,412…",
|
|
279
|
+
isBusy: true,
|
|
280
|
+
progress: { value: 1200, max: 3412 },
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
assert.match(html, /role="progressbar"/);
|
|
284
|
+
assert.match(html, /aria-valuenow="1200"/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("omits the progress bar when progress is absent", () => {
|
|
288
|
+
const html = renderToString(
|
|
289
|
+
createElement(SelectionTopBar, { ...handlers, count: 2 }),
|
|
290
|
+
);
|
|
291
|
+
assert.doesNotMatch(html, /role="progressbar"/);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("omitting every new prop renders exactly the pre-existing bar shape", () => {
|
|
145
295
|
const html = renderToString(
|
|
146
296
|
createElement(SelectionTopBar, { ...handlers, count: 2 }),
|
|
147
297
|
);
|
|
@@ -151,6 +301,6 @@ describe("SelectionTopBar", () => {
|
|
|
151
301
|
"no select-all control",
|
|
152
302
|
);
|
|
153
303
|
assert.match(text(html), /2 messages selected/, "default count copy");
|
|
154
|
-
assert.doesNotMatch(html, /role="
|
|
304
|
+
assert.doesNotMatch(html, /role="progressbar"/, "no progress bar");
|
|
155
305
|
});
|
|
156
306
|
});
|
|
@@ -10,8 +10,10 @@ const meta: Meta<typeof SelectionTopBar> = {
|
|
|
10
10
|
onMarkRead: () => undefined,
|
|
11
11
|
onDelete: () => undefined,
|
|
12
12
|
},
|
|
13
|
+
// Full viewport width — a fixed w-[390px] wrapper inside a 390px Storybook
|
|
14
|
+
// viewport clipped the delete button off-screen in every story here.
|
|
13
15
|
render: (args) => (
|
|
14
|
-
<div className="w-
|
|
16
|
+
<div className="w-full rounded-md border border-line">
|
|
15
17
|
<SelectionTopBar {...args} />
|
|
16
18
|
</div>
|
|
17
19
|
),
|
|
@@ -33,12 +35,14 @@ export const Busy: Story = { args: { count: 2, isBusy: true } };
|
|
|
33
35
|
export const CrossAccountHint: Story = {
|
|
34
36
|
args: {
|
|
35
37
|
count: 4,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
notice: {
|
|
39
|
+
tone: "warning",
|
|
40
|
+
text: "Move only works within one account — clear selection or pick messages from a single account",
|
|
41
|
+
},
|
|
38
42
|
},
|
|
39
43
|
};
|
|
40
44
|
|
|
41
|
-
/** Some but not all rows checked: the select-all control renders the
|
|
45
|
+
/** Some but not all loaded rows checked: the select-all control renders the
|
|
42
46
|
* `Checkbox` tri-state dash, not the box or the tick. */
|
|
43
47
|
export const SelectAll: Story = {
|
|
44
48
|
args: {
|
|
@@ -51,44 +55,130 @@ export const SelectAll: Story = {
|
|
|
51
55
|
},
|
|
52
56
|
};
|
|
53
57
|
|
|
54
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Every loaded row checked. The count line names its scope by default —
|
|
60
|
+
* "All 47 loaded selected" — instead of a bare "47 messages selected" next
|
|
61
|
+
* to a fully ticked box, which reads as "everything" to anyone who has used
|
|
62
|
+
* a select-all checkbox before.
|
|
63
|
+
*/
|
|
55
64
|
export const AllSelected: Story = {
|
|
56
65
|
args: {
|
|
57
|
-
count:
|
|
66
|
+
count: 47,
|
|
67
|
+
selectAll: {
|
|
68
|
+
checked: true,
|
|
69
|
+
indeterminate: false,
|
|
70
|
+
onChange: () => undefined,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The search has more matches than are loaded: an escalation notice offers a
|
|
77
|
+
* real button (not prose) naming the total. Tapping it is what flips the
|
|
78
|
+
* selection's identity from an id set to the search query (out of scope for
|
|
79
|
+
* this kit — the caller supplies the count once paging resolves it).
|
|
80
|
+
*/
|
|
81
|
+
export const EscalationAvailable: Story = {
|
|
82
|
+
args: {
|
|
83
|
+
count: 47,
|
|
58
84
|
selectAll: {
|
|
59
85
|
checked: true,
|
|
60
86
|
indeterminate: false,
|
|
61
87
|
onChange: () => undefined,
|
|
62
88
|
},
|
|
89
|
+
notice: {
|
|
90
|
+
tone: "info",
|
|
91
|
+
text: "",
|
|
92
|
+
action: {
|
|
93
|
+
label: 'Select all 3,412 matching "npm"',
|
|
94
|
+
onClick: () => undefined,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
63
97
|
},
|
|
64
98
|
};
|
|
65
99
|
|
|
66
|
-
/**
|
|
67
|
-
*
|
|
100
|
+
/**
|
|
101
|
+
* Selection has been escalated to the search query: the count names the
|
|
102
|
+
* query's total, not a materialized id count, and the notice offers a way
|
|
103
|
+
* back to the bounded selection.
|
|
104
|
+
*/
|
|
105
|
+
export const Escalated: Story = {
|
|
106
|
+
args: {
|
|
107
|
+
count: 3412,
|
|
108
|
+
statusLabel: 'All 3,412 matching "npm" selected',
|
|
109
|
+
notice: {
|
|
110
|
+
tone: "info",
|
|
111
|
+
text: "",
|
|
112
|
+
action: { label: "Clear selection", onClick: () => undefined },
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* While a search result set is still paging, the exact count isn't known
|
|
119
|
+
* yet — a running total instead of a static "Counting…", delete hidden
|
|
120
|
+
* (nothing to act on with an unknown total), and an explicit Stop rather
|
|
121
|
+
* than overloading the X (which still means "cancel selection").
|
|
122
|
+
*/
|
|
68
123
|
export const Counting: Story = {
|
|
69
124
|
args: {
|
|
70
125
|
count: 0,
|
|
71
|
-
|
|
126
|
+
isCounting: true,
|
|
127
|
+
statusLabel: "Counting… 1,900 so far",
|
|
128
|
+
selectAll: {
|
|
129
|
+
checked: true,
|
|
130
|
+
indeterminate: false,
|
|
131
|
+
onChange: () => undefined,
|
|
132
|
+
},
|
|
133
|
+
notice: {
|
|
134
|
+
tone: "info",
|
|
135
|
+
text: "",
|
|
136
|
+
action: { label: "Stop", onClick: () => undefined },
|
|
137
|
+
},
|
|
72
138
|
},
|
|
73
139
|
};
|
|
74
140
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
141
|
+
/** Past ~10s the counting state says so, rather than looking stuck. */
|
|
142
|
+
export const CountingLargeResultSet: Story = {
|
|
143
|
+
args: {
|
|
144
|
+
count: 0,
|
|
145
|
+
isCounting: true,
|
|
146
|
+
statusLabel: "Counting… 12,400 so far. This is a big result set.",
|
|
147
|
+
notice: {
|
|
148
|
+
tone: "info",
|
|
149
|
+
text: "",
|
|
150
|
+
action: { label: "Stop", onClick: () => undefined },
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A bulk delete in progress reports a running total via `statusLabel` and a
|
|
157
|
+
* determinate `ProgressBar`; the delete button shows its busy spinner (never
|
|
158
|
+
* disables) and mark-read is hidden — nothing here can act mid-delete.
|
|
159
|
+
*/
|
|
77
160
|
export const DeletingWithProgress: Story = {
|
|
78
161
|
args: {
|
|
79
162
|
count: 3412,
|
|
80
163
|
statusLabel: "Deleting 1,200 of 3,412…",
|
|
81
164
|
isBusy: true,
|
|
165
|
+
progress: { value: 1200, max: 3412 },
|
|
82
166
|
},
|
|
83
167
|
};
|
|
84
168
|
|
|
85
|
-
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
169
|
+
/**
|
|
170
|
+
* After a bulk delete finishes with some batches failed: the count reflects
|
|
171
|
+
* only what's still selected — the failures — not the original selection,
|
|
172
|
+
* and Retry is a real button naming how many.
|
|
173
|
+
*/
|
|
88
174
|
export const PartialFailure: Story = {
|
|
89
175
|
args: {
|
|
90
|
-
count:
|
|
91
|
-
|
|
176
|
+
count: 340,
|
|
177
|
+
notice: {
|
|
178
|
+
tone: "danger",
|
|
179
|
+
text: "3,072 moved to Trash. 340 couldn't be deleted.",
|
|
180
|
+
action: { label: "Retry 340", onClick: () => undefined },
|
|
181
|
+
},
|
|
92
182
|
},
|
|
93
183
|
};
|
|
94
184
|
|
|
@@ -1,34 +1,51 @@
|
|
|
1
1
|
import { Loader2, MailOpen, Trash2, X } from "lucide-react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
|
+
import { Banner, type BannerTone } from "./banner.js";
|
|
3
4
|
import { Button } from "./button.js";
|
|
4
5
|
import { Checkbox } from "./checkbox.js";
|
|
6
|
+
import { ProgressBar } from "./progress-bar.js";
|
|
7
|
+
|
|
8
|
+
const formatCount = (n: number): string => n.toLocaleString();
|
|
9
|
+
|
|
10
|
+
export interface SelectionTopBarNoticeAction {
|
|
11
|
+
label: string;
|
|
12
|
+
onClick: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SelectionTopBarNotice {
|
|
16
|
+
tone: BannerTone;
|
|
17
|
+
text: string;
|
|
18
|
+
action?: SelectionTopBarNoticeAction;
|
|
19
|
+
}
|
|
5
20
|
|
|
6
21
|
export interface SelectionTopBarProps {
|
|
7
22
|
count: number;
|
|
8
23
|
onCancel: () => void;
|
|
9
24
|
onDelete: () => void;
|
|
10
|
-
/** Optional — hide the mark-read button when omitted
|
|
25
|
+
/** Optional — hide the mark-read button when omitted, or while `isBusy`. */
|
|
11
26
|
onMarkRead?: () => void;
|
|
12
27
|
/**
|
|
13
28
|
* Slot for a move-to-folder trigger. Rendered between mark-read and delete.
|
|
14
29
|
* Kept as a render prop so the caller controls API dependencies.
|
|
15
30
|
*/
|
|
16
31
|
moveSlot?: ReactNode;
|
|
17
|
-
/**
|
|
18
|
-
* Cross-account hint surfaced below the action row. When set, Move is
|
|
19
|
-
* expected to be suppressed by the caller (via moveSlot).
|
|
20
|
-
*/
|
|
21
|
-
moveDisabledHint?: string;
|
|
22
32
|
/**
|
|
23
33
|
* True while a delete or move mutation is in flight. The delete button
|
|
24
|
-
* shows a spinner
|
|
34
|
+
* shows a spinner and mark-read is hidden (never disabled — nothing here
|
|
35
|
+
* disables, states that can't act are hidden instead).
|
|
25
36
|
*/
|
|
26
37
|
isBusy?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* True while a search result set is still paging to find its total. Hides
|
|
40
|
+
* delete — the count it would act on isn't known yet.
|
|
41
|
+
*/
|
|
42
|
+
isCounting?: boolean;
|
|
27
43
|
/**
|
|
28
44
|
* Select-all control rendered between cancel and the count label. Presence
|
|
29
45
|
* of this prop is what renders the checkbox — omit it for a bar with no
|
|
30
46
|
* select-all affordance. `indeterminate` renders the some-selected tri-state
|
|
31
|
-
* (`Checkbox`'s dash), `checked` is the all-selected state.
|
|
47
|
+
* (`Checkbox`'s dash), `checked` is the all-selected state. The checkbox
|
|
48
|
+
* itself stays visually small; a wrapping 44px hit area makes it tappable.
|
|
32
49
|
*/
|
|
33
50
|
selectAll?: {
|
|
34
51
|
checked: boolean;
|
|
@@ -36,18 +53,31 @@ export interface SelectionTopBarProps {
|
|
|
36
53
|
onChange: () => void;
|
|
37
54
|
};
|
|
38
55
|
/**
|
|
39
|
-
* Overrides the "{count} messages selected" text.
|
|
40
|
-
* count
|
|
41
|
-
*
|
|
42
|
-
*
|
|
56
|
+
* Overrides the default "{count} messages selected" text. Required once the
|
|
57
|
+
* count's scope is anything other than "every loaded row is selected" —
|
|
58
|
+
* an escalated selection ("All 3,412 matching \"npm\" selected"), a
|
|
59
|
+
* counting state ("Counting… 1,900 so far"), or bulk-delete progress
|
|
60
|
+
* ("Deleting 1,200 of 3,412…"). When `selectAll.checked` is true and this
|
|
61
|
+
* is omitted, the default text names the loaded-scope itself ("All 47
|
|
62
|
+
* loaded selected") rather than a bare count — a ticked select-all box
|
|
63
|
+
* next to a bare number reads as "everything", which is only true for the
|
|
64
|
+
* escalated case.
|
|
43
65
|
*/
|
|
44
66
|
statusLabel?: string;
|
|
45
67
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
68
|
+
* Determinate progress for a bulk operation in flight (e.g. a chunked
|
|
69
|
+
* delete). Renders a `ProgressBar` below the action row. Independent of
|
|
70
|
+
* `notice` — a caller can show progress and a notice at the same time.
|
|
71
|
+
*/
|
|
72
|
+
progress?: { value: number; max: number; tone?: BannerTone };
|
|
73
|
+
/**
|
|
74
|
+
* Toned status line below the action row, sometimes carrying an action
|
|
75
|
+
* button — a cross-account move restriction, a "Select all N matching…"
|
|
76
|
+
* escalation, a "Stop" during counting, or a partial-failure "Retry N".
|
|
77
|
+
* Replaces the old `moveDisabledHint`/`failureHint` pair: a caller shows
|
|
78
|
+
* at most one notice at a time.
|
|
49
79
|
*/
|
|
50
|
-
|
|
80
|
+
notice?: SelectionTopBarNotice;
|
|
51
81
|
}
|
|
52
82
|
|
|
53
83
|
/**
|
|
@@ -60,83 +90,106 @@ export function SelectionTopBar({
|
|
|
60
90
|
onMarkRead,
|
|
61
91
|
onDelete,
|
|
62
92
|
moveSlot,
|
|
63
|
-
moveDisabledHint,
|
|
64
93
|
isBusy = false,
|
|
94
|
+
isCounting = false,
|
|
65
95
|
selectAll,
|
|
66
96
|
statusLabel,
|
|
67
|
-
|
|
97
|
+
progress,
|
|
98
|
+
notice,
|
|
68
99
|
}: SelectionTopBarProps) {
|
|
100
|
+
const defaultLabel = selectAll?.checked
|
|
101
|
+
? `All ${formatCount(count)} loaded selected`
|
|
102
|
+
: `${formatCount(count)} ${count === 1 ? "message" : "messages"} selected`;
|
|
103
|
+
|
|
69
104
|
return (
|
|
70
105
|
<header className="flex shrink-0 flex-col border-b border-line bg-surface-sunken">
|
|
71
106
|
<div className="flex h-pane-header items-center gap-2 px-row-inset">
|
|
72
107
|
<Button
|
|
73
108
|
variant="ghost"
|
|
74
|
-
size="
|
|
109
|
+
size="touch"
|
|
75
110
|
icon={<X className="size-4" />}
|
|
76
111
|
onClick={onCancel}
|
|
77
112
|
aria-label="Cancel selection"
|
|
78
|
-
className="-ml-
|
|
113
|
+
className="-ml-2 shrink-0"
|
|
79
114
|
/>
|
|
80
115
|
{selectAll && (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
116
|
+
// biome-ignore lint/a11y/noLabelWithoutControl: label wraps Checkbox's own input, giving the 20px control a real 44px hit area
|
|
117
|
+
<label className="-ml-1.5 flex size-11 shrink-0 cursor-pointer items-center justify-center">
|
|
118
|
+
<Checkbox
|
|
119
|
+
aria-label="Select all"
|
|
120
|
+
checked={selectAll.checked}
|
|
121
|
+
indeterminate={selectAll.indeterminate}
|
|
122
|
+
onChange={selectAll.onChange}
|
|
123
|
+
/>
|
|
124
|
+
</label>
|
|
88
125
|
)}
|
|
89
|
-
<span
|
|
90
|
-
|
|
91
|
-
|
|
126
|
+
<span
|
|
127
|
+
className="min-w-0 flex-1 truncate text-sm font-medium text-fg"
|
|
128
|
+
role="status"
|
|
129
|
+
aria-live="polite"
|
|
130
|
+
>
|
|
131
|
+
{statusLabel ?? defaultLabel}
|
|
92
132
|
</span>
|
|
93
|
-
{onMarkRead && (
|
|
133
|
+
{onMarkRead && !isBusy && (
|
|
94
134
|
<Button
|
|
95
135
|
variant="ghost"
|
|
96
|
-
size="
|
|
136
|
+
size="touch"
|
|
97
137
|
icon={<MailOpen className="size-4" />}
|
|
98
|
-
onClick={
|
|
138
|
+
onClick={onMarkRead}
|
|
99
139
|
aria-label="Mark as read"
|
|
100
|
-
aria-busy={isBusy || undefined}
|
|
101
140
|
className="shrink-0"
|
|
102
141
|
/>
|
|
103
142
|
)}
|
|
104
143
|
{moveSlot}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
144
|
+
{!isCounting && (
|
|
145
|
+
<Button
|
|
146
|
+
variant="ghost"
|
|
147
|
+
size="touch"
|
|
148
|
+
icon={
|
|
149
|
+
isBusy ? (
|
|
150
|
+
<Loader2 className="size-4 animate-spin" />
|
|
151
|
+
) : (
|
|
152
|
+
<Trash2 className="size-4 text-danger" />
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
onClick={isBusy ? undefined : onDelete}
|
|
156
|
+
aria-label="Move selected messages to Trash"
|
|
157
|
+
aria-busy={isBusy || undefined}
|
|
158
|
+
className="ml-4 shrink-0"
|
|
159
|
+
/>
|
|
160
|
+
)}
|
|
120
161
|
</div>
|
|
121
|
-
{
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
</p>
|
|
162
|
+
{progress && (
|
|
163
|
+
<div className="px-row-inset pb-2">
|
|
164
|
+
<ProgressBar
|
|
165
|
+
value={progress.value}
|
|
166
|
+
max={progress.max}
|
|
167
|
+
tone={progress.tone}
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
130
170
|
)}
|
|
131
|
-
{
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
171
|
+
{notice && (
|
|
172
|
+
<Banner
|
|
173
|
+
tone={notice.tone}
|
|
174
|
+
variant="soft"
|
|
135
175
|
role="status"
|
|
136
176
|
aria-live="polite"
|
|
177
|
+
className="mx-row-inset mb-2"
|
|
137
178
|
>
|
|
138
|
-
|
|
139
|
-
|
|
179
|
+
<div className="flex items-center justify-between gap-2">
|
|
180
|
+
{notice.text && <span>{notice.text}</span>}
|
|
181
|
+
{notice.action && (
|
|
182
|
+
<Button
|
|
183
|
+
variant="ghost"
|
|
184
|
+
size="md"
|
|
185
|
+
onClick={notice.action.onClick}
|
|
186
|
+
className="-my-1 min-h-11 shrink-0"
|
|
187
|
+
>
|
|
188
|
+
{notice.action.label}
|
|
189
|
+
</Button>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
</Banner>
|
|
140
193
|
)}
|
|
141
194
|
</header>
|
|
142
195
|
);
|
|
@@ -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
|
@@ -235,6 +235,10 @@ export {
|
|
|
235
235
|
type PopoverMenuItem,
|
|
236
236
|
type PopoverMenuProps,
|
|
237
237
|
} from "./components/popover-menu.js";
|
|
238
|
+
export {
|
|
239
|
+
ProgressBar,
|
|
240
|
+
type ProgressBarProps,
|
|
241
|
+
} from "./components/progress-bar.js";
|
|
238
242
|
export {
|
|
239
243
|
PullToRefresh,
|
|
240
244
|
type PullToRefreshProps,
|
|
@@ -336,6 +340,8 @@ export {
|
|
|
336
340
|
export { Select, type SelectProps } from "./components/select.js";
|
|
337
341
|
export {
|
|
338
342
|
SelectionTopBar,
|
|
343
|
+
type SelectionTopBarNotice,
|
|
344
|
+
type SelectionTopBarNoticeAction,
|
|
339
345
|
type SelectionTopBarProps,
|
|
340
346
|
} from "./components/selection-top-bar.js";
|
|
341
347
|
export {
|