@remit/ui 0.0.1

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.
Files changed (188) hide show
  1. package/package.json +50 -0
  2. package/src/components/address-display.render.test.ts +93 -0
  3. package/src/components/address-display.stories.tsx +55 -0
  4. package/src/components/address-display.tsx +132 -0
  5. package/src/components/address-tag.render.test.ts +39 -0
  6. package/src/components/address-tag.stories.tsx +41 -0
  7. package/src/components/address-tag.tsx +38 -0
  8. package/src/components/app-password-hint.render.test.ts +23 -0
  9. package/src/components/app-password-hint.stories.tsx +19 -0
  10. package/src/components/app-password-hint.tsx +24 -0
  11. package/src/components/app-shell-slotted.render.test.ts +128 -0
  12. package/src/components/app-shell-slotted.tsx +267 -0
  13. package/src/components/app-shell-types.ts +328 -0
  14. package/src/components/app-shell.render.test.ts +415 -0
  15. package/src/components/app-shell.tsx +277 -0
  16. package/src/components/auth-card.render.test.ts +24 -0
  17. package/src/components/auth-card.stories.tsx +50 -0
  18. package/src/components/auth-card.tsx +32 -0
  19. package/src/components/auth-footer.render.test.ts +17 -0
  20. package/src/components/auth-footer.stories.tsx +23 -0
  21. package/src/components/auth-footer.tsx +14 -0
  22. package/src/components/auth-hero.render.test.ts +31 -0
  23. package/src/components/auth-hero.stories.tsx +19 -0
  24. package/src/components/auth-hero.tsx +41 -0
  25. package/src/components/auto-moved-badge.render.test.ts +42 -0
  26. package/src/components/auto-moved-badge.stories.tsx +48 -0
  27. package/src/components/auto-moved-badge.tsx +57 -0
  28. package/src/components/avatar.tsx +60 -0
  29. package/src/components/badge.tsx +49 -0
  30. package/src/components/banner.render.test.ts +58 -0
  31. package/src/components/banner.stories.tsx +92 -0
  32. package/src/components/banner.tsx +81 -0
  33. package/src/components/bottom-sheet.render.test.ts +48 -0
  34. package/src/components/bottom-sheet.stories.tsx +71 -0
  35. package/src/components/bottom-sheet.tsx +146 -0
  36. package/src/components/brief-section.render.test.ts +98 -0
  37. package/src/components/brief-section.stories.tsx +68 -0
  38. package/src/components/brief-section.tsx +102 -0
  39. package/src/components/brief-sections.render.test.ts +72 -0
  40. package/src/components/brief-sections.stories.tsx +178 -0
  41. package/src/components/brief-sections.tsx +201 -0
  42. package/src/components/button.tsx +48 -0
  43. package/src/components/card.tsx +48 -0
  44. package/src/components/category-badge.render.test.ts +40 -0
  45. package/src/components/category-badge.stories.tsx +37 -0
  46. package/src/components/category-badge.tsx +71 -0
  47. package/src/components/checkbox.render.test.ts +65 -0
  48. package/src/components/checkbox.stories.tsx +77 -0
  49. package/src/components/checkbox.tsx +78 -0
  50. package/src/components/compose-action-bar.render.test.ts +47 -0
  51. package/src/components/compose-action-bar.stories.tsx +35 -0
  52. package/src/components/compose-action-bar.tsx +95 -0
  53. package/src/components/compose-form-shell.render.test.ts +54 -0
  54. package/src/components/compose-form-shell.stories.tsx +96 -0
  55. package/src/components/compose-form-shell.tsx +49 -0
  56. package/src/components/danger-zone-section.tsx +36 -0
  57. package/src/components/dialog.render.test.ts +55 -0
  58. package/src/components/dialog.tsx +104 -0
  59. package/src/components/email-frame-css.ts +137 -0
  60. package/src/components/field-label.render.test.ts +16 -0
  61. package/src/components/field-label.tsx +19 -0
  62. package/src/components/filter-sheet.render.test.ts +76 -0
  63. package/src/components/filter-sheet.stories.tsx +146 -0
  64. package/src/components/filter-sheet.tsx +316 -0
  65. package/src/components/folder-role.tsx +66 -0
  66. package/src/components/input.tsx +44 -0
  67. package/src/components/intelligence-panel.stories.tsx +149 -0
  68. package/src/components/intelligence-panel.tsx +546 -0
  69. package/src/components/isolated-email-frame.render.test.ts +147 -0
  70. package/src/components/isolated-email-frame.stories.tsx +192 -0
  71. package/src/components/isolated-email-frame.tsx +265 -0
  72. package/src/components/kbd.tsx +17 -0
  73. package/src/components/keyboard-hint-bar.tsx +58 -0
  74. package/src/components/list-item.tsx +39 -0
  75. package/src/components/mail-action-toolbar.render.test.ts +43 -0
  76. package/src/components/mail-action-toolbar.tsx +180 -0
  77. package/src/components/mail-header.render.test.ts +69 -0
  78. package/src/components/mail-header.stories.tsx +233 -0
  79. package/src/components/mail-header.tsx +123 -0
  80. package/src/components/message-body-view.render.test.ts +59 -0
  81. package/src/components/message-body-view.stories.tsx +146 -0
  82. package/src/components/message-body-view.tsx +150 -0
  83. package/src/components/message-header.render.test.ts +58 -0
  84. package/src/components/message-header.stories.tsx +79 -0
  85. package/src/components/message-header.tsx +59 -0
  86. package/src/components/message-list-pane.render.test.ts +136 -0
  87. package/src/components/message-list-pane.stories.tsx +246 -0
  88. package/src/components/message-list-pane.tsx +248 -0
  89. package/src/components/message-list-state.render.test.ts +72 -0
  90. package/src/components/message-list-state.tsx +97 -0
  91. package/src/components/message-row.render.test.ts +48 -0
  92. package/src/components/message-row.stories.tsx +121 -0
  93. package/src/components/message-row.tsx +231 -0
  94. package/src/components/mobile-message-action-bar.render.test.ts +87 -0
  95. package/src/components/mobile-message-action-bar.stories.tsx +57 -0
  96. package/src/components/mobile-message-action-bar.tsx +175 -0
  97. package/src/components/mobile-reading-pane.render.test.ts +102 -0
  98. package/src/components/mobile-reading-pane.tsx +188 -0
  99. package/src/components/mobile-search-view.stories.tsx +247 -0
  100. package/src/components/mobile-search-view.tsx +100 -0
  101. package/src/components/move-mailbox-picker.render.test.ts +113 -0
  102. package/src/components/move-mailbox-picker.stories.tsx +76 -0
  103. package/src/components/move-mailbox-picker.tsx +324 -0
  104. package/src/components/nav-sidebar.render.test.ts +351 -0
  105. package/src/components/nav-sidebar.stories.tsx +216 -0
  106. package/src/components/nav-sidebar.tsx +625 -0
  107. package/src/components/outbox-row.render.test.ts +61 -0
  108. package/src/components/outbox-row.stories.tsx +53 -0
  109. package/src/components/outbox-row.tsx +120 -0
  110. package/src/components/outbox-status-badge.tsx +60 -0
  111. package/src/components/pane-layout.render.test.ts +105 -0
  112. package/src/components/popover-menu.render.test.ts +32 -0
  113. package/src/components/popover-menu.stories.tsx +57 -0
  114. package/src/components/popover-menu.tsx +110 -0
  115. package/src/components/primitives.stories.tsx +173 -0
  116. package/src/components/pull-to-refresh.render.test.ts +46 -0
  117. package/src/components/pull-to-refresh.stories.tsx +71 -0
  118. package/src/components/pull-to-refresh.tsx +54 -0
  119. package/src/components/quoted-text.render.test.ts +39 -0
  120. package/src/components/quoted-text.stories.tsx +67 -0
  121. package/src/components/quoted-text.tsx +67 -0
  122. package/src/components/reading-pane-empty.tsx +44 -0
  123. package/src/components/reading-pane.render.test.ts +148 -0
  124. package/src/components/reading-pane.stories.tsx +194 -0
  125. package/src/components/reading-pane.tsx +351 -0
  126. package/src/components/rescue-banner.render.test.ts +32 -0
  127. package/src/components/rescue-banner.stories.tsx +31 -0
  128. package/src/components/rescue-banner.tsx +50 -0
  129. package/src/components/rescue-candidate-row.render.test.ts +68 -0
  130. package/src/components/rescue-candidate-row.stories.tsx +108 -0
  131. package/src/components/rescue-candidate-row.tsx +94 -0
  132. package/src/components/rescue-from-spam-flow.render.test.ts +100 -0
  133. package/src/components/rescue-from-spam-flow.stories.tsx +92 -0
  134. package/src/components/rescue-from-spam-flow.tsx +342 -0
  135. package/src/components/resizable.tsx +55 -0
  136. package/src/components/role-appointment-list.render.test.ts +97 -0
  137. package/src/components/role-appointment-list.stories.tsx +138 -0
  138. package/src/components/role-appointment-list.tsx +227 -0
  139. package/src/components/row-actions.render.test.ts +83 -0
  140. package/src/components/row-actions.stories.tsx +87 -0
  141. package/src/components/row-actions.tsx +139 -0
  142. package/src/components/search-bar.render.test.ts +55 -0
  143. package/src/components/search-bar.stories.tsx +46 -0
  144. package/src/components/search-bar.tsx +118 -0
  145. package/src/components/search-result-row.tsx +139 -0
  146. package/src/components/search-results.render.test.ts +89 -0
  147. package/src/components/search-results.stories.tsx +126 -0
  148. package/src/components/search-results.tsx +215 -0
  149. package/src/components/search-token-chip.render.test.ts +42 -0
  150. package/src/components/search-token-chip.stories.tsx +42 -0
  151. package/src/components/search-token-chip.tsx +66 -0
  152. package/src/components/security-select.render.test.ts +33 -0
  153. package/src/components/security-select.tsx +53 -0
  154. package/src/components/segmented-control.tsx +70 -0
  155. package/src/components/select.tsx +33 -0
  156. package/src/components/selection-top-bar.render.test.ts +67 -0
  157. package/src/components/selection-top-bar.stories.tsx +39 -0
  158. package/src/components/selection-top-bar.tsx +95 -0
  159. package/src/components/sender-group-switch.tsx +63 -0
  160. package/src/components/sender-trust-indicator.render.test.ts +67 -0
  161. package/src/components/sender-trust-indicator.stories.tsx +58 -0
  162. package/src/components/sender-trust-indicator.tsx +87 -0
  163. package/src/components/settings-primitives.render.test.ts +160 -0
  164. package/src/components/settings-primitives.stories.tsx +80 -0
  165. package/src/components/settings-screen.tsx +354 -0
  166. package/src/components/swipeable-row.render.test.ts +106 -0
  167. package/src/components/swipeable-row.stories.tsx +117 -0
  168. package/src/components/swipeable-row.tsx +268 -0
  169. package/src/components/touch-list-body.render.test.ts +66 -0
  170. package/src/components/touch-list-body.stories.tsx +76 -0
  171. package/src/components/touch-list.tsx +87 -0
  172. package/src/components/wizard.render.test.ts +49 -0
  173. package/src/components/wizard.stories.tsx +113 -0
  174. package/src/components/wizard.tsx +321 -0
  175. package/src/filter-presets.test.ts +93 -0
  176. package/src/filter-presets.ts +101 -0
  177. package/src/index.ts +378 -0
  178. package/src/lib/cid-resolver.ts +35 -0
  179. package/src/lib/cn.ts +7 -0
  180. package/src/lib/email-layout-clamp.test.ts +109 -0
  181. package/src/lib/email-layout-clamp.ts +64 -0
  182. package/src/lib/email-render-treatment.test.ts +82 -0
  183. package/src/lib/email-render-treatment.ts +53 -0
  184. package/src/lib/email-sanitizer.test.ts +362 -0
  185. package/src/lib/email-sanitizer.ts +298 -0
  186. package/src/lib/row-keyboard.test.ts +41 -0
  187. package/src/lib/row-keyboard.ts +20 -0
  188. package/src/tokens.css +217 -0
@@ -0,0 +1,246 @@
1
+ import type { Decorator, Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { inboxFilterConfig } from "../filter-presets.js";
4
+ import type { ThreadSection } from "./app-shell-types.js";
5
+ import { FilterSheet } from "./filter-sheet.js";
6
+ import { MailHeader } from "./mail-header.js";
7
+ import { MessageListPane } from "./message-list-pane.js";
8
+ import { SelectionTopBar } from "./selection-top-bar.js";
9
+
10
+ const sections: ThreadSection[] = [
11
+ {
12
+ id: "today",
13
+ label: "Today",
14
+ threads: [
15
+ {
16
+ id: "t1",
17
+ accountId: "a1",
18
+ fromName: "Alex Rivera",
19
+ fromEmail: "alex@example.com",
20
+ subject: "Q3 planning notes",
21
+ snippet: "Here are the notes from today's planning session.",
22
+ timeLabel: "9:42",
23
+ category: "personal",
24
+ },
25
+ {
26
+ id: "t2",
27
+ accountId: "a1",
28
+ fromName: "Acme Billing",
29
+ fromEmail: "billing@acme.com",
30
+ subject: "Your invoice is ready",
31
+ snippet: "Invoice #1042 is available to view.",
32
+ timeLabel: "8:15",
33
+ isRead: true,
34
+ category: "transactional",
35
+ },
36
+ ],
37
+ },
38
+ {
39
+ id: "earlier",
40
+ label: "Earlier",
41
+ threads: [
42
+ {
43
+ id: "t3",
44
+ accountId: "a1",
45
+ fromName: "Weekly Digest",
46
+ fromEmail: "news@digest.com",
47
+ subject: "This week in tech",
48
+ snippet: "The top stories you might have missed.",
49
+ timeLabel: "Mon",
50
+ category: "newsletter",
51
+ messageCount: 3,
52
+ },
53
+ ],
54
+ },
55
+ ];
56
+
57
+ const meta: Meta<typeof MessageListPane> = {
58
+ title: "Screens/Kit/MessageListPane",
59
+ component: MessageListPane,
60
+ parameters: { layout: "centered" },
61
+ args: {
62
+ listTitle: "Inbox",
63
+ listMeta: "3 conversations",
64
+ sections,
65
+ onSelectThread: () => undefined,
66
+ onSelectBriefCategory: () => undefined,
67
+ },
68
+ };
69
+ export default meta;
70
+
71
+ type Story = StoryObj<typeof MessageListPane>;
72
+
73
+ const desktopFrame: Decorator = (Story) => (
74
+ <div className="h-screen w-96 overflow-hidden border border-line">
75
+ <Story />
76
+ </div>
77
+ );
78
+
79
+ const narrowFrame: Decorator = (Story) => (
80
+ <div
81
+ className="overflow-hidden border border-line"
82
+ style={{ width: 390, height: 844 }}
83
+ >
84
+ <Story />
85
+ </div>
86
+ );
87
+
88
+ export const DesktopList: Story = {
89
+ args: { isDesktop: true, flatList: true },
90
+ decorators: [desktopFrame],
91
+ };
92
+
93
+ export const NarrowTouchList: Story = {
94
+ args: { isDesktop: false, flatList: true },
95
+ decorators: [narrowFrame],
96
+ };
97
+
98
+ export const Brief: Story = {
99
+ args: { isDesktop: true, briefFilters: true, sections },
100
+ decorators: [desktopFrame],
101
+ };
102
+
103
+ /**
104
+ * Inbox behind its filter: the MailHeader top row, then the FilterSheet bar
105
+ * whose caret opens the inbox preset — categories + Unread/Flagged/Has
106
+ * attachment. No accounts group: an inbox is already scoped to one account.
107
+ */
108
+ function InboxScreen({
109
+ initialExpanded = false,
110
+ }: {
111
+ initialExpanded?: boolean;
112
+ }) {
113
+ const preset = inboxFilterConfig();
114
+ const [searchValue, setSearchValue] = useState("");
115
+ const [searchOpen, setSearchOpen] = useState(false);
116
+ const [expanded, setExpanded] = useState(initialExpanded);
117
+ const [category, setCategory] = useState("all");
118
+ const [activeFilters, setActiveFilters] = useState<Set<string>>(new Set());
119
+ const rows = sections.flatMap((s) => s.threads);
120
+
121
+ return (
122
+ <div className="flex h-full flex-col">
123
+ <MailHeader
124
+ title="Inbox"
125
+ unreadCount={3}
126
+ isDesktop={false}
127
+ onMenuClick={() => undefined}
128
+ searchValue={searchValue}
129
+ onSearchChange={setSearchValue}
130
+ searchOpen={searchOpen}
131
+ onSearchOpenChange={setSearchOpen}
132
+ />
133
+ <div className="min-h-0 flex-1">
134
+ <FilterSheet
135
+ categories={preset.categories}
136
+ filters={preset.filters}
137
+ sources={preset.sources}
138
+ selectedCategory={category}
139
+ activeFilters={activeFilters}
140
+ expanded={expanded}
141
+ onExpandedChange={setExpanded}
142
+ onSelectCategory={setCategory}
143
+ onToggleFilter={(id) =>
144
+ setActiveFilters((prev) => {
145
+ const next = new Set(prev);
146
+ if (next.has(id)) next.delete(id);
147
+ else next.add(id);
148
+ return next;
149
+ })
150
+ }
151
+ onClear={() => {
152
+ setCategory("all");
153
+ setActiveFilters(new Set());
154
+ }}
155
+ >
156
+ <ul className="divide-y divide-line">
157
+ {rows.map((thread) => (
158
+ <li key={thread.id} className="px-row-inset py-2.5">
159
+ <div className="text-sm font-medium text-fg">
160
+ {thread.fromName}
161
+ </div>
162
+ <div className="truncate text-xs text-fg-muted">
163
+ {thread.subject}
164
+ </div>
165
+ </li>
166
+ ))}
167
+ </ul>
168
+ </FilterSheet>
169
+ </div>
170
+ </div>
171
+ );
172
+ }
173
+
174
+ /** Inbox filter collapsed: header + the FilterSheet bar over the inbox list. */
175
+ export const InboxWithFilter: Story = {
176
+ render: () => <InboxScreen />,
177
+ decorators: [narrowFrame],
178
+ };
179
+
180
+ /** Inbox filter expanded: categories + Unread/Flagged/Has attachment. */
181
+ export const InboxWithFilterExpanded: Story = {
182
+ render: () => <InboxScreen initialExpanded />,
183
+ decorators: [narrowFrame],
184
+ };
185
+
186
+ /** Consumer-supplied `listBody` slot — the pane renders the chrome (header,
187
+ * keyboard hints) while the caller owns the scrollable rows. This models
188
+ * the web-client's virtualized inbox path. */
189
+ export const CustomListBody: Story = {
190
+ args: {
191
+ isDesktop: true,
192
+ flatList: true,
193
+ listBody: (
194
+ <div className="flex-1 overflow-y-auto divide-y divide-line">
195
+ {sections.flatMap((s) =>
196
+ s.threads.map((t) => (
197
+ <a
198
+ key={t.id}
199
+ href={`?selectedMessageId=${t.id}`}
200
+ className="flex items-center gap-3 px-4 py-3 hover:bg-surface-sunken"
201
+ >
202
+ <span className="font-medium text-sm">{t.fromName}</span>
203
+ <span className="text-sm text-fg-muted truncate">
204
+ {t.subject}
205
+ </span>
206
+ </a>
207
+ )),
208
+ )}
209
+ </div>
210
+ ),
211
+ },
212
+ decorators: [desktopFrame],
213
+ };
214
+
215
+ /** External `selectionBar` slot — the pane delegates the header to the caller
216
+ * when a selection is active. */
217
+ export const ExternalSelectionBar: Story = {
218
+ args: {
219
+ isDesktop: true,
220
+ flatList: true,
221
+ selectionBar: (
222
+ <SelectionTopBar
223
+ count={2}
224
+ onCancel={() => undefined}
225
+ onMarkRead={() => undefined}
226
+ onDelete={() => undefined}
227
+ />
228
+ ),
229
+ },
230
+ decorators: [desktopFrame],
231
+ };
232
+
233
+ /** Fail-loud error state — the specific failure detail is surfaced under the
234
+ * headline (not a bare "something went wrong"), with a way back (Retry) and a
235
+ * place for the failure to go (Report a problem). */
236
+ export const ErrorState: Story = {
237
+ args: {
238
+ isDesktop: true,
239
+ flatList: true,
240
+ listState: "error",
241
+ errorMessage: "Request timed out while loading this mailbox.",
242
+ onRetry: () => undefined,
243
+ onReportError: () => undefined,
244
+ },
245
+ decorators: [desktopFrame],
246
+ };
@@ -0,0 +1,248 @@
1
+ import { Menu } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { useState } from "react";
4
+ import type { AppShellProps, TouchSeed } from "./app-shell-types.js";
5
+ import { BriefSections } from "./brief-sections.js";
6
+ import { Button } from "./button.js";
7
+ import { KeyboardHintBar } from "./keyboard-hint-bar.js";
8
+ import {
9
+ MessageListEmpty,
10
+ MessageListError,
11
+ MessageListLoading,
12
+ } from "./message-list-state.js";
13
+ import { ComfortableRow, CompactRow } from "./message-row.js";
14
+ import { SelectionTopBar } from "./selection-top-bar.js";
15
+ import type { SwipePeek } from "./swipeable-row.js";
16
+ import { TouchListBody } from "./touch-list.js";
17
+
18
+ /* ------------------------------------------------------------------ */
19
+ /* Pane 2: message list (sectioned, dense, density toggle) */
20
+ /* ------------------------------------------------------------------ */
21
+
22
+ export function MessageListPane({
23
+ listTitle,
24
+ listMeta,
25
+ sections,
26
+ briefFilters,
27
+ flatList,
28
+ listState = "ready",
29
+ searchQuery,
30
+ errorMessage,
31
+ onRetry,
32
+ onReportError,
33
+ briefCategory,
34
+ selectedThreadId,
35
+ density = "comfortable",
36
+ onSelectThread,
37
+ onSelectBriefCategory,
38
+ onOpenNav,
39
+ isDesktop,
40
+ initialTouchState,
41
+ selectionBar,
42
+ listBody,
43
+ hideHeader = false,
44
+ }: Pick<
45
+ AppShellProps,
46
+ | "listTitle"
47
+ | "listMeta"
48
+ | "sections"
49
+ | "briefFilters"
50
+ | "flatList"
51
+ | "listState"
52
+ | "searchQuery"
53
+ | "errorMessage"
54
+ | "onRetry"
55
+ | "onReportError"
56
+ | "briefCategory"
57
+ | "selectedThreadId"
58
+ | "density"
59
+ | "onSelectThread"
60
+ | "onSelectBriefCategory"
61
+ > & {
62
+ /** When set, the list header shows a folders/menu button that opens the nav
63
+ * slide-over (list-only widths, where the nav is not a persistent pane). */
64
+ onOpenNav?: () => void;
65
+ /** Container-derived ≥1024; below it the touch triage chrome takes over. */
66
+ isDesktop: boolean;
67
+ initialTouchState?: TouchSeed;
68
+ /**
69
+ * Replaces the pane header when a selection is active. The caller controls
70
+ * the selection state and toolbar actions (mark-read, move, delete, cancel).
71
+ * When omitted the pane's built-in touch-triage selection bar is used.
72
+ */
73
+ selectionBar?: ReactNode;
74
+ /**
75
+ * Overrides the row-rendering section of the non-brief list — the whole
76
+ * scrollable body including virtualization, swipe-triage and any load-more
77
+ * indicator. Wins on every width (desktop and touch): the consumer owns the
78
+ * rows, so they stay real `<a href>` anchors and keep their own gestures
79
+ * instead of the kit substituting the mock `TouchListBody`. Brief paths are
80
+ * unaffected. When set, `sections` / `selectedThreadId` / `onSelectThread`
81
+ * are still used by the pane chrome; the rows themselves come from this slot.
82
+ */
83
+ listBody?: ReactNode;
84
+ /**
85
+ * Suppress the built-in title header. The consumer owns the header (e.g. the
86
+ * shared `MailHeader` rendered above the pane). The selection bar still
87
+ * replaces the (now absent) header while a selection is active.
88
+ */
89
+ hideHeader?: boolean;
90
+ }) {
91
+ const Row = density === "compact" ? CompactRow : ComfortableRow;
92
+
93
+ const touchTriage = !isDesktop && !briefFilters && listState === "ready";
94
+ const seededRows = sections.flatMap((section) => section.threads);
95
+ const [selectionMode, setSelectionMode] = useState(
96
+ initialTouchState === "selection",
97
+ );
98
+ const [checkedIds, setCheckedIds] = useState<ReadonlySet<string>>(() =>
99
+ initialTouchState === "selection"
100
+ ? new Set(seededRows.slice(0, 2).map((t) => t.id))
101
+ : new Set(),
102
+ );
103
+ const [refreshing, setRefreshing] = useState(false);
104
+ const initialPeek: SwipePeek | undefined =
105
+ initialTouchState === "peek-trailing"
106
+ ? "trailing"
107
+ : initialTouchState === "peek-leading"
108
+ ? "leading"
109
+ : undefined;
110
+
111
+ const toggleCheck = (id: string) => {
112
+ setCheckedIds((prev) => {
113
+ const next = new Set(prev);
114
+ if (next.has(id)) next.delete(id);
115
+ else next.add(id);
116
+ if (next.size === 0) setSelectionMode(false);
117
+ return next;
118
+ });
119
+ };
120
+ const enterSelection = (id: string) => {
121
+ setSelectionMode(true);
122
+ setCheckedIds(new Set([id]));
123
+ };
124
+ const cancelSelection = () => {
125
+ setSelectionMode(false);
126
+ setCheckedIds(new Set());
127
+ };
128
+ const refresh = () => {
129
+ setRefreshing(true);
130
+ setTimeout(() => setRefreshing(false), 1400);
131
+ };
132
+
133
+ // When the caller supplies a selectionBar slot, it owns selection state.
134
+ // Fall back to the built-in touch-triage bar only when no external bar is given.
135
+ const inBuiltinSelection =
136
+ !selectionBar && touchTriage && selectionMode && checkedIds.size > 0;
137
+
138
+ return (
139
+ <section className="flex h-full w-full flex-col bg-surface">
140
+ {selectionBar ??
141
+ (inBuiltinSelection ? (
142
+ <SelectionTopBar
143
+ count={checkedIds.size}
144
+ onCancel={cancelSelection}
145
+ onMarkRead={cancelSelection}
146
+ onDelete={cancelSelection}
147
+ />
148
+ ) : hideHeader ? null : (
149
+ <header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-row-inset">
150
+ {onOpenNav && (
151
+ <Button
152
+ variant="ghost"
153
+ size="sm"
154
+ icon={<Menu className="size-4" />}
155
+ onClick={onOpenNav}
156
+ aria-label="Open folders"
157
+ className="-ml-1 shrink-0"
158
+ />
159
+ )}
160
+ <h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
161
+ {listTitle}
162
+ </h1>
163
+ {listMeta && (
164
+ <span className="shrink-0 text-2xs text-fg-subtle">
165
+ {listMeta}
166
+ </span>
167
+ )}
168
+ </header>
169
+ ))}
170
+
171
+ {/* List body. Non-ready states replace the rows entirely (the live
172
+ MessageList does the same): skeleton on cold load, empty on a clean
173
+ mailbox / search, fail-hard error with a way back + report. */}
174
+ {listState === "loading" ? (
175
+ <div className="flex-1 overflow-y-auto">
176
+ <MessageListLoading />
177
+ </div>
178
+ ) : listState === "empty" ? (
179
+ <MessageListEmpty searchQuery={searchQuery} />
180
+ ) : listState === "error" ? (
181
+ <MessageListError
182
+ message={errorMessage}
183
+ onRetry={onRetry}
184
+ onReport={onReportError}
185
+ />
186
+ ) : briefFilters ? (
187
+ <BriefSections
188
+ sections={sections}
189
+ briefCategory={briefCategory}
190
+ selectedThreadId={selectedThreadId}
191
+ Row={Row}
192
+ onSelectThread={onSelectThread}
193
+ onSelectBriefCategory={onSelectBriefCategory}
194
+ />
195
+ ) : listBody != null ? (
196
+ /* Consumer-provided body wins on every width — it owns the rows
197
+ (real <a href> anchors, virtualization, infinite scroll) and its
198
+ own swipe-triage. The built-in TouchListBody below is only the
199
+ mock fallback for callers that don't supply a body. */
200
+ listBody
201
+ ) : touchTriage ? (
202
+ <TouchListBody
203
+ sections={sections}
204
+ selectedThreadId={selectedThreadId}
205
+ selectionMode={selectionMode}
206
+ checkedIds={checkedIds}
207
+ initialPeek={initialPeek}
208
+ onToggleCheck={toggleCheck}
209
+ onEnterSelection={enterSelection}
210
+ onOpenThread={(id) => onSelectThread?.(id)}
211
+ onRefresh={refresh}
212
+ refreshing={refreshing}
213
+ />
214
+ ) : (
215
+ <div className="flex-1 overflow-y-auto">
216
+ {sections.map((section) => (
217
+ <div key={section.id}>
218
+ {/* The plain flat mailbox suppresses section labels — it is one
219
+ continuous list, like the live $mailboxId MessageList. */}
220
+ {!flatList && section.label && (
221
+ <div className="sticky top-0 flex h-section-row items-center justify-between border-b border-line bg-surface-sunken px-row-inset">
222
+ <span className="text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
223
+ {section.label}
224
+ </span>
225
+ <span className="text-2xs text-fg-subtle tabular-nums">
226
+ {section.threads.length}
227
+ </span>
228
+ </div>
229
+ )}
230
+ <div className="divide-y divide-line">
231
+ {section.threads.map((thread) => (
232
+ <Row
233
+ key={thread.id}
234
+ thread={thread}
235
+ active={thread.id === selectedThreadId}
236
+ onClick={() => onSelectThread?.(thread.id)}
237
+ />
238
+ ))}
239
+ </div>
240
+ </div>
241
+ ))}
242
+ </div>
243
+ )}
244
+
245
+ {isDesktop && <KeyboardHintBar />}
246
+ </section>
247
+ );
248
+ }
@@ -0,0 +1,72 @@
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 {
6
+ MessageListEmpty,
7
+ MessageListError,
8
+ MessageListLoading,
9
+ } from "./message-list-state.js";
10
+
11
+ describe("MessageListLoading", () => {
12
+ const html = renderToString(createElement(MessageListLoading));
13
+
14
+ it("renders eight skeleton rows", () => {
15
+ const rows = html.match(/animate-pulse/g) ?? [];
16
+ assert.equal(rows.length, 8, "eight pulse rows, like the live skeleton");
17
+ });
18
+
19
+ it("marks the region busy for assistive tech", () => {
20
+ assert.match(html, /aria-busy="true"/);
21
+ });
22
+ });
23
+
24
+ describe("MessageListEmpty", () => {
25
+ it("uses the plain mailbox copy with no query", () => {
26
+ const html = renderToString(createElement(MessageListEmpty, {}));
27
+ assert.match(html, /No messages in this mailbox/);
28
+ });
29
+
30
+ it("switches to the search copy when a query is active", () => {
31
+ const html = renderToString(
32
+ createElement(MessageListEmpty, { searchQuery: "invoice" }),
33
+ );
34
+ assert.match(html, /No messages match your search/);
35
+ });
36
+
37
+ it("treats a whitespace-only query as no search", () => {
38
+ const html = renderToString(
39
+ createElement(MessageListEmpty, { searchQuery: " " }),
40
+ );
41
+ assert.match(html, /No messages in this mailbox/);
42
+ });
43
+ });
44
+
45
+ describe("MessageListError", () => {
46
+ it("fails hard: alert role, plain failure line, and the detail message", () => {
47
+ const html = renderToString(
48
+ createElement(MessageListError, { message: "Network unreachable" }),
49
+ );
50
+ assert.match(html, /role="alert"/, "blocking alert, never a toast");
51
+ assert.match(html, /Couldn&#x27;t load messages/, "plain failure line");
52
+ assert.match(html, /Network unreachable/);
53
+ });
54
+
55
+ it("offers a way back and a report path when handlers are given", () => {
56
+ const html = renderToString(
57
+ createElement(MessageListError, {
58
+ onRetry: () => {},
59
+ onReport: () => {},
60
+ }),
61
+ );
62
+ assert.match(html, /Retry/);
63
+ assert.match(html, /Report a problem/);
64
+ });
65
+
66
+ it("never renders a disabled control", () => {
67
+ const html = renderToString(
68
+ createElement(MessageListError, { onRetry: () => {} }),
69
+ );
70
+ assert.doesNotMatch(html, /\sdisabled[\s=>]/);
71
+ });
72
+ });
@@ -0,0 +1,97 @@
1
+ import { AlertCircle } from "lucide-react";
2
+ import { Button } from "./button.js";
3
+
4
+ /** Non-ready states the flat mailbox list can be in. "ready" renders rows. */
5
+ export type ListState = "ready" | "loading" | "empty" | "error";
6
+
7
+ /**
8
+ * Skeleton mirror of the live web-client MessageList LoadingSkeleton: eight
9
+ * pulse rows shaped like a comfortable message row (sender / time, subject,
10
+ * snippet). Kept visually identical so the cold-load mock matches production.
11
+ */
12
+ export function MessageListLoading() {
13
+ return (
14
+ // biome-ignore lint/a11y/useSemanticElements: <div> with role="status" preserves block layout; <output> is inline
15
+ <div
16
+ className="space-y-0"
17
+ role="status"
18
+ aria-busy="true"
19
+ aria-label="Loading messages"
20
+ >
21
+ {Array.from({ length: 8 }).map((_, i) => (
22
+ // biome-ignore lint/suspicious/noArrayIndexKey: list is static, no stable id
23
+ <div key={i} className="animate-pulse border-b border-line px-3 py-2">
24
+ <div className="mb-2 flex items-center justify-between">
25
+ <div className="h-4 w-32 rounded bg-surface-sunken" />
26
+ <div className="h-3 w-16 rounded bg-surface-sunken" />
27
+ </div>
28
+ <div className="mb-2 h-4 w-48 rounded bg-surface-sunken" />
29
+ <div className="h-3 w-full rounded bg-surface-sunken" />
30
+ </div>
31
+ ))}
32
+ </div>
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Empty mailbox / empty-search state. Copy mirrors the live MessageList
38
+ * EmptyState: a plain "No messages…" line, switching to the search variant
39
+ * when a query is active.
40
+ */
41
+ export function MessageListEmpty({ searchQuery }: { searchQuery?: string }) {
42
+ const isSearching = Boolean(searchQuery?.trim());
43
+ return (
44
+ <div className="flex h-full flex-1 items-center justify-center">
45
+ <div className="flex flex-col items-center justify-center p-8 text-center">
46
+ <p className="text-fg-muted">
47
+ {isSearching
48
+ ? "No messages match your search"
49
+ : "No messages in this mailbox"}
50
+ </p>
51
+ </div>
52
+ </div>
53
+ );
54
+ }
55
+
56
+ /**
57
+ * Fail-hard list error (ux.md): a centered, blocking message that stops the
58
+ * list — never a toast, never a control left looking healthy. States plainly
59
+ * what failed, surfaces the underlying message, and offers a way back (Retry)
60
+ * plus a way for the failure to go somewhere (Report).
61
+ */
62
+ export function MessageListError({
63
+ message = "Something went wrong loading this mailbox.",
64
+ onRetry,
65
+ onReport,
66
+ }: {
67
+ message?: string;
68
+ onRetry?: () => void;
69
+ onReport?: () => void;
70
+ }) {
71
+ return (
72
+ <div className="flex h-full flex-1 items-center justify-center p-4">
73
+ <div
74
+ role="alert"
75
+ className="flex max-w-sm flex-col items-center justify-center gap-3 text-center"
76
+ >
77
+ <AlertCircle className="size-8 text-danger" aria-hidden="true" />
78
+ <div>
79
+ <p className="font-medium text-danger">Couldn't load messages</p>
80
+ <p className="mt-1 break-words text-sm text-fg-muted">{message}</p>
81
+ </div>
82
+ <div className="flex items-center gap-2">
83
+ {onRetry && (
84
+ <Button variant="secondary" size="sm" onClick={onRetry}>
85
+ Retry
86
+ </Button>
87
+ )}
88
+ {onReport && (
89
+ <Button variant="ghost" size="sm" onClick={onReport}>
90
+ Report a problem
91
+ </Button>
92
+ )}
93
+ </div>
94
+ </div>
95
+ </div>
96
+ );
97
+ }