@remit/ui 0.0.77 → 0.0.78
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
CHANGED
|
@@ -15,9 +15,7 @@
|
|
|
15
15
|
* The second: a finger held on glass drifts, and it drifts past the distance
|
|
16
16
|
* at which the drag starts tracking. Cancelling the press there meant a real
|
|
17
17
|
* hold on a phone never entered selection mode — the row followed the drift
|
|
18
|
-
* and snapped back, and nothing else happened.
|
|
19
|
-
* against the anchor (`linkComponent`) row the mailbox list actually renders,
|
|
20
|
-
* which is also where the native link menu has to stay suppressed.
|
|
18
|
+
* and snapped back, and nothing else happened.
|
|
21
19
|
*/
|
|
22
20
|
|
|
23
21
|
import assert from "node:assert/strict";
|
|
@@ -123,36 +121,6 @@ function mount(handlers: Handlers) {
|
|
|
123
121
|
return row;
|
|
124
122
|
}
|
|
125
123
|
|
|
126
|
-
// The mailbox list renders the open affordance as a real anchor via
|
|
127
|
-
// `linkComponent`; the native long-press menu only bites on an `<a href>`.
|
|
128
|
-
function mountAnchor(handlers: Handlers) {
|
|
129
|
-
act(() => {
|
|
130
|
-
root.render(
|
|
131
|
-
createElement(SwipeableRow, {
|
|
132
|
-
thread,
|
|
133
|
-
selectionMode: false,
|
|
134
|
-
checked: false,
|
|
135
|
-
active: false,
|
|
136
|
-
peek: "none",
|
|
137
|
-
onPeek: handlers.onPeek,
|
|
138
|
-
onToggleCheck: () => undefined,
|
|
139
|
-
onLongPress: handlers.onLongPress,
|
|
140
|
-
onOpen: handlers.onOpen,
|
|
141
|
-
onAct: () => undefined,
|
|
142
|
-
linkComponent: ({ onOpenClick, children, ...rowProps }) =>
|
|
143
|
-
createElement(
|
|
144
|
-
"a",
|
|
145
|
-
{ ...rowProps, id: "row", href: "/thread/1", onClick: onOpenClick },
|
|
146
|
-
children,
|
|
147
|
-
),
|
|
148
|
-
}),
|
|
149
|
-
);
|
|
150
|
-
});
|
|
151
|
-
const row = dom.window.document.getElementById("row");
|
|
152
|
-
assert.ok(row, "anchor row did not mount");
|
|
153
|
-
return row;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
124
|
function contextMenu(row: Element) {
|
|
157
125
|
const event = new dom.window.MouseEvent("contextmenu", {
|
|
158
126
|
bubbles: true,
|
|
@@ -309,7 +277,7 @@ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)
|
|
|
309
277
|
let longPressed = 0;
|
|
310
278
|
let opened = 0;
|
|
311
279
|
const peeks: SwipePeek[] = [];
|
|
312
|
-
const row =
|
|
280
|
+
const row = mount({
|
|
313
281
|
onLongPress: () => longPressed++,
|
|
314
282
|
onOpen: () => opened++,
|
|
315
283
|
onPeek: (next) => peeks.push(next),
|
|
@@ -331,7 +299,7 @@ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)
|
|
|
331
299
|
it("a hold that drifts vertically past the axis threshold still enters selection mode", async () => {
|
|
332
300
|
let longPressed = 0;
|
|
333
301
|
const peeks: SwipePeek[] = [];
|
|
334
|
-
const row =
|
|
302
|
+
const row = mount({
|
|
335
303
|
onLongPress: () => longPressed++,
|
|
336
304
|
onOpen: () => undefined,
|
|
337
305
|
onPeek: (next) => peeks.push(next),
|
|
@@ -349,10 +317,10 @@ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)
|
|
|
349
317
|
|
|
350
318
|
it("an aborted short drag opens nothing on release", async () => {
|
|
351
319
|
// The drag claimed the axis but never reached the escape distance, so the
|
|
352
|
-
// press was still live
|
|
353
|
-
// press
|
|
320
|
+
// press was still live, and react-aria synthesizes a click for an
|
|
321
|
+
// unresolved press.
|
|
354
322
|
let clicks = 0;
|
|
355
|
-
const row =
|
|
323
|
+
const row = mount({
|
|
356
324
|
onLongPress: () => undefined,
|
|
357
325
|
onOpen: () => undefined,
|
|
358
326
|
onPeek: () => undefined,
|
|
@@ -367,10 +335,10 @@ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)
|
|
|
367
335
|
assert.equal(clicks, 0);
|
|
368
336
|
});
|
|
369
337
|
|
|
370
|
-
it("suppresses the native
|
|
371
|
-
// Android Chrome raises
|
|
372
|
-
//
|
|
373
|
-
const row =
|
|
338
|
+
it("suppresses the native context menu raised over a drifting hold", async () => {
|
|
339
|
+
// Android Chrome raises its own menu at its own threshold, which can land
|
|
340
|
+
// mid-drift and ahead of the app's long press.
|
|
341
|
+
const row = mount({
|
|
374
342
|
onLongPress: () => undefined,
|
|
375
343
|
onOpen: () => undefined,
|
|
376
344
|
onPeek: () => undefined,
|
|
@@ -3,11 +3,7 @@ import { describe, it } from "node:test";
|
|
|
3
3
|
import { createElement } from "react";
|
|
4
4
|
import { renderToString } from "react-dom/server";
|
|
5
5
|
import type { ThreadRowData } from "./app-shell-types.js";
|
|
6
|
-
import {
|
|
7
|
-
SwipeableRow,
|
|
8
|
-
type SwipeableRowOpenProps,
|
|
9
|
-
type SwipePeek,
|
|
10
|
-
} from "./swipeable-row.js";
|
|
6
|
+
import { SwipeableRow, type SwipePeek } from "./swipeable-row.js";
|
|
11
7
|
|
|
12
8
|
const thread: ThreadRowData = {
|
|
13
9
|
id: "thread-1",
|
|
@@ -63,45 +59,14 @@ describe("SwipeableRow", () => {
|
|
|
63
59
|
assert.match(read, /aria-label="Mark as unread"/);
|
|
64
60
|
});
|
|
65
61
|
|
|
66
|
-
it("
|
|
67
|
-
const html
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
createElement(SwipeableRow, {
|
|
75
|
-
...baseProps,
|
|
76
|
-
peek: "none",
|
|
77
|
-
linkComponent: ({ className, children }: SwipeableRowOpenProps) =>
|
|
78
|
-
createElement(
|
|
79
|
-
"a",
|
|
80
|
-
{ href: "/mail/m1?selectedMessageId=t1", className },
|
|
81
|
-
children,
|
|
82
|
-
),
|
|
83
|
-
}),
|
|
84
|
-
);
|
|
85
|
-
assert.match(html, /<a [^>]*href="\/mail\/m1\?selectedMessageId=t1"/);
|
|
86
|
-
assert.match(html, /Q3 planning notes/);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("keeps the button (not the anchor) in selection mode even with a linkComponent", () => {
|
|
90
|
-
const html = renderToString(
|
|
91
|
-
createElement(SwipeableRow, {
|
|
92
|
-
...baseProps,
|
|
93
|
-
selectionMode: true,
|
|
94
|
-
peek: "none",
|
|
95
|
-
linkComponent: ({ className, children }: SwipeableRowOpenProps) =>
|
|
96
|
-
createElement(
|
|
97
|
-
"a",
|
|
98
|
-
{ href: "/should-not-render", className },
|
|
99
|
-
children,
|
|
100
|
-
),
|
|
101
|
-
}),
|
|
102
|
-
);
|
|
103
|
-
assert.doesNotMatch(html, /<a /);
|
|
104
|
-
assert.match(html, /<button[^>]*>/);
|
|
62
|
+
it("opens through a button, never an anchor (#116)", () => {
|
|
63
|
+
for (const html of [
|
|
64
|
+
render("none"),
|
|
65
|
+
render("none", { selectionMode: true }),
|
|
66
|
+
]) {
|
|
67
|
+
assert.match(html, /<button[^>]*data-message-row/);
|
|
68
|
+
assert.doesNotMatch(html, /<a /);
|
|
69
|
+
}
|
|
105
70
|
});
|
|
106
71
|
|
|
107
72
|
it("gives the row checkbox semantics while in selection mode", () => {
|
|
@@ -69,31 +69,6 @@ export const SelectionChecked: Story = {
|
|
|
69
69
|
args: { peek: "none", selectionMode: true, checked: true },
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
-
/**
|
|
73
|
-
* The open affordance is rendered as a real `<a href>` via `linkComponent`,
|
|
74
|
-
* so deep-link, middle-click and open-in-new-tab work. Consumers pass their
|
|
75
|
-
* router's Link; here a plain anchor stands in. Inspect the DOM: the row is an
|
|
76
|
-
* anchor, not a button.
|
|
77
|
-
*/
|
|
78
|
-
export const AsAnchor: Story = {
|
|
79
|
-
name: "As anchor (linkComponent)",
|
|
80
|
-
args: {
|
|
81
|
-
peek: "none",
|
|
82
|
-
linkComponent: ({ onOpenClick, children, ...rowProps }) => (
|
|
83
|
-
<a
|
|
84
|
-
{...rowProps}
|
|
85
|
-
href="/mail/inbox?selectedMessageId=thread-1"
|
|
86
|
-
onClick={(e) => {
|
|
87
|
-
e.preventDefault();
|
|
88
|
-
onOpenClick(e);
|
|
89
|
-
}}
|
|
90
|
-
>
|
|
91
|
-
{children}
|
|
92
|
-
</a>
|
|
93
|
-
),
|
|
94
|
-
},
|
|
95
|
-
};
|
|
96
|
-
|
|
97
72
|
export const Acting: Story = {
|
|
98
73
|
name: "Acting (interactive)",
|
|
99
74
|
render: () => {
|
|
@@ -130,7 +105,7 @@ export const Acting: Story = {
|
|
|
130
105
|
},
|
|
131
106
|
};
|
|
132
107
|
|
|
133
|
-
function
|
|
108
|
+
function GestureRow({
|
|
134
109
|
onLongPress,
|
|
135
110
|
selectionMode,
|
|
136
111
|
checked,
|
|
@@ -147,23 +122,14 @@ function AnchorRow({
|
|
|
147
122
|
selectionMode={selectionMode ?? false}
|
|
148
123
|
checked={checked ?? false}
|
|
149
124
|
onLongPress={onLongPress ?? (() => undefined)}
|
|
150
|
-
linkComponent={({ onOpenClick, children, ...rowProps }) => (
|
|
151
|
-
<a
|
|
152
|
-
{...rowProps}
|
|
153
|
-
href="/mail/inbox?selectedMessageId=thread-1"
|
|
154
|
-
onClick={(e) => {
|
|
155
|
-
e.preventDefault();
|
|
156
|
-
onOpenClick(e);
|
|
157
|
-
}}
|
|
158
|
-
>
|
|
159
|
-
{children}
|
|
160
|
-
</a>
|
|
161
|
-
)}
|
|
162
125
|
/>
|
|
163
126
|
</PhoneFrame>
|
|
164
127
|
);
|
|
165
128
|
}
|
|
166
129
|
|
|
130
|
+
const rowElement = (canvasElement: HTMLElement) =>
|
|
131
|
+
canvasElement.querySelector<HTMLButtonElement>("button[data-message-row]");
|
|
132
|
+
|
|
167
133
|
/**
|
|
168
134
|
* The long press is the way into multi-select on touch. Press and hold the row
|
|
169
135
|
* (mouse hold works too — react-aria fires the long press for both) and it
|
|
@@ -177,7 +143,7 @@ export const LongPressToSelect: Story = {
|
|
|
177
143
|
const [selected, setSelected] = useState(false);
|
|
178
144
|
return (
|
|
179
145
|
<div className="space-y-2">
|
|
180
|
-
<
|
|
146
|
+
<GestureRow
|
|
181
147
|
selectionMode={selected}
|
|
182
148
|
checked={selected}
|
|
183
149
|
onLongPress={() => setSelected((v) => !v)}
|
|
@@ -206,7 +172,7 @@ export const LongPressWithDrift: Story = {
|
|
|
206
172
|
const [selected, setSelected] = useState(false);
|
|
207
173
|
return (
|
|
208
174
|
<div className="space-y-2">
|
|
209
|
-
<
|
|
175
|
+
<GestureRow
|
|
210
176
|
selectionMode={selected}
|
|
211
177
|
checked={selected}
|
|
212
178
|
onLongPress={() => setSelected(true)}
|
|
@@ -220,10 +186,10 @@ export const LongPressWithDrift: Story = {
|
|
|
220
186
|
);
|
|
221
187
|
},
|
|
222
188
|
play: async ({ canvasElement }) => {
|
|
223
|
-
const
|
|
224
|
-
if (!
|
|
189
|
+
const row = rowElement(canvasElement);
|
|
190
|
+
if (!row) return;
|
|
225
191
|
const touch = (type: string, clientX: number, clientY: number) =>
|
|
226
|
-
|
|
192
|
+
row.dispatchEvent(
|
|
227
193
|
new PointerEvent(type, {
|
|
228
194
|
bubbles: true,
|
|
229
195
|
pointerType: "touch",
|
|
@@ -247,30 +213,29 @@ export const LongPressWithDrift: Story = {
|
|
|
247
213
|
};
|
|
248
214
|
|
|
249
215
|
/**
|
|
250
|
-
* A touch long press over a
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
* writes the outcome below.
|
|
216
|
+
* A touch long press over a row normally raises the browser's own context menu,
|
|
217
|
+
* which collides with the long-press-to-select gesture above. `useLongPress`
|
|
218
|
+
* suppresses that menu when the press came from touch or pen, while leaving a
|
|
219
|
+
* mouse right-click's menu alone. The play step drives a synthetic touch press
|
|
220
|
+
* then a contextmenu and writes the outcome below.
|
|
256
221
|
*/
|
|
257
222
|
export const TouchContextMenuSuppressed: Story = {
|
|
258
223
|
name: "Touch context menu suppressed",
|
|
259
224
|
render: () => (
|
|
260
225
|
<div className="space-y-2">
|
|
261
|
-
<
|
|
226
|
+
<GestureRow />
|
|
262
227
|
<p data-testid="context-menu-outcome" className="text-xs text-fg-muted">
|
|
263
228
|
Waiting for a touch press…
|
|
264
229
|
</p>
|
|
265
230
|
</div>
|
|
266
231
|
),
|
|
267
232
|
play: async ({ canvasElement }) => {
|
|
268
|
-
const
|
|
233
|
+
const row = rowElement(canvasElement);
|
|
269
234
|
const outcome = canvasElement.querySelector<HTMLParagraphElement>(
|
|
270
235
|
'[data-testid="context-menu-outcome"]',
|
|
271
236
|
);
|
|
272
|
-
if (!
|
|
273
|
-
|
|
237
|
+
if (!row || !outcome) return;
|
|
238
|
+
row.dispatchEvent(
|
|
274
239
|
new PointerEvent("pointerdown", {
|
|
275
240
|
bubbles: true,
|
|
276
241
|
pointerType: "touch",
|
|
@@ -281,7 +246,7 @@ export const TouchContextMenuSuppressed: Story = {
|
|
|
281
246
|
bubbles: true,
|
|
282
247
|
cancelable: true,
|
|
283
248
|
});
|
|
284
|
-
|
|
249
|
+
row.dispatchEvent(menu);
|
|
285
250
|
outcome.textContent = menu.defaultPrevented
|
|
286
251
|
? "Native context menu suppressed on touch."
|
|
287
252
|
: "Native context menu allowed.";
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { DOMAttributes } from "@react-types/shared";
|
|
2
1
|
import { Check, Mail, MailOpen, Trash2 } from "lucide-react";
|
|
3
2
|
import { useRef, useState } from "react";
|
|
4
3
|
import { mergeProps } from "react-aria";
|
|
@@ -64,26 +63,6 @@ export function commitPeek(offset: number): SwipePeek {
|
|
|
64
63
|
return "none";
|
|
65
64
|
}
|
|
66
65
|
|
|
67
|
-
/**
|
|
68
|
-
* Props the row's interactive (open) element must receive — the swipe gesture
|
|
69
|
-
* handlers (merged with react-aria's long-press props: pointer handlers,
|
|
70
|
-
* `aria-describedby`, and friends), the transform/transition style, the row
|
|
71
|
-
* body, plus an onClick that suppresses navigation when the row is peeked. A
|
|
72
|
-
* consumer passes `linkComponent` to render these on a real anchor (e.g. a
|
|
73
|
-
* router Link) so the open affordance is a true `<a href>` — keeping
|
|
74
|
-
* open-in-new-tab, middle-click, deep-link and a11y — instead of the default
|
|
75
|
-
* JS-only `<button onOpen>`.
|
|
76
|
-
*/
|
|
77
|
-
export interface SwipeableRowOpenProps extends DOMAttributes {
|
|
78
|
-
className: string;
|
|
79
|
-
style: React.CSSProperties;
|
|
80
|
-
/** Wire to the anchor's onClick. When the row is peeked it calls
|
|
81
|
-
* preventDefault so a tap closes the peek instead of navigating; otherwise
|
|
82
|
-
* it is a no-op and the anchor's native navigation proceeds. */
|
|
83
|
-
onOpenClick: (e: { preventDefault: () => void }) => void;
|
|
84
|
-
children: React.ReactNode;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
66
|
export function SwipeableRow({
|
|
88
67
|
thread,
|
|
89
68
|
selectionMode,
|
|
@@ -95,7 +74,6 @@ export function SwipeableRow({
|
|
|
95
74
|
onLongPress,
|
|
96
75
|
onOpen,
|
|
97
76
|
onAct,
|
|
98
|
-
linkComponent,
|
|
99
77
|
}: {
|
|
100
78
|
thread: ThreadRowData;
|
|
101
79
|
selectionMode: boolean;
|
|
@@ -109,12 +87,6 @@ export function SwipeableRow({
|
|
|
109
87
|
/** Tapping a revealed action performs it: "leading" = toggle read,
|
|
110
88
|
* "trailing" = delete. */
|
|
111
89
|
onAct: (side: "leading" | "trailing") => void;
|
|
112
|
-
/** Render the open affordance as a real anchor (router Link / `<a href>`)
|
|
113
|
-
* instead of the default `<button onOpen>`. Receives the gesture handlers,
|
|
114
|
-
* style, peek-aware onClickCapture and row body to spread onto the anchor.
|
|
115
|
-
* When provided, a plain tap navigates via the anchor's native click (so
|
|
116
|
-
* deep-link/middle-click work); onOpen is not called for the tap. */
|
|
117
|
-
linkComponent?: (props: SwipeableRowOpenProps) => React.ReactNode;
|
|
118
90
|
}) {
|
|
119
91
|
const gesture = useRef<{
|
|
120
92
|
startX: number;
|
|
@@ -197,9 +169,8 @@ export function SwipeableRow({
|
|
|
197
169
|
// or a real cancel arrived) — those handle themselves, so do nothing.
|
|
198
170
|
if (!g) return;
|
|
199
171
|
// A drag that claimed an axis but never escaped left react-aria's press
|
|
200
|
-
// live, and an unresolved press synthesizes a click on release
|
|
201
|
-
//
|
|
202
|
-
// tap, so end the press with it.
|
|
172
|
+
// live, and an unresolved press synthesizes a click on release. The drag
|
|
173
|
+
// was neither a swipe nor a tap, so end the press with it.
|
|
203
174
|
if (g.axis !== "none" && !g.escaped) cancelLongPress(e);
|
|
204
175
|
if (g.axis === "horizontal") {
|
|
205
176
|
if (offset !== null) onPeek(commitPeek(offset));
|
|
@@ -216,8 +187,6 @@ export function SwipeableRow({
|
|
|
216
187
|
onPeek("none");
|
|
217
188
|
return;
|
|
218
189
|
}
|
|
219
|
-
// A real anchor handles the open via its native click — don't double-fire.
|
|
220
|
-
if (linkComponent) return;
|
|
221
190
|
onOpen();
|
|
222
191
|
};
|
|
223
192
|
|
|
@@ -242,12 +211,6 @@ export function SwipeableRow({
|
|
|
242
211
|
onPointerCancel,
|
|
243
212
|
});
|
|
244
213
|
|
|
245
|
-
// A tap on a peeked anchor must close the peek, not navigate; suppress the
|
|
246
|
-
// native click in that case. onPointerUp already snapped it closed.
|
|
247
|
-
const onOpenClick = (e: { preventDefault: () => void }) => {
|
|
248
|
-
if (peek !== "none") e.preventDefault();
|
|
249
|
-
};
|
|
250
|
-
|
|
251
214
|
const offset = dragX ?? peekOffset(peek);
|
|
252
215
|
const revealed: SwipePeek =
|
|
253
216
|
offset > 0 ? "leading" : offset < 0 ? "trailing" : "none";
|
|
@@ -255,12 +218,9 @@ export function SwipeableRow({
|
|
|
255
218
|
const interactiveClassName = cn(
|
|
256
219
|
// opaque bg so the row occludes the action behind it until peeked
|
|
257
220
|
"relative touch-pan-y bg-surface",
|
|
258
|
-
// This row's long press enters selection mode; without
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
// suppresses the touch-fired contextmenu; the callout fires no
|
|
262
|
-
// cancelable event, so CSS is the only lever left for it.
|
|
263
|
-
"select-none [-webkit-touch-callout:none]",
|
|
221
|
+
// This row's long press enters selection mode; without this, the hold
|
|
222
|
+
// starts a native text selection across the row's subject and snippet.
|
|
223
|
+
"select-none",
|
|
264
224
|
comfortableRowClass({ active: checked || active }),
|
|
265
225
|
);
|
|
266
226
|
const interactiveStyle: React.CSSProperties = {
|
|
@@ -297,14 +257,14 @@ export function SwipeableRow({
|
|
|
297
257
|
)}
|
|
298
258
|
{/*
|
|
299
259
|
* Tappable, focusable entry point into selection mode — long-press is
|
|
300
|
-
* never the only way in.
|
|
301
|
-
*
|
|
302
|
-
*
|
|
260
|
+
* never the only way in. A span, not a <button>: it sits inside the
|
|
261
|
+
* row's own open button, which may not nest one. The checkbox role and
|
|
262
|
+
* label are what the user and the test suite address, so both stay.
|
|
303
263
|
*/}
|
|
304
|
-
{/* biome-ignore lint/a11y/useSemanticElements: a native <input type="checkbox"> can't host the Avatar as its visible content
|
|
305
|
-
<
|
|
306
|
-
type="button"
|
|
264
|
+
{/* biome-ignore lint/a11y/useSemanticElements: a native <input type="checkbox"> can't host the Avatar as its visible content, and a nested <button> inside the row's own button is invalid HTML */}
|
|
265
|
+
<span
|
|
307
266
|
role="checkbox"
|
|
267
|
+
tabIndex={0}
|
|
308
268
|
aria-checked={checked}
|
|
309
269
|
aria-label={`Select message from ${thread.fromName}`}
|
|
310
270
|
onPointerDown={stopRowGesture}
|
|
@@ -315,10 +275,16 @@ export function SwipeableRow({
|
|
|
315
275
|
e.stopPropagation();
|
|
316
276
|
onLongPress();
|
|
317
277
|
}}
|
|
278
|
+
onKeyDown={(e) => {
|
|
279
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
280
|
+
e.preventDefault();
|
|
281
|
+
e.stopPropagation();
|
|
282
|
+
onLongPress();
|
|
283
|
+
}}
|
|
318
284
|
className="-m-2 inline-flex size-11 shrink-0 items-center justify-center rounded-full"
|
|
319
285
|
>
|
|
320
286
|
<Avatar name={thread.fromName} email={thread.fromEmail} size="sm" />
|
|
321
|
-
</
|
|
287
|
+
</span>
|
|
322
288
|
<ComfortableRowTextContent thread={thread} />
|
|
323
289
|
</>
|
|
324
290
|
);
|
|
@@ -350,29 +316,21 @@ export function SwipeableRow({
|
|
|
350
316
|
</button>
|
|
351
317
|
)}
|
|
352
318
|
|
|
353
|
-
{/*
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
aria-checked={selectionMode ? checked : undefined}
|
|
369
|
-
{...gestureProps}
|
|
370
|
-
className={interactiveClassName}
|
|
371
|
-
style={interactiveStyle}
|
|
372
|
-
>
|
|
373
|
-
{body}
|
|
374
|
-
</button>
|
|
375
|
-
)}
|
|
319
|
+
{/* `data-message-row` is the mail app's row marker: its key dispatcher
|
|
320
|
+
reads it to tell a row apart from a control nested inside one, and
|
|
321
|
+
the e2e suite locates rows by it. */}
|
|
322
|
+
{/* 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 */}
|
|
323
|
+
<button
|
|
324
|
+
type="button"
|
|
325
|
+
role={selectionMode ? "checkbox" : undefined}
|
|
326
|
+
aria-checked={selectionMode ? checked : undefined}
|
|
327
|
+
data-message-row
|
|
328
|
+
{...gestureProps}
|
|
329
|
+
className={interactiveClassName}
|
|
330
|
+
style={interactiveStyle}
|
|
331
|
+
>
|
|
332
|
+
{body}
|
|
333
|
+
</button>
|
|
376
334
|
</div>
|
|
377
335
|
);
|
|
378
336
|
}
|