@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,227 @@
1
+ import { Check, Folder } from "lucide-react";
2
+ import { useState } from "react";
3
+ import { Button } from "./button.js";
4
+ import {
5
+ canonicalRoleLabel,
6
+ type FolderRole,
7
+ providerLeaf,
8
+ roleIcon,
9
+ } from "./folder-role.js";
10
+ import { Input } from "./input.js";
11
+ import { Select } from "./select.js";
12
+
13
+ /* ------------------------------------------------------------------ */
14
+ /* Role-centric folder settings (RFC 032, issue #976). */
15
+ /* */
16
+ /* Inverts the per-folder model: the canonical roles are the fixed */
17
+ /* anchor, and each row appoints WHICH existing folder fills it. A role */
18
+ /* points to at most one folder, so two-"Drafts" is unrepresentable. */
19
+ /* Replaces folder-name-row / folder-name-list. */
20
+ /* ------------------------------------------------------------------ */
21
+
22
+ /** The canonical roles a user appoints, in display order. */
23
+ export const APPOINTABLE_ROLES: readonly FolderRole[] = [
24
+ "inbox",
25
+ "drafts",
26
+ "sent",
27
+ "archive",
28
+ "junk",
29
+ "trash",
30
+ ];
31
+
32
+ export interface CandidateFolder {
33
+ /** Stable id (the mailbox id). */
34
+ mailboxId: string;
35
+ /** Server truth — the IMAP path. Read-only. */
36
+ providerPath: string;
37
+ /** Live message count, so the user can pick the folder that holds mail. */
38
+ messageCount: number;
39
+ }
40
+
41
+ /** Empty <option> value standing for "no folder appointed". */
42
+ const NONE = "";
43
+
44
+ /** Picker option text: `Concepten · 340 msgs`. */
45
+ function folderOptionLabel(folder: CandidateFolder): string {
46
+ const noun = folder.messageCount === 1 ? "msg" : "msgs";
47
+ return `${providerLeaf(folder.providerPath)} · ${folder.messageCount} ${noun}`;
48
+ }
49
+
50
+ interface RoleAppointmentRowProps {
51
+ role: FolderRole;
52
+ folders: readonly CandidateFolder[];
53
+ /** The mailbox appointed to this role, or null when the role is unfilled. */
54
+ appointedId: string | null;
55
+ /** Committed display-name override for the appointed folder. */
56
+ displayName: string;
57
+ onAppoint: (role: FolderRole, mailboxId: string | null) => void;
58
+ onRename: (mailboxId: string, name: string) => void;
59
+ }
60
+
61
+ /**
62
+ * One role row: the canonical role on the left, a picker choosing which
63
+ * existing folder fills it (options carry message counts so the user picks the
64
+ * folder that actually holds mail), and — once a folder is appointed — a rename
65
+ * field for its sidebar label. Selecting a folder here clears it from any other
66
+ * role on write; the picker can never produce a duplicate.
67
+ */
68
+ function RoleAppointmentRow({
69
+ role,
70
+ folders,
71
+ appointedId,
72
+ displayName,
73
+ onAppoint,
74
+ onRename,
75
+ }: RoleAppointmentRowProps) {
76
+ const [draftName, setDraftName] = useState(displayName);
77
+ const appointed = folders.find((f) => f.mailboxId === appointedId) ?? null;
78
+ const label = canonicalRoleLabel(role);
79
+ const renameDirty =
80
+ appointed != null && draftName.trim() !== displayName.trim();
81
+
82
+ return (
83
+ <div className="flex flex-col gap-1 border-b border-line px-row-inset py-2.5 last:border-b-0">
84
+ <div className="flex items-center gap-2">
85
+ <span className="flex w-28 shrink-0 items-center gap-2 text-sm font-medium text-fg">
86
+ {roleIcon(role)}
87
+ {label}
88
+ </span>
89
+ <Select
90
+ className="w-56 shrink-0"
91
+ value={appointedId ?? NONE}
92
+ aria-label={`Folder for ${label}`}
93
+ onChange={(event) =>
94
+ onAppoint(
95
+ role,
96
+ event.target.value === NONE ? null : event.target.value,
97
+ )
98
+ }
99
+ >
100
+ <option value={NONE}>None</option>
101
+ {folders.map((folder) => (
102
+ <option key={folder.mailboxId} value={folder.mailboxId}>
103
+ {folderOptionLabel(folder)}
104
+ </option>
105
+ ))}
106
+ </Select>
107
+ {appointed && (
108
+ <>
109
+ <Input
110
+ className="flex-1"
111
+ value={draftName}
112
+ placeholder={label}
113
+ aria-label={`Display name for ${label}`}
114
+ onChange={(event) => setDraftName(event.target.value)}
115
+ />
116
+ {renameDirty && (
117
+ <Button
118
+ variant="ghost"
119
+ size="sm"
120
+ className="shrink-0"
121
+ icon={<Check className="size-4" />}
122
+ aria-label="Save display name"
123
+ onClick={() => onRename(appointed.mailboxId, draftName)}
124
+ />
125
+ )}
126
+ </>
127
+ )}
128
+ </div>
129
+ {appointed && (
130
+ <span
131
+ className="truncate pl-[7.5rem] text-2xs text-fg-subtle"
132
+ title={appointed.providerPath}
133
+ >
134
+ {appointed.providerPath} · {appointed.messageCount}{" "}
135
+ {appointed.messageCount === 1 ? "message" : "messages"}
136
+ </span>
137
+ )}
138
+ </div>
139
+ );
140
+ }
141
+
142
+ export interface RoleAppointmentListProps {
143
+ accountEmail: string;
144
+ /** Every folder the account exposes (candidates for any role). */
145
+ folders: readonly CandidateFolder[];
146
+ /** role → appointed mailboxId. A missing role means "None". */
147
+ appointments: Readonly<Record<string, string | null>>;
148
+ /** mailboxId → committed display-name override. */
149
+ displayNames?: Readonly<Record<string, string>>;
150
+ onAppoint: (role: FolderRole, mailboxId: string | null) => void;
151
+ onRename: (mailboxId: string, name: string) => void;
152
+ }
153
+
154
+ /**
155
+ * The per-account folder-roles settings section. Lists the canonical roles,
156
+ * each appointing one existing folder, then the leftover unappointed folders as
157
+ * plain folders. Controlled — the caller owns the appointment map and routes
158
+ * each change back. Pure kit primitives (Select/Input/Button); no disabled
159
+ * controls (a picker always offers "None").
160
+ */
161
+ export function RoleAppointmentList({
162
+ accountEmail,
163
+ folders,
164
+ appointments,
165
+ displayNames = {},
166
+ onAppoint,
167
+ onRename,
168
+ }: RoleAppointmentListProps) {
169
+ const appointedIds = new Set(
170
+ Object.values(appointments).filter((id): id is string => id != null),
171
+ );
172
+ const leftovers = folders.filter((f) => !appointedIds.has(f.mailboxId));
173
+
174
+ return (
175
+ <section className="space-y-3">
176
+ <header className="space-y-1">
177
+ <h2 className="text-sm font-semibold text-fg">
178
+ Folder roles — {accountEmail}
179
+ </h2>
180
+ <p className="text-xs text-fg-muted">
181
+ Each role points to one folder. Pick the folder that holds the mail —
182
+ the counts help you tell real folders from empty look-alikes.
183
+ Appointing a folder here removes it from any other role.
184
+ </p>
185
+ </header>
186
+ <div className="rounded-sm border border-line bg-surface">
187
+ {APPOINTABLE_ROLES.map((role) => {
188
+ const appointedId = appointments[role] ?? null;
189
+ return (
190
+ <RoleAppointmentRow
191
+ key={role}
192
+ role={role}
193
+ folders={folders}
194
+ appointedId={appointedId}
195
+ displayName={appointedId ? (displayNames[appointedId] ?? "") : ""}
196
+ onAppoint={onAppoint}
197
+ onRename={onRename}
198
+ />
199
+ );
200
+ })}
201
+ </div>
202
+
203
+ {leftovers.length > 0 && (
204
+ <div className="space-y-1.5">
205
+ <h3 className="text-xs font-medium text-fg-muted">Other folders</h3>
206
+ <ul className="rounded-sm border border-line bg-surface">
207
+ {leftovers.map((folder) => (
208
+ <li
209
+ key={folder.mailboxId}
210
+ className="flex items-center gap-2 border-b border-line px-row-inset py-2 text-sm text-fg last:border-b-0"
211
+ >
212
+ <Folder className="size-4 shrink-0 text-fg-subtle" />
213
+ <span className="truncate">
214
+ {providerLeaf(folder.providerPath)}
215
+ </span>
216
+ <span className="ml-auto shrink-0 text-2xs text-fg-subtle">
217
+ {folder.messageCount}{" "}
218
+ {folder.messageCount === 1 ? "message" : "messages"}
219
+ </span>
220
+ </li>
221
+ ))}
222
+ </ul>
223
+ </div>
224
+ )}
225
+ </section>
226
+ );
227
+ }
@@ -0,0 +1,83 @@
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 { RowActions } from "./row-actions.js";
6
+
7
+ describe("RowActions", () => {
8
+ it("renders secondary actions as buttons, never disabled", () => {
9
+ const html = renderToString(
10
+ createElement(RowActions, {
11
+ actions: [
12
+ { label: "Manage", onClick: () => undefined },
13
+ {
14
+ label: "Reconnect",
15
+ variant: "secondary",
16
+ onClick: () => undefined,
17
+ },
18
+ ],
19
+ }),
20
+ );
21
+ assert.match(html, /Manage/);
22
+ assert.match(html, /Reconnect/);
23
+ assert.doesNotMatch(html, /disabled=""/);
24
+ });
25
+
26
+ it("renders the destructive action as a real button with no disabled attribute", () => {
27
+ const html = renderToString(
28
+ createElement(RowActions, {
29
+ destructive: {
30
+ label: "Delete",
31
+ iconOnly: true,
32
+ onClick: () => undefined,
33
+ confirm: { prompt: "Delete this account?" },
34
+ },
35
+ }),
36
+ );
37
+ assert.match(html, /<button/);
38
+ assert.match(html, /aria-label="Delete"/);
39
+ assert.doesNotMatch(html, /disabled=""/);
40
+ });
41
+
42
+ it("uses aria-busy, not disabled, while an action is busy", () => {
43
+ const html = renderToString(
44
+ createElement(RowActions, {
45
+ actions: [
46
+ {
47
+ label: "Reconnect",
48
+ variant: "secondary",
49
+ busy: true,
50
+ busyLabel: "Redirecting…",
51
+ onClick: () => undefined,
52
+ },
53
+ ],
54
+ }),
55
+ );
56
+ assert.match(html, /aria-busy="true"/);
57
+ assert.match(html, /Redirecting/);
58
+ assert.match(html, /animate-spin/);
59
+ assert.doesNotMatch(html, /disabled=""/);
60
+ });
61
+
62
+ it("renders an icon-only destructive action with an accessible label", () => {
63
+ const html = renderToString(
64
+ createElement(RowActions, {
65
+ actions: [
66
+ {
67
+ label: "Retry sending",
68
+ iconOnly: true,
69
+ onClick: () => undefined,
70
+ },
71
+ ],
72
+ destructive: {
73
+ label: "Delete message",
74
+ iconOnly: true,
75
+ onClick: () => undefined,
76
+ },
77
+ }),
78
+ );
79
+ assert.match(html, /aria-label="Retry sending"/);
80
+ assert.match(html, /aria-label="Delete message"/);
81
+ assert.doesNotMatch(html, /disabled=""/);
82
+ });
83
+ });
@@ -0,0 +1,87 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { RotateCcw, Send, Trash2 } from "lucide-react";
3
+ import { RowActions } from "./row-actions.js";
4
+
5
+ const meta: Meta<typeof RowActions> = {
6
+ title: "Primitives/RowActions",
7
+ component: RowActions,
8
+ parameters: { layout: "padded" },
9
+ };
10
+ export default meta;
11
+
12
+ type Story = StoryObj<typeof RowActions>;
13
+
14
+ export const SecondaryWithDestructiveConfirm: Story = {
15
+ name: "Secondary + destructive confirm",
16
+ args: {
17
+ actions: [{ label: "Manage", onClick: () => undefined }],
18
+ destructive: {
19
+ label: "Delete",
20
+ icon: <Trash2 className="size-3.5" />,
21
+ iconOnly: true,
22
+ onClick: () => undefined,
23
+ confirm: {
24
+ prompt: "Delete this account?",
25
+ confirmLabel: "Delete account",
26
+ },
27
+ },
28
+ },
29
+ };
30
+
31
+ export const Reconnect: Story = {
32
+ args: {
33
+ actions: [
34
+ { label: "Reconnect", variant: "secondary", onClick: () => undefined },
35
+ ],
36
+ destructive: {
37
+ label: "Delete",
38
+ icon: <Trash2 className="size-3.5" />,
39
+ iconOnly: true,
40
+ onClick: () => undefined,
41
+ confirm: {
42
+ prompt: "Delete this account?",
43
+ confirmLabel: "Delete account",
44
+ },
45
+ },
46
+ },
47
+ };
48
+
49
+ export const Reconnecting: Story = {
50
+ args: {
51
+ actions: [
52
+ {
53
+ label: "Reconnect",
54
+ variant: "secondary",
55
+ busy: true,
56
+ busyLabel: "Redirecting…",
57
+ onClick: () => undefined,
58
+ },
59
+ ],
60
+ },
61
+ };
62
+
63
+ export const OutboxFailedRow: Story = {
64
+ name: "Outbox — failed row",
65
+ args: {
66
+ actions: [
67
+ {
68
+ label: "Retry sending",
69
+ icon: <RotateCcw className="size-3.5" />,
70
+ iconOnly: true,
71
+ onClick: () => undefined,
72
+ },
73
+ {
74
+ label: "Edit as draft",
75
+ icon: <Send className="size-3.5" />,
76
+ iconOnly: true,
77
+ onClick: () => undefined,
78
+ },
79
+ ],
80
+ destructive: {
81
+ label: "Delete message",
82
+ icon: <Trash2 className="size-3.5" />,
83
+ iconOnly: true,
84
+ onClick: () => undefined,
85
+ },
86
+ },
87
+ };
@@ -0,0 +1,139 @@
1
+ import { Loader2 } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { useState } from "react";
4
+ import { cn } from "../lib/cn.js";
5
+ import { Button } from "./button.js";
6
+
7
+ const spinner = (): ReactNode => <Loader2 className="size-3.5 animate-spin" />;
8
+
9
+ export interface RowAction {
10
+ label: string;
11
+ onClick: () => void;
12
+ icon?: ReactNode;
13
+ variant?: "secondary" | "ghost";
14
+ busy?: boolean;
15
+ /** Replaces the label while busy (e.g. "Redirecting…"). */
16
+ busyLabel?: string;
17
+ /** Render the label visually hidden, leaving an icon-only button. */
18
+ iconOnly?: boolean;
19
+ }
20
+
21
+ export interface RowDestructiveAction {
22
+ label: string;
23
+ onClick: () => void;
24
+ icon?: ReactNode;
25
+ busy?: boolean;
26
+ busyLabel?: string;
27
+ iconOnly?: boolean;
28
+ /**
29
+ * When set, the destructive action reflects an inline confirm step
30
+ * (cancel / confirm) instead of firing immediately. Surfaces that own a
31
+ * separate confirm dialog omit this and handle confirmation themselves.
32
+ */
33
+ confirm?: {
34
+ /** Plain consequence shown before the confirm button. */
35
+ prompt: string;
36
+ confirmLabel?: string;
37
+ cancelLabel?: string;
38
+ };
39
+ }
40
+
41
+ export interface RowActionsProps {
42
+ /** Secondary actions, rendered before the destructive one. */
43
+ actions?: RowAction[];
44
+ /** The single destructive action (delete / remove). */
45
+ destructive?: RowDestructiveAction;
46
+ className?: string;
47
+ }
48
+
49
+ const actionLabel = (label: string, iconOnly?: boolean): ReactNode =>
50
+ iconOnly ? <span className="sr-only">{label}</span> : label;
51
+
52
+ /**
53
+ * One shared per-row action cluster. Secondary actions plus an optional
54
+ * destructive action that either reflects an inline confirm step or defers
55
+ * to a surface-owned dialog. Never a naked destructive icon, never a
56
+ * disabled control — busy state uses `aria-busy`.
57
+ */
58
+ export function RowActions({
59
+ actions = [],
60
+ destructive,
61
+ className,
62
+ }: RowActionsProps) {
63
+ const [confirming, setConfirming] = useState(false);
64
+
65
+ if (confirming && destructive?.confirm) {
66
+ return (
67
+ <div className={cn("flex items-center gap-2", className)}>
68
+ <span className="text-xs text-fg-muted">
69
+ {destructive.confirm.prompt}
70
+ </span>
71
+ <Button
72
+ variant="secondary"
73
+ size="sm"
74
+ onClick={() => setConfirming(false)}
75
+ >
76
+ {destructive.confirm.cancelLabel ?? "Cancel"}
77
+ </Button>
78
+ <Button
79
+ variant="danger"
80
+ size="sm"
81
+ aria-busy={destructive.busy}
82
+ icon={destructive.busy ? spinner() : undefined}
83
+ onClick={() => {
84
+ if (destructive.busy) return;
85
+ destructive.onClick();
86
+ }}
87
+ >
88
+ {destructive.busy
89
+ ? (destructive.busyLabel ?? "Working…")
90
+ : (destructive.confirm.confirmLabel ?? destructive.label)}
91
+ </Button>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ return (
97
+ <div className={cn("flex items-center gap-2", className)}>
98
+ {actions.map((action) => (
99
+ <Button
100
+ key={action.label}
101
+ variant={action.variant ?? "ghost"}
102
+ size="sm"
103
+ aria-busy={action.busy}
104
+ aria-label={action.iconOnly ? action.label : undefined}
105
+ icon={action.busy ? spinner() : action.icon}
106
+ onClick={() => {
107
+ if (action.busy) return;
108
+ action.onClick();
109
+ }}
110
+ >
111
+ {action.busy && action.busyLabel
112
+ ? action.busyLabel
113
+ : actionLabel(action.label, action.iconOnly)}
114
+ </Button>
115
+ ))}
116
+ {destructive && (
117
+ <Button
118
+ variant="ghost"
119
+ size="sm"
120
+ aria-busy={destructive.busy}
121
+ aria-label={destructive.iconOnly ? destructive.label : undefined}
122
+ icon={destructive.busy ? spinner() : destructive.icon}
123
+ onClick={() => {
124
+ if (destructive.busy) return;
125
+ if (destructive.confirm) {
126
+ setConfirming(true);
127
+ return;
128
+ }
129
+ destructive.onClick();
130
+ }}
131
+ >
132
+ {destructive.busy && destructive.busyLabel
133
+ ? destructive.busyLabel
134
+ : actionLabel(destructive.label, destructive.iconOnly)}
135
+ </Button>
136
+ )}
137
+ </div>
138
+ );
139
+ }
@@ -0,0 +1,55 @@
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 { SearchBar } from "./search-bar.js";
6
+
7
+ const noop = () => {};
8
+
9
+ describe("SearchBar", () => {
10
+ it("renders the search input with an accessible label and placeholder", () => {
11
+ const html = renderToString(
12
+ createElement(SearchBar, {
13
+ value: "",
14
+ onChange: noop,
15
+ onClear: noop,
16
+ placeholder: "Search mail",
17
+ }),
18
+ );
19
+ assert.match(html, /aria-label="Search mail"/);
20
+ assert.match(html, /placeholder="Search mail"/);
21
+ assert.match(html, /value=""/);
22
+ });
23
+
24
+ it("omits the clear button while the query is empty", () => {
25
+ const html = renderToString(
26
+ createElement(SearchBar, { value: "", onChange: noop, onClear: noop }),
27
+ );
28
+ assert.doesNotMatch(html, /aria-label="Clear search"/);
29
+ });
30
+
31
+ it("shows the clear button once a query is present", () => {
32
+ const html = renderToString(
33
+ createElement(SearchBar, {
34
+ value: "receipt",
35
+ onChange: noop,
36
+ onClear: noop,
37
+ }),
38
+ );
39
+ assert.match(html, /aria-label="Clear search"/);
40
+ assert.match(html, /value="receipt"/);
41
+ });
42
+
43
+ it("omits the inline clear button when showClearButton is false", () => {
44
+ const html = renderToString(
45
+ createElement(SearchBar, {
46
+ value: "receipt",
47
+ onChange: noop,
48
+ onClear: noop,
49
+ showClearButton: false,
50
+ }),
51
+ );
52
+ assert.doesNotMatch(html, /aria-label="Clear search"/);
53
+ assert.match(html, /value="receipt"/);
54
+ });
55
+ });
@@ -0,0 +1,46 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { SearchBar } from "./search-bar.js";
4
+
5
+ const meta: Meta<typeof SearchBar> = {
6
+ title: "Mail/SearchBar",
7
+ component: SearchBar,
8
+ parameters: { layout: "padded" },
9
+ };
10
+ export default meta;
11
+
12
+ type Story = StoryObj<typeof SearchBar>;
13
+
14
+ const Interactive = ({ initial = "" }: { initial?: string }) => {
15
+ const [value, setValue] = useState(initial);
16
+ return (
17
+ <div className="w-80">
18
+ <SearchBar
19
+ value={value}
20
+ onChange={setValue}
21
+ onClear={() => setValue("")}
22
+ onClearQuery={() => setValue("")}
23
+ globalFocusKey={false}
24
+ />
25
+ </div>
26
+ );
27
+ };
28
+
29
+ export const Empty: Story = {
30
+ render: () => <Interactive />,
31
+ };
32
+
33
+ export const Typing: Story = {
34
+ render: () => <Interactive initial="invoi" />,
35
+ };
36
+
37
+ export const WithQuery: Story = {
38
+ render: () => <Interactive initial="from:acme receipt" />,
39
+ };
40
+
41
+ export const Focused: Story = {
42
+ render: () => <Interactive />,
43
+ play: async ({ canvasElement }) => {
44
+ canvasElement.querySelector<HTMLInputElement>("#mail-search")?.focus();
45
+ },
46
+ };