@remit/ui 0.0.14 → 0.0.16
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 +44 -2
- 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/quarantine-bug-dialog.tsx +72 -0
- package/src/components/quarantine-entry-row.tsx +71 -0
- package/src/components/quarantine-fixtures.ts +81 -0
- package/src/components/quarantine-report.test.ts +151 -0
- package/src/components/quarantine-report.ts +221 -0
- package/src/components/quarantine-section.render.test.ts +101 -0
- package/src/components/quarantine-section.stories.tsx +74 -0
- package/src/components/quarantine-section.tsx +71 -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 +37 -1
|
@@ -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
|
@@ -75,7 +75,12 @@ export {
|
|
|
75
75
|
BriefSections,
|
|
76
76
|
type BriefSectionsProps,
|
|
77
77
|
} from "./components/brief-sections.js";
|
|
78
|
-
export {
|
|
78
|
+
export {
|
|
79
|
+
Button,
|
|
80
|
+
ButtonLink,
|
|
81
|
+
type ButtonLinkProps,
|
|
82
|
+
type ButtonProps,
|
|
83
|
+
} from "./components/button.js";
|
|
79
84
|
export {
|
|
80
85
|
Card,
|
|
81
86
|
CardBody,
|
|
@@ -235,10 +240,39 @@ export {
|
|
|
235
240
|
type PopoverMenuItem,
|
|
236
241
|
type PopoverMenuProps,
|
|
237
242
|
} from "./components/popover-menu.js";
|
|
243
|
+
export {
|
|
244
|
+
ProgressBar,
|
|
245
|
+
type ProgressBarProps,
|
|
246
|
+
} from "./components/progress-bar.js";
|
|
238
247
|
export {
|
|
239
248
|
PullToRefresh,
|
|
240
249
|
type PullToRefreshProps,
|
|
241
250
|
} from "./components/pull-to-refresh.js";
|
|
251
|
+
export {
|
|
252
|
+
QuarantineBugDialog,
|
|
253
|
+
type QuarantineBugDialogProps,
|
|
254
|
+
} from "./components/quarantine-bug-dialog.js";
|
|
255
|
+
export {
|
|
256
|
+
QuarantineEntryRow,
|
|
257
|
+
type QuarantineEntryRowProps,
|
|
258
|
+
} from "./components/quarantine-entry-row.js";
|
|
259
|
+
export { quarantineDemoEntries } from "./components/quarantine-fixtures.js";
|
|
260
|
+
export {
|
|
261
|
+
formatQuarantineReport,
|
|
262
|
+
QUARANTINE_REPORT_DISCLAIMER,
|
|
263
|
+
type QuarantineEntry,
|
|
264
|
+
type QuarantineFailureCode,
|
|
265
|
+
type QuarantineFailureStage,
|
|
266
|
+
type QuarantineMimeNode,
|
|
267
|
+
type QuarantineReportSections,
|
|
268
|
+
quarantineIssueTitle,
|
|
269
|
+
quarantineReportSections,
|
|
270
|
+
quarantineSummary,
|
|
271
|
+
} from "./components/quarantine-report.js";
|
|
272
|
+
export {
|
|
273
|
+
QuarantineSection,
|
|
274
|
+
type QuarantineSectionProps,
|
|
275
|
+
} from "./components/quarantine-section.js";
|
|
242
276
|
export {
|
|
243
277
|
QuotedText,
|
|
244
278
|
type QuotedTextProps,
|
|
@@ -336,6 +370,8 @@ export {
|
|
|
336
370
|
export { Select, type SelectProps } from "./components/select.js";
|
|
337
371
|
export {
|
|
338
372
|
SelectionTopBar,
|
|
373
|
+
type SelectionTopBarNotice,
|
|
374
|
+
type SelectionTopBarNoticeAction,
|
|
339
375
|
type SelectionTopBarProps,
|
|
340
376
|
} from "./components/selection-top-bar.js";
|
|
341
377
|
export {
|