@remit/web-client 0.0.46 → 0.0.48

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.46",
3
+ "version": "0.0.48",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * MailTopBar — the app's one search surface and its global actions.
3
3
  *
4
- * Mounted above every pane by the `/mail` shell, so the field spans the nav,
5
- * the list and the reading pane. It is the app's search, not the list's: the
6
- * list header drops its own field wherever this bar is mounted, so exactly one
7
- * search input exists on the page and the "/" shortcut has one target.
4
+ * Mounted by the `/mail` shell over the list, the reading pane and the
5
+ * intelligence rail, starting on the list's left edge. It is the app's search,
6
+ * not the list's: the list header drops its own field wherever this bar is
7
+ * mounted, so exactly one search input exists on the page and the "/" shortcut
8
+ * has one target.
8
9
  *
9
10
  * The actions here are the ones that belong to the app rather than to whatever
10
11
  * is currently listed or open — compose, bug report, account. Reply, delete,
@@ -54,11 +55,6 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
54
55
 
55
56
  return (
56
57
  <AppTopBar
57
- leading={
58
- <span className="px-1 text-sm font-semibold tracking-tight text-fg">
59
- remit
60
- </span>
61
- }
62
58
  search={
63
59
  <SearchBar
64
60
  value={searchInput}
@@ -21,17 +21,12 @@ import {
21
21
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
22
22
  import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
23
23
  import {
24
- Avatar,
25
24
  type BriefCategoryFilter,
26
25
  BriefSections,
27
26
  briefFilterConfig,
28
- ComfortableRowTextContent,
29
- cn,
30
- comfortableRowClass,
31
27
  type FilterSheetProps,
32
28
  type FilterSheetSource,
33
29
  KeyboardHintBar,
34
- LIST_ROW_ATTRIBUTE,
35
30
  type SearchResult,
36
31
  type ThreadRowData,
37
32
  type ThreadSection,
@@ -56,6 +51,7 @@ import { useMailContext } from "@/lib/mail-context";
56
51
  import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
57
52
  import { parseSearchTokens } from "@/lib/search-tokens";
58
53
  import { MailListHeader } from "./MailListHeader";
54
+ import { MessageRow } from "./MessageRow";
59
55
 
60
56
  /* The brief's attribute chips as predicates (mirrors the kit `briefFilterChips`
61
57
  ids) so the phone search takeover narrows results the same way the list does. */
@@ -124,36 +120,6 @@ const ErrorBanner = ({ accountEmail }: ErrorBannerProps) => {
124
120
  );
125
121
  };
126
122
 
127
- // ---------------------------------------------------------------------------
128
- // Brief row — a navigation-aware row satisfying remit-ui's BriefRowComponent
129
- // ---------------------------------------------------------------------------
130
-
131
- const BriefRow = ({
132
- thread,
133
- active,
134
- onClick,
135
- }: {
136
- thread: ThreadRowData;
137
- active?: boolean;
138
- onClick?: () => void;
139
- }) => {
140
- const unread = !thread.isRead;
141
- return (
142
- <button
143
- type="button"
144
- {...LIST_ROW_ATTRIBUTE}
145
- onClick={onClick}
146
- className={cn("group w-full", comfortableRowClass({ active }))}
147
- >
148
- {unread && (
149
- <span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
150
- )}
151
- <Avatar name={thread.fromName} email={thread.fromEmail} size="sm" />
152
- <ComfortableRowTextContent thread={thread} />
153
- </button>
154
- );
155
- };
156
-
157
123
  // ---------------------------------------------------------------------------
158
124
  // Main component
159
125
  // ---------------------------------------------------------------------------
@@ -441,7 +407,7 @@ export function DailyBrief({
441
407
  ) : (
442
408
  <BriefSections
443
409
  sections={sections}
444
- Row={BriefRow}
410
+ Row={MessageRow}
445
411
  briefCategory={selectedCategory}
446
412
  onSelectBriefCategory={setSelectedCategory}
447
413
  sources={accountSources}
@@ -14,7 +14,6 @@
14
14
  * `listBody` so the real rows render at every width.
15
15
  */
16
16
  import {
17
- ComfortableRow,
18
17
  flaggedFilterConfig,
19
18
  LIST_ROW_SELECTOR,
20
19
  MessageListPane,
@@ -37,6 +36,7 @@ import { rowToSearchResult } from "@/lib/search-result";
37
36
  import { parseSearchTokens } from "@/lib/search-tokens";
38
37
  import { dedupeByThread } from "@/lib/starred-rows";
39
38
  import { MailViewChrome } from "./MailViewChrome";
39
+ import { MessageRow } from "./MessageRow";
40
40
 
41
41
  const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
42
42
  unread: (t) => !t.isRead,
@@ -138,7 +138,7 @@ export function FlaggedList({
138
138
  <div ref={listRef} className="flex-1 overflow-y-auto">
139
139
  <div className="divide-y divide-line">
140
140
  {rows.map((thread) => (
141
- <ComfortableRow
141
+ <MessageRow
142
142
  key={thread.id}
143
143
  thread={thread}
144
144
  active={thread.id === selectedMessageId}
@@ -1,31 +1,19 @@
1
- import { messageOperationsDescribeMessageOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
1
+ /**
2
+ * MessageListItem — the mailbox list's adapter onto the shared `MessageRow`.
3
+ *
4
+ * It maps an API thread to the row's `ThreadRowData` shape and supplies the two
5
+ * things only the mailbox list has: route-linking rows and the auto-moved
6
+ * badge. Everything visual and interactive lives in `MessageRow`, which the
7
+ * brief and Flagged render too.
8
+ */
2
9
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
3
- import {
4
- Avatar,
5
- ComfortableRowTextContent,
6
- CompactRowBody,
7
- comfortableRowClass,
8
- compactRowClass,
9
- type Density,
10
- mergeProps,
11
- type SenderTrustLevel,
12
- type ThreadRowData,
13
- useLongPress,
14
- } from "@remit/ui";
15
- import { useQueryClient } from "@tanstack/react-query";
16
- import { Link } from "@tanstack/react-router";
17
- import { Check } from "lucide-react";
18
- import { type MouseEvent, memo, useCallback } from "react";
10
+ import type { Density, SenderTrustLevel, ThreadRowData } from "@remit/ui";
11
+ import { memo } from "react";
19
12
  import type { SelectionModifiers } from "@/hooks/useSelection";
20
13
  import { toDisplayCategory } from "@/lib/display-category";
21
14
  import { formatEmailDate } from "@/lib/format";
22
- import { cn } from "@/lib/utils";
23
15
  import { AutoMovedIndicator } from "./AutoMovedIndicator";
24
-
25
- interface MailboxLinkSearch {
26
- selectedMessageId?: string;
27
- q?: string;
28
- }
16
+ import { MessageRow } from "./MessageRow";
29
17
 
30
18
  interface MessageListItemProps {
31
19
  thread: RemitImapThreadMessageResponse;
@@ -33,305 +21,94 @@ interface MessageListItemProps {
33
21
  /** Owning account, used to resolve the Inbox/Junk mailboxes for the auto-moved badge's undo action. */
34
22
  accountId?: string;
35
23
  isSelected: boolean;
36
- /**
37
- * Roving keyboard focus cursor (#429). Distinct from `isSelected` (the open
38
- * thread): a focused-but-not-open row shows the left accent rail; the open
39
- * row shows the full highlight. Both can be true (the open row stays focused).
40
- */
41
24
  isFocused?: boolean;
42
- /**
43
- * Whether this row holds the list's single tab stop (roving tabindex). Every
44
- * other row is `tabIndex={-1}`, so Tab enters the list at the cursor and
45
- * Shift+Tab leaves it rather than stepping through hundreds of rows.
46
- */
47
25
  isTabStop?: boolean;
48
- /** Called when the row takes DOM focus, so the roving cursor follows it. */
49
26
  onFocusRow?: (messageId: string) => void;
50
27
  isChecked: boolean;
51
28
  onToggleCheck: (id: string) => void;
52
- /**
53
- * Desktop mouse selection. Called from the row's onClick with the click
54
- * modifiers. Returns true when selection consumed the click (the row should
55
- * not navigate); false for a plain click (navigation proceeds).
56
- */
57
29
  onRowSelect: (messageId: string, modifiers: SelectionModifiers) => boolean;
58
30
  messageCount?: number;
59
- /** When true, the checkbox is always visible (e.g. mobile multi-select mode). */
60
31
  isMultiSelectMode?: boolean;
61
- /** Called on long press (mobile only). Receives the row's messageId. */
62
32
  onLongPress?: (messageId: string) => void;
63
- /** Whether the current viewport is desktop size. */
64
33
  isDesktop?: boolean;
65
- /** Row density — comfortable (default) or compact (mutt mode). */
66
34
  density?: Density;
67
35
  }
68
36
 
69
37
  /**
70
- * Map a RemitImapThreadMessageResponse to the ThreadRowData shape used by
71
- * remit-ui row body components.
38
+ * Map a RemitImapThreadMessageResponse to the ThreadRowData shape the shared
39
+ * row renders.
72
40
  */
73
- const toThreadRowData = (
41
+ export const threadToRowData = (
74
42
  thread: RemitImapThreadMessageResponse,
75
- messageCount: number | undefined,
76
- ): ThreadRowData => {
77
- // Use the backend's authoritative DKIM-alignment verdict rather than
78
- // re-deriving it in the view: dkimMismatch already accounts for the
79
- // multi-signature / alignment semantics a single string compare misses.
80
- const suspicious = thread.authenticity?.dkimMismatch === true;
81
-
82
- return {
83
- id: thread.messageId,
84
- accountId: thread.accountConfigId,
85
- fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
86
- fromEmail: thread.fromEmail ?? "",
87
- subject: thread.subject ?? "(No subject)",
88
- snippet: thread.snippet ?? "",
89
- timeLabel: formatEmailDate(thread.sentDate),
90
- isRead: thread.isRead,
91
- hasAttachment: thread.hasAttachment,
92
- starred: thread.hasStars === true,
93
- trust: thread.senderTrust as SenderTrustLevel,
94
- category: toDisplayCategory(thread.category),
95
- messageCount,
96
- suspicious,
97
- };
98
- };
43
+ messageCount?: number,
44
+ ): ThreadRowData => ({
45
+ id: thread.messageId,
46
+ accountId: thread.accountConfigId,
47
+ mailboxId: thread.mailboxId,
48
+ fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
49
+ fromEmail: thread.fromEmail ?? "",
50
+ subject: thread.subject ?? "(No subject)",
51
+ snippet: thread.snippet ?? "",
52
+ timeLabel: formatEmailDate(thread.sentDate),
53
+ isRead: thread.isRead,
54
+ hasAttachment: thread.hasAttachment,
55
+ starred: thread.hasStars === true,
56
+ trust: thread.senderTrust as SenderTrustLevel,
57
+ category: toDisplayCategory(thread.category),
58
+ messageCount,
59
+ // The backend's DKIM-alignment verdict is authoritative: it already accounts
60
+ // for the multi-signature / alignment semantics a single string compare
61
+ // misses.
62
+ suspicious: thread.authenticity?.dkimMismatch === true,
63
+ });
99
64
 
100
65
  const MessageListItemComponent = ({
101
66
  thread,
102
67
  mailboxId,
103
68
  accountId,
104
69
  isSelected,
105
- isFocused = false,
106
- isTabStop = false,
70
+ isFocused,
71
+ isTabStop,
107
72
  onFocusRow,
108
73
  isChecked,
109
74
  onToggleCheck,
110
75
  onRowSelect,
111
76
  messageCount,
112
- isMultiSelectMode = false,
77
+ isMultiSelectMode,
113
78
  onLongPress,
114
- isDesktop = true,
115
- density = "comfortable",
116
- }: MessageListItemProps) => {
117
- const queryClient = useQueryClient();
118
- const messageId = thread.messageId;
119
- const rowData = toThreadRowData(thread, messageCount);
120
-
121
- const handleCheckboxClick = (e: MouseEvent) => {
122
- e.preventDefault();
123
- e.stopPropagation();
124
- onToggleCheck(thread.messageId);
125
- };
126
-
127
- // Desktop mouse selection semantics. Plain click falls through to the Link's
128
- // navigation; shift / cmd / ctrl click is routed to selection and the
129
- // navigation is suppressed.
130
- //
131
- // A modified click must preventDefault: the router skips navigation for any
132
- // modified click and leaves the anchor's own default in place, which in a
133
- // browser means shift-click opens a new window and cmd-click a new tab.
134
- // Shift-click also drags a native text selection across the rows it spans,
135
- // so drop it — the row highlight is the selection the user asked for.
136
- //
137
- // On mobile, once selection mode is active (#92) a plain tap toggles the
138
- // row instead of opening it — the same tap-to-toggle contract every
139
- // reference mail client uses once you're mid-selection. Outside selection
140
- // mode a short tap still falls through to the Link's native navigation.
141
- const handleRowClick = useCallback(
142
- (e: MouseEvent) => {
143
- if (!isDesktop) {
144
- if (!isMultiSelectMode) return;
145
- e.preventDefault();
146
- e.stopPropagation();
147
- onToggleCheck(messageId);
148
- return;
149
- }
150
- const modifiers = {
151
- shiftKey: e.shiftKey,
152
- metaKey: e.metaKey,
153
- ctrlKey: e.ctrlKey,
154
- };
155
- const handled = onRowSelect(messageId, modifiers);
156
- if (!handled) return;
157
- e.preventDefault();
158
- e.stopPropagation();
159
- if (modifiers.shiftKey) {
160
- window.getSelection()?.removeAllRanges();
161
- }
162
- },
163
- [isDesktop, isMultiSelectMode, onToggleCheck, onRowSelect, messageId],
164
- );
165
-
166
- // Shift-click starts a native text selection on mousedown; suppressing it
167
- // there keeps the drag from painting a text range over the rows.
168
- const handleRowMouseDown = useCallback(
169
- (e: MouseEvent) => {
170
- if (!isDesktop) return;
171
- if (e.shiftKey) e.preventDefault();
172
- },
173
- [isDesktop],
174
- );
175
-
176
- const handleLongPress = useCallback(() => {
177
- onLongPress?.(messageId);
178
- }, [onLongPress, messageId]);
179
-
180
- const { longPressProps } = useLongPress({
181
- onLongPress: handleLongPress,
182
- delayMs: 500,
183
- accessibilityDescription: isChecked ? "Deselect message" : "Select message",
184
- });
185
-
186
- // Intent-based prefetch: by the time the user clicks, the body is in
187
- // React Query's cache and the detail pane renders without a spinner.
188
- const prefetchMessage = useCallback(() => {
189
- queryClient.prefetchQuery(
190
- messageOperationsDescribeMessageOptions({
191
- path: { messageId: thread.messageId },
192
- }),
193
- );
194
- }, [queryClient, thread.messageId]);
195
-
196
- const handleRowFocus = useCallback(() => {
197
- prefetchMessage();
198
- onFocusRow?.(messageId);
199
- }, [prefetchMessage, onFocusRow, messageId]);
200
-
201
- // Listbox semantics + roving tabindex, shared by both densities. Merged
202
- // (not spread) with the mobile long-press props: react-aria's pressProps
203
- // carries its own onClick for its internal press bookkeeping, and a plain
204
- // object spread would silently drop whichever onClick landed second
205
- // instead of running both.
206
- const rowInteractionProps = mergeProps(
207
- {
208
- "data-message-row": true,
209
- "data-message-id": messageId,
210
- role: "option" as const,
211
- "aria-selected": isChecked,
212
- tabIndex: isTabStop ? 0 : -1,
213
- onClick: handleRowClick,
214
- onMouseDown: handleRowMouseDown,
215
- onMouseEnter: prefetchMessage,
216
- onFocus: handleRowFocus,
217
- },
218
- isDesktop ? {} : longPressProps,
219
- );
220
-
221
- const unread = !thread.isRead;
222
-
223
- if (density === "compact") {
224
- return (
225
- <Link
226
- to="/mail/$mailboxId"
227
- params={{ mailboxId }}
228
- search={(prev: MailboxLinkSearch) => ({
229
- ...prev,
230
- selectedMessageId: thread.messageId,
231
- })}
232
- {...rowInteractionProps}
233
- className={cn(
234
- compactRowClass({ active: isSelected, focused: isFocused }),
235
- "outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset",
236
- isChecked && "bg-accent-soft",
237
- // Long-press enters selection mode; without these, Android Chrome
238
- // opens the link context menu / starts text selection and iOS
239
- // Safari fires the callout, racing the app's handler. react-aria
240
- // suppresses contextmenu/text-selection but not iOS's callout —
241
- // it fires no cancelable event, so CSS is the only lever.
242
- !isDesktop && "min-h-11 select-none [-webkit-touch-callout:none]",
243
- )}
244
- >
245
- <CompactRowBody thread={rowData} />
246
- </Link>
247
- );
248
- }
249
-
250
- // Comfortable density: avatar/checkbox leading slot + text content.
251
- // The slot is a fixed 36px so the row never reflows when state changes.
252
- // Unread dot is positioned absolute (left-1.5, vertically centered).
253
- return (
254
- <Link
255
- to="/mail/$mailboxId"
256
- params={{ mailboxId }}
257
- search={(prev: MailboxLinkSearch) => ({
258
- ...prev,
259
- selectedMessageId: thread.messageId,
260
- })}
261
- {...rowInteractionProps}
262
- className={cn(
263
- "group",
264
- comfortableRowClass({ active: isSelected, focused: isFocused }),
265
- "outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset",
266
- isChecked && "bg-accent-soft",
267
- // See the compact-density Link above for why both are required.
268
- !isDesktop && "min-h-11 select-none [-webkit-touch-callout:none]",
269
- )}
270
- >
271
- {/* Absolute unread dot — 6px gutter from the left pane hairline */}
272
- {unread && (
273
- <span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
274
- )}
275
-
276
- {/* Leading slot: avatar by default, checkbox on hover (desktop) or
277
- while checked / in multi-select mode. Fixed 28px slot (size-7,
278
- matching Avatar size="sm" in remit-ui ComfortableRow). */}
279
- <div className="relative size-7 shrink-0">
280
- <Avatar
281
- name={thread.fromName ?? thread.fromEmail ?? "?"}
282
- email={thread.fromEmail ?? undefined}
79
+ isDesktop,
80
+ density,
81
+ }: MessageListItemProps) => (
82
+ <MessageRow
83
+ thread={threadToRowData(thread, messageCount)}
84
+ linkMailboxId={mailboxId}
85
+ inListbox
86
+ active={isSelected}
87
+ focused={isFocused}
88
+ isTabStop={isTabStop}
89
+ density={density}
90
+ isDesktop={isDesktop}
91
+ onFocusRow={onFocusRow}
92
+ selection={{
93
+ isChecked,
94
+ onToggleCheck,
95
+ onRowSelect,
96
+ isMultiSelectMode,
97
+ onLongPress,
98
+ }}
99
+ badge={
100
+ thread.autoMoved ? (
101
+ <AutoMovedIndicator
102
+ accountId={accountId}
103
+ messageId={thread.messageId}
104
+ threadId={thread.threadId}
105
+ mailboxId={thread.mailboxId}
106
+ autoMoved={thread.autoMoved}
283
107
  size="sm"
284
- className={cn(
285
- "absolute inset-0",
286
- "sm:group-hover:opacity-0 transition-opacity",
287
- (isChecked || isMultiSelectMode) && "opacity-0",
288
- )}
289
108
  />
290
- <button
291
- type="button"
292
- // Out of the tab order: the row is the list's single tab stop, and
293
- // this control is `opacity-0` until hover, so a tabbable one would
294
- // put focus on something invisible on every row.
295
- tabIndex={-1}
296
- onClick={handleCheckboxClick}
297
- className={cn(
298
- "absolute inset-0 size-7 rounded-full border items-center justify-center transition-opacity",
299
- isMultiSelectMode ? "flex" : "hidden sm:flex",
300
- isChecked
301
- ? "bg-accent border-accent text-accent-fg opacity-100"
302
- : isMultiSelectMode
303
- ? "border-fg-subtle/40 opacity-100 bg-canvas"
304
- : "border-fg-subtle/40 opacity-0 group-hover:opacity-100 bg-canvas",
305
- )}
306
- aria-label={isChecked ? "Deselect message" : "Select message"}
307
- >
308
- {isChecked && <Check className="size-3" />}
309
- </button>
310
- </div>
311
-
312
- {/* Text/glyph content block */}
313
- <ComfortableRowTextContent
314
- thread={rowData}
315
- badge={
316
- thread.autoMoved && (
317
- <AutoMovedIndicator
318
- accountId={accountId}
319
- messageId={thread.messageId}
320
- threadId={thread.threadId}
321
- mailboxId={thread.mailboxId}
322
- autoMoved={thread.autoMoved}
323
- size="sm"
324
- />
325
- )
326
- }
327
- />
328
- </Link>
329
- );
330
- };
109
+ ) : undefined
110
+ }
111
+ />
112
+ );
331
113
 
332
- // Wrapped in React.memo so virtualized rows don't re-render on every parent
333
- // state change. The `search` callback prop on Link is inline, but it gets a
334
- // stable reference because the parent itself is stable across re-renders
335
- // (mailboxId is a string from route params). React.memo with default shallow
336
- // equality is appropriate here since props are primitives + stable callback.
337
114
  export const MessageListItem = memo(MessageListItemComponent);
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Row semantics for the shared `MessageRow` (#149).
3
+ *
4
+ * `role="option"` belongs to the mailbox list, whose rows sit inside a
5
+ * `role="listbox"` container. The brief and Flagged render rows in ordinary
6
+ * containers: an orphan `option` there is invalid ARIA and, more concretely,
7
+ * strips the row of its button role — which is how the black-box suite and any
8
+ * screen reader address it.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import type { ThreadRowData } from "@remit/ui";
14
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
15
+ import React, { createElement } from "react";
16
+ import { renderToString } from "react-dom/server";
17
+ import { MessageRow } from "./MessageRow";
18
+
19
+ (globalThis as { React?: typeof React }).React = React;
20
+
21
+ const thread: ThreadRowData = {
22
+ id: "m1",
23
+ accountId: "a1",
24
+ fromName: "Alex Rivera",
25
+ fromEmail: "alex@example.com",
26
+ subject: "Quarterly numbers are in",
27
+ snippet: "The deck is on the shared drive.",
28
+ timeLabel: "9:42",
29
+ isRead: true,
30
+ };
31
+
32
+ const render = (props: Parameters<typeof MessageRow>[0]): string =>
33
+ renderToString(
34
+ createElement(
35
+ QueryClientProvider,
36
+ { client: new QueryClient() },
37
+ createElement(MessageRow, props),
38
+ ) as never,
39
+ );
40
+
41
+ describe("MessageRow semantics", () => {
42
+ it("renders a plain button outside a listbox (brief, Flagged)", () => {
43
+ const html = render({ thread });
44
+ assert.match(html, /<button/);
45
+ assert.doesNotMatch(html, /role="option"/);
46
+ assert.doesNotMatch(html, /aria-selected/);
47
+ });
48
+
49
+ it("renders listbox option semantics when the container is a listbox", () => {
50
+ const html = render({ thread, inListbox: true });
51
+ assert.match(html, /role="option"/);
52
+ assert.match(html, /aria-selected/);
53
+ });
54
+
55
+ it("renders the row's sender, subject and snippet", () => {
56
+ const html = render({ thread });
57
+ assert.match(html, /Alex Rivera/);
58
+ assert.match(html, /Quarterly numbers are in/);
59
+ assert.match(html, /shared drive/);
60
+ });
61
+ });
@@ -0,0 +1,279 @@
1
+ /**
2
+ * MessageRow — the one thread row every list in the app renders.
3
+ *
4
+ * The mailbox list, the daily brief and Flagged used to carry three
5
+ * near-identical rows (#149). They differ in two axes only, both props here:
6
+ *
7
+ * - `linkMailboxId` — set for the mailbox list, whose rows are real links so a
8
+ * plain click routes and a middle click opens a tab. Omitted for the brief
9
+ * and Flagged, whose rows report the tap through `onClick`.
10
+ * - `selection` — set where multi-select is available. Absent renders the
11
+ * avatar alone with no checkbox, which is what "non-selectable mode" means.
12
+ */
13
+ import { messageOperationsDescribeMessageOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
14
+ import {
15
+ ComfortableRowBody,
16
+ CompactRowBody,
17
+ comfortableRowClass,
18
+ compactRowClass,
19
+ type Density,
20
+ mergeProps,
21
+ type ThreadRowData,
22
+ useLongPress,
23
+ } from "@remit/ui";
24
+ import { useQueryClient } from "@tanstack/react-query";
25
+ import { Link } from "@tanstack/react-router";
26
+ import { type MouseEvent, memo, type ReactNode, useCallback } from "react";
27
+ import type { SelectionModifiers } from "@/hooks/useSelection";
28
+ import { cn } from "@/lib/utils";
29
+
30
+ interface MailboxLinkSearch {
31
+ selectedMessageId?: string;
32
+ q?: string;
33
+ }
34
+
35
+ export interface MessageRowSelection {
36
+ isChecked: boolean;
37
+ onToggleCheck: (messageId: string) => void;
38
+ /**
39
+ * Desktop mouse selection. Returns true when selection consumed the click
40
+ * (the row must not open); false for a plain click.
41
+ */
42
+ onRowSelect: (messageId: string, modifiers: SelectionModifiers) => boolean;
43
+ /** Checkbox stays visible — mobile multi-select mode. */
44
+ isMultiSelectMode?: boolean;
45
+ /** Long press enters selection mode (mobile only). */
46
+ onLongPress?: (messageId: string) => void;
47
+ }
48
+
49
+ export interface MessageRowProps {
50
+ thread: ThreadRowData;
51
+ /** The row is the open thread. */
52
+ active?: boolean;
53
+ /**
54
+ * Roving keyboard focus cursor (#429). Distinct from `active`: a
55
+ * focused-but-not-open row shows the left accent rail; the open row shows the
56
+ * full highlight. Both can be true.
57
+ */
58
+ focused?: boolean;
59
+ /**
60
+ * Whether this row holds the list's single tab stop (roving tabindex). Every
61
+ * other row is `tabIndex={-1}`, so Tab enters the list at the cursor and
62
+ * Shift+Tab leaves it rather than stepping through hundreds of rows.
63
+ */
64
+ isTabStop?: boolean;
65
+ density?: Density;
66
+ isDesktop?: boolean;
67
+ /** Extra chip after the category badge (e.g. the auto-moved indicator). */
68
+ badge?: ReactNode;
69
+ selection?: MessageRowSelection;
70
+ /**
71
+ * The row sits inside a container with `role="listbox"` (the mailbox list),
72
+ * so it carries `role="option"` and `aria-selected`. Off everywhere else:
73
+ * the brief and Flagged render their rows in ordinary containers, where an
74
+ * orphan `option` is invalid ARIA and costs the row its button semantics.
75
+ */
76
+ inListbox?: boolean;
77
+ /** Mailbox whose route the row links to; omit for callback-driven rows. */
78
+ linkMailboxId?: string;
79
+ /** Called when the row is opened by a plain click on a non-linking row. */
80
+ onClick?: () => void;
81
+ /** Called when the row takes DOM focus, so the roving cursor follows it. */
82
+ onFocusRow?: (messageId: string) => void;
83
+ }
84
+
85
+ const MessageRowComponent = ({
86
+ thread,
87
+ active = false,
88
+ focused = false,
89
+ isTabStop = false,
90
+ density = "comfortable",
91
+ isDesktop = true,
92
+ badge,
93
+ selection,
94
+ inListbox = false,
95
+ linkMailboxId,
96
+ onClick,
97
+ onFocusRow,
98
+ }: MessageRowProps) => {
99
+ const queryClient = useQueryClient();
100
+ const messageId = thread.id;
101
+ const isChecked = selection?.isChecked ?? false;
102
+ const isMultiSelectMode = selection?.isMultiSelectMode ?? false;
103
+ const onToggleCheck = selection?.onToggleCheck;
104
+ const onRowSelect = selection?.onRowSelect;
105
+ const onLongPress = selection?.onLongPress;
106
+
107
+ const handleCheckboxClick = useCallback(
108
+ (e: MouseEvent) => {
109
+ e.preventDefault();
110
+ e.stopPropagation();
111
+ onToggleCheck?.(messageId);
112
+ },
113
+ [onToggleCheck, messageId],
114
+ );
115
+
116
+ // Desktop mouse selection semantics. Plain click falls through to the Link's
117
+ // navigation (or `onClick` on a non-linking row); shift / cmd / ctrl click is
118
+ // routed to selection and the navigation is suppressed.
119
+ //
120
+ // A modified click must preventDefault: the router skips navigation for any
121
+ // modified click and leaves the anchor's own default in place, which in a
122
+ // browser means shift-click opens a new window and cmd-click a new tab.
123
+ // Shift-click also drags a native text selection across the rows it spans,
124
+ // so drop it — the row highlight is the selection the user asked for.
125
+ //
126
+ // On mobile, once selection mode is active (#92) a plain tap toggles the row
127
+ // instead of opening it — the same tap-to-toggle contract every reference
128
+ // mail client uses once you're mid-selection.
129
+ const handleRowClick = useCallback(
130
+ (e: MouseEvent) => {
131
+ if (!isDesktop) {
132
+ if (isMultiSelectMode) {
133
+ e.preventDefault();
134
+ e.stopPropagation();
135
+ onToggleCheck?.(messageId);
136
+ return;
137
+ }
138
+ onClick?.();
139
+ return;
140
+ }
141
+ const modifiers = {
142
+ shiftKey: e.shiftKey,
143
+ metaKey: e.metaKey,
144
+ ctrlKey: e.ctrlKey,
145
+ };
146
+ if (onRowSelect?.(messageId, modifiers)) {
147
+ e.preventDefault();
148
+ e.stopPropagation();
149
+ if (modifiers.shiftKey) window.getSelection()?.removeAllRanges();
150
+ return;
151
+ }
152
+ onClick?.();
153
+ },
154
+ [
155
+ isDesktop,
156
+ isMultiSelectMode,
157
+ onToggleCheck,
158
+ onRowSelect,
159
+ onClick,
160
+ messageId,
161
+ ],
162
+ );
163
+
164
+ // Shift-click starts a native text selection on mousedown; suppressing it
165
+ // there keeps the drag from painting a text range over the rows.
166
+ const handleRowMouseDown = useCallback(
167
+ (e: MouseEvent) => {
168
+ if (!isDesktop) return;
169
+ if (e.shiftKey) e.preventDefault();
170
+ },
171
+ [isDesktop],
172
+ );
173
+
174
+ const handleLongPress = useCallback(() => {
175
+ onLongPress?.(messageId);
176
+ }, [onLongPress, messageId]);
177
+
178
+ const { longPressProps } = useLongPress({
179
+ onLongPress: handleLongPress,
180
+ delayMs: 500,
181
+ accessibilityDescription: isChecked ? "Deselect message" : "Select message",
182
+ });
183
+
184
+ // Intent-based prefetch: by the time the user clicks, the body is in
185
+ // React Query's cache and the detail pane renders without a spinner.
186
+ const prefetchMessage = useCallback(() => {
187
+ queryClient.prefetchQuery(
188
+ messageOperationsDescribeMessageOptions({ path: { messageId } }),
189
+ );
190
+ }, [queryClient, messageId]);
191
+
192
+ const handleRowFocus = useCallback(() => {
193
+ prefetchMessage();
194
+ onFocusRow?.(messageId);
195
+ }, [prefetchMessage, onFocusRow, messageId]);
196
+
197
+ // Listbox semantics + roving tabindex, shared by both densities. Merged (not
198
+ // spread) with the mobile long-press props: react-aria's pressProps carries
199
+ // its own onClick for its internal press bookkeeping, and a plain object
200
+ // spread would silently drop whichever onClick landed second instead of
201
+ // running both.
202
+ const interactionProps = mergeProps(
203
+ {
204
+ "data-list-row": "",
205
+ "data-message-row": true,
206
+ "data-message-id": messageId,
207
+ ...(inListbox
208
+ ? { role: "option" as const, "aria-selected": isChecked }
209
+ : {}),
210
+ tabIndex: isTabStop ? 0 : -1,
211
+ onClick: handleRowClick,
212
+ onMouseDown: handleRowMouseDown,
213
+ onMouseEnter: prefetchMessage,
214
+ onFocus: handleRowFocus,
215
+ },
216
+ isDesktop || !selection ? {} : longPressProps,
217
+ );
218
+
219
+ const className = cn(
220
+ density === "compact"
221
+ ? compactRowClass({ active, focused })
222
+ : cn("group", comfortableRowClass({ active, focused })),
223
+ "outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset",
224
+ isChecked && "bg-accent-soft",
225
+ // Long-press enters selection mode; without these, Android Chrome opens
226
+ // the link context menu / starts text selection and iOS Safari fires the
227
+ // callout, racing the app's handler. react-aria suppresses
228
+ // contextmenu/text-selection but not iOS's callout — it fires no
229
+ // cancelable event, so CSS is the only lever.
230
+ !isDesktop && "min-h-11 select-none [-webkit-touch-callout:none]",
231
+ );
232
+
233
+ const body =
234
+ density === "compact" ? (
235
+ <CompactRowBody thread={thread} />
236
+ ) : (
237
+ <ComfortableRowBody
238
+ thread={thread}
239
+ badge={badge}
240
+ selection={
241
+ selection
242
+ ? {
243
+ checked: isChecked,
244
+ alwaysVisible: isMultiSelectMode,
245
+ onToggle: handleCheckboxClick,
246
+ }
247
+ : undefined
248
+ }
249
+ />
250
+ );
251
+
252
+ if (linkMailboxId === undefined) {
253
+ return (
254
+ <button type="button" {...interactionProps} className={className}>
255
+ {body}
256
+ </button>
257
+ );
258
+ }
259
+
260
+ return (
261
+ <Link
262
+ to="/mail/$mailboxId"
263
+ params={{ mailboxId: linkMailboxId }}
264
+ search={(prev: MailboxLinkSearch) => ({
265
+ ...prev,
266
+ selectedMessageId: messageId,
267
+ })}
268
+ {...interactionProps}
269
+ className={className}
270
+ >
271
+ {body}
272
+ </Link>
273
+ );
274
+ };
275
+
276
+ // Wrapped in React.memo so virtualized rows don't re-render on every parent
277
+ // state change. Props are primitives plus callbacks the parents keep stable, so
278
+ // default shallow equality is appropriate.
279
+ export const MessageRow = memo(MessageRowComponent);