@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,48 @@
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 type { ThreadRowData } from "./app-shell-types.js";
6
+ import { ComfortableRow, CompactRow } from "./message-row.js";
7
+
8
+ const base: ThreadRowData = {
9
+ id: "t1",
10
+ accountId: "a1",
11
+ fromName: "Alex Rivera",
12
+ fromEmail: "alex@example.com",
13
+ subject: "Q3 planning notes",
14
+ snippet: "Pushed the deck to the shared drive.",
15
+ timeLabel: "9:42",
16
+ };
17
+
18
+ describe("ComfortableRow", () => {
19
+ it("renders fromName, subject and snippet", () => {
20
+ const html = renderToString(
21
+ createElement(ComfortableRow, { thread: { ...base, isRead: true } }),
22
+ );
23
+ assert.match(html, /Alex Rivera/);
24
+ assert.match(html, /Q3 planning notes/);
25
+ assert.match(html, /Pushed the deck/);
26
+ });
27
+
28
+ it("renders unread styling differently from read", () => {
29
+ const unread = renderToString(
30
+ createElement(ComfortableRow, { thread: { ...base, isRead: false } }),
31
+ );
32
+ const read = renderToString(
33
+ createElement(ComfortableRow, { thread: { ...base, isRead: true } }),
34
+ );
35
+ assert.notEqual(unread, read);
36
+ assert.match(unread, /font-semibold/);
37
+ });
38
+ });
39
+
40
+ describe("CompactRow", () => {
41
+ it("renders fromName and subject", () => {
42
+ const html = renderToString(
43
+ createElement(CompactRow, { thread: { ...base, isRead: true } }),
44
+ );
45
+ assert.match(html, /Alex Rivera/);
46
+ assert.match(html, /Q3 planning notes/);
47
+ });
48
+ });
@@ -0,0 +1,121 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import type { ThreadRowData } from "./app-shell-types.js";
3
+ import { ComfortableRow, CompactRow } from "./message-row.js";
4
+
5
+ const read: ThreadRowData = {
6
+ id: "r-read",
7
+ accountId: "a1",
8
+ fromName: "Alex Rivera",
9
+ fromEmail: "alex@example.com",
10
+ subject: "Re: Q3 planning notes",
11
+ snippet: "Sounds good — I pushed the deck to the shared drive.",
12
+ timeLabel: "9:42",
13
+ isRead: true,
14
+ };
15
+
16
+ const unread: ThreadRowData = {
17
+ id: "r-unread",
18
+ accountId: "a1",
19
+ fromName: "Priya Nair",
20
+ fromEmail: "priya@example.com",
21
+ subject: "Design review tomorrow",
22
+ snippet: "Can we move it to 2pm? I have a conflict in the morning.",
23
+ timeLabel: "8:15",
24
+ isRead: false,
25
+ messageCount: 3,
26
+ };
27
+
28
+ const starred: ThreadRowData = {
29
+ id: "r-starred",
30
+ accountId: "a1",
31
+ fromName: "Sam Okafor",
32
+ fromEmail: "sam@example.com",
33
+ subject: "Contract signed",
34
+ snippet: "Attaching the countersigned PDF for your records.",
35
+ timeLabel: "Mon",
36
+ isRead: true,
37
+ starred: true,
38
+ };
39
+
40
+ const suspicious: ThreadRowData = {
41
+ id: "r-suspicious",
42
+ accountId: "a1",
43
+ fromName: "Account Security",
44
+ fromEmail: "no-reply@secure-update.example",
45
+ subject: "Verify your account immediately",
46
+ snippet: "Your account will be suspended unless you confirm now.",
47
+ timeLabel: "Tue",
48
+ isRead: false,
49
+ suspicious: true,
50
+ };
51
+
52
+ const withAttachment: ThreadRowData = {
53
+ id: "r-attachment",
54
+ accountId: "a1",
55
+ fromName: "Dana Lopez",
56
+ fromEmail: "dana@example.com",
57
+ subject: "Invoice for May",
58
+ snippet: "Please find the attached invoice, due end of month.",
59
+ timeLabel: "Wed",
60
+ isRead: true,
61
+ hasAttachment: true,
62
+ };
63
+
64
+ const withCategory: ThreadRowData = {
65
+ id: "r-category",
66
+ accountId: "a1",
67
+ fromName: "The Weekly Brief",
68
+ fromEmail: "hello@weekly.example",
69
+ subject: "This week in product",
70
+ snippet: "Five stories you might have missed this week.",
71
+ timeLabel: "Thu",
72
+ isRead: false,
73
+ category: "newsletter",
74
+ };
75
+
76
+ const all = [read, unread, starred, suspicious, withAttachment, withCategory];
77
+
78
+ const meta: Meta = {
79
+ title: "Primitives/MessageRow",
80
+ parameters: { layout: "padded" },
81
+ };
82
+ export default meta;
83
+
84
+ type Story = StoryObj;
85
+
86
+ const List = ({ children }: { children: React.ReactNode }) => (
87
+ <div className="w-md divide-y divide-line rounded-md border border-line">
88
+ {children}
89
+ </div>
90
+ );
91
+
92
+ export const Comfortable: Story = {
93
+ render: () => (
94
+ <List>
95
+ {all.map((thread) => (
96
+ <ComfortableRow key={thread.id} thread={thread} />
97
+ ))}
98
+ </List>
99
+ ),
100
+ };
101
+
102
+ export const Compact: Story = {
103
+ render: () => (
104
+ <List>
105
+ {all.map((thread) => (
106
+ <CompactRow key={thread.id} thread={thread} />
107
+ ))}
108
+ </List>
109
+ ),
110
+ };
111
+
112
+ export const States: Story = {
113
+ render: () => (
114
+ <List>
115
+ <ComfortableRow thread={unread} />
116
+ <ComfortableRow thread={read} />
117
+ <ComfortableRow thread={starred} />
118
+ <ComfortableRow thread={suspicious} />
119
+ </List>
120
+ ),
121
+ };
@@ -0,0 +1,231 @@
1
+ import { Paperclip, ShieldAlert, Star } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { cn } from "../lib/cn.js";
4
+ import { categoryTone, type ThreadRowData } from "./app-shell-types.js";
5
+ import { Avatar } from "./avatar.js";
6
+ import { Badge } from "./badge.js";
7
+
8
+ /**
9
+ * Returns the CSS classes for a compact row outer element.
10
+ * `active` = open/selected; `focused` = keyboard-focused (left accent rail).
11
+ */
12
+ export const compactRowClass = ({
13
+ active,
14
+ focused,
15
+ }: {
16
+ active?: boolean;
17
+ focused?: boolean;
18
+ }) =>
19
+ cn(
20
+ "relative flex h-8 w-full items-center gap-2 px-row-inset text-left",
21
+ active
22
+ ? "bg-accent-2-soft"
23
+ : focused
24
+ ? "bg-surface-sunken"
25
+ : "hover:bg-surface-sunken",
26
+ focused &&
27
+ !active &&
28
+ "before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-accent-2",
29
+ );
30
+
31
+ /**
32
+ * Returns the CSS classes for a comfortable row outer element.
33
+ * `active` = open/selected; `focused` = keyboard-focused (left accent rail).
34
+ */
35
+ export const comfortableRowClass = ({
36
+ active,
37
+ focused,
38
+ }: {
39
+ active?: boolean;
40
+ focused?: boolean;
41
+ }) =>
42
+ cn(
43
+ // full-bleed highlight; content inset with a clear unread-dot gutter
44
+ "relative flex w-full items-start gap-3 py-2 pl-5 pr-row-inset text-left transition-colors",
45
+ active
46
+ ? "bg-accent-2-soft"
47
+ : focused
48
+ ? "bg-surface-sunken"
49
+ : "hover:bg-surface-sunken",
50
+ focused &&
51
+ !active &&
52
+ "before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-accent-2",
53
+ );
54
+
55
+ /**
56
+ * Inner body of a compact (mutt-mode) row. Place inside any wrapper element.
57
+ * The wrapper should use `compactRowClass()` for the outer CSS.
58
+ */
59
+ export function CompactRowBody({ thread }: { thread: ThreadRowData }) {
60
+ const unread = !thread.isRead;
61
+ return (
62
+ <>
63
+ <span className="flex w-4 shrink-0 items-center justify-center">
64
+ {thread.suspicious ? (
65
+ <ShieldAlert className="size-3.5 text-danger" />
66
+ ) : thread.starred ? (
67
+ <Star className="size-3 fill-warning text-warning" />
68
+ ) : unread ? (
69
+ <span className="size-1.5 rounded-full bg-accent" />
70
+ ) : null}
71
+ </span>
72
+ <span
73
+ className={cn(
74
+ "w-36 shrink-0 truncate text-xs",
75
+ unread ? "font-semibold text-fg" : "text-fg-muted",
76
+ )}
77
+ >
78
+ {thread.fromName}
79
+ </span>
80
+ <span className="min-w-0 flex-1 truncate text-xs">
81
+ <span className={cn(unread ? "font-medium text-fg" : "text-fg-muted")}>
82
+ {thread.subject}
83
+ </span>
84
+ <span className="text-fg-subtle"> — {thread.snippet}</span>
85
+ </span>
86
+ {thread.hasAttachment && (
87
+ <Paperclip className="size-3 shrink-0 text-fg-subtle" />
88
+ )}
89
+ <span className="w-11 shrink-0 text-right text-2xs text-fg-subtle tabular-nums">
90
+ {thread.timeLabel}
91
+ </span>
92
+ </>
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Text/glyph content block of a comfortable row (everything after the leading
98
+ * avatar/slot). Consumers provide their own leading element (Avatar, checkbox,
99
+ * etc.) and the unread dot (via the absolute-positioned sibling). Use this with
100
+ * `comfortableRowClass()` and render the leading slot + unread dot separately.
101
+ */
102
+ export function ComfortableRowTextContent({
103
+ thread,
104
+ badge,
105
+ }: {
106
+ thread: ThreadRowData;
107
+ /** Extra chip rendered after the category badge (e.g. an auto-moved indicator). */
108
+ badge?: ReactNode;
109
+ }) {
110
+ const unread = !thread.isRead;
111
+ return (
112
+ <span className="min-w-0 flex-1">
113
+ <span className="flex items-baseline justify-between gap-2">
114
+ <span
115
+ className={cn(
116
+ "flex min-w-0 items-center gap-1.5 truncate text-sm",
117
+ unread ? "font-semibold text-fg" : "font-medium text-fg-muted",
118
+ )}
119
+ >
120
+ {thread.trust === "vip" && (
121
+ <span className="size-1.5 shrink-0 rounded-full bg-accent-2" />
122
+ )}
123
+ <span className="truncate">{thread.fromName}</span>
124
+ {thread.messageCount != null && thread.messageCount > 1 && (
125
+ <span className="shrink-0 text-2xs font-normal text-fg-subtle">
126
+ {thread.messageCount}
127
+ </span>
128
+ )}
129
+ </span>
130
+ <span className="shrink-0 text-2xs text-fg-subtle tabular-nums">
131
+ {thread.timeLabel}
132
+ </span>
133
+ </span>
134
+ <span className="flex items-center gap-1.5">
135
+ <span
136
+ className={cn(
137
+ "truncate text-sm",
138
+ unread ? "text-fg" : "text-fg-muted",
139
+ )}
140
+ >
141
+ {thread.subject}
142
+ </span>
143
+ {thread.suspicious && (
144
+ <ShieldAlert className="size-3.5 shrink-0 text-danger" />
145
+ )}
146
+ {thread.starred && (
147
+ <Star className="size-3 shrink-0 fill-warning text-warning" />
148
+ )}
149
+ {thread.hasAttachment && (
150
+ <Paperclip className="size-3 shrink-0 text-fg-subtle" />
151
+ )}
152
+ </span>
153
+ <span className="flex items-center gap-1.5">
154
+ <span className="line-clamp-1 min-w-0 flex-1 text-xs text-fg-subtle">
155
+ {thread.snippet}
156
+ </span>
157
+ {thread.category && thread.category !== "personal" && (
158
+ <Badge tone={categoryTone[thread.category]} className="shrink-0">
159
+ {thread.category}
160
+ </Badge>
161
+ )}
162
+ {badge}
163
+ </span>
164
+ </span>
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Full inner body of a comfortable row (unread dot + avatar + text content).
170
+ * Place inside any wrapper element that uses `comfortableRowClass()`.
171
+ */
172
+ export function ComfortableRowBody({ thread }: { thread: ThreadRowData }) {
173
+ const unread = !thread.isRead;
174
+ return (
175
+ <>
176
+ {unread && (
177
+ <span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
178
+ )}
179
+ <Avatar name={thread.fromName} email={thread.fromEmail} size="sm" />
180
+ <ComfortableRowTextContent thread={thread} />
181
+ </>
182
+ );
183
+ }
184
+
185
+ export function CompactRow({
186
+ thread,
187
+ active,
188
+ onClick,
189
+ }: {
190
+ thread: ThreadRowData;
191
+ active?: boolean;
192
+ onClick?: () => void;
193
+ }) {
194
+ return (
195
+ <button
196
+ type="button"
197
+ onClick={onClick}
198
+ className={compactRowClass({ active })}
199
+ >
200
+ <CompactRowBody thread={thread} />
201
+ </button>
202
+ );
203
+ }
204
+
205
+ export function ComfortableRow({
206
+ thread,
207
+ active,
208
+ onClick,
209
+ }: {
210
+ thread: ThreadRowData;
211
+ active?: boolean;
212
+ onClick?: () => void;
213
+ }) {
214
+ return (
215
+ <button
216
+ type="button"
217
+ onClick={onClick}
218
+ className={comfortableRowClass({ active })}
219
+ >
220
+ <ComfortableRowBody thread={thread} />
221
+ </button>
222
+ );
223
+ }
224
+
225
+ /** A row renderer the brief drives — Comfortable/Compact rows or a consumer's
226
+ * own (e.g. the web client's navigation-aware row) all satisfy this shape. */
227
+ export type BriefRowComponent = (props: {
228
+ thread: ThreadRowData;
229
+ active?: boolean;
230
+ onClick?: () => void;
231
+ }) => React.ReactNode;
@@ -0,0 +1,87 @@
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 { MobileMessageActionBar } from "./mobile-message-action-bar.js";
6
+
7
+ const base = { hasThread: true } as const;
8
+
9
+ const countMatches = (html: string, needle: string) =>
10
+ html.split(needle).length - 1;
11
+
12
+ describe("MobileMessageActionBar", () => {
13
+ it("renders exactly one star and one trash, and no archive (IMAP has no archive)", () => {
14
+ const html = renderToString(
15
+ createElement(MobileMessageActionBar, {
16
+ ...base,
17
+ moveSlot: createElement("button", {
18
+ type: "button",
19
+ "aria-label": "Move to folder",
20
+ }),
21
+ }),
22
+ );
23
+ assert.equal(countMatches(html, 'aria-label="Flag"'), 1);
24
+ assert.equal(countMatches(html, 'aria-label="Move to folder"'), 1);
25
+ assert.equal(countMatches(html, 'aria-label="Move to Trash"'), 1);
26
+ assert.doesNotMatch(html, /aria-label="Archive"/);
27
+ });
28
+
29
+ it("flips the star label when starred", () => {
30
+ const html = renderToString(
31
+ createElement(MobileMessageActionBar, { ...base, isStarred: true }),
32
+ );
33
+ assert.match(html, /aria-label="Remove flag"/);
34
+ assert.match(html, /aria-pressed="true"/);
35
+ });
36
+
37
+ it("renders the move slot the caller supplies", () => {
38
+ const html = renderToString(
39
+ createElement(MobileMessageActionBar, {
40
+ ...base,
41
+ moveSlot: createElement("button", {
42
+ type: "button",
43
+ "aria-label": "Move to folder",
44
+ }),
45
+ }),
46
+ );
47
+ assert.match(html, /aria-label="Move to folder"/);
48
+ });
49
+
50
+ it("shows the overflow trigger when a mark-read handler is present", () => {
51
+ const html = renderToString(
52
+ createElement(MobileMessageActionBar, {
53
+ ...base,
54
+ onToggleRead: () => undefined,
55
+ }),
56
+ );
57
+ assert.match(html, /aria-label="More actions"/);
58
+ });
59
+
60
+ it("omits the overflow trigger with no overflow actions", () => {
61
+ const html = renderToString(createElement(MobileMessageActionBar, base));
62
+ assert.doesNotMatch(html, /aria-label="More actions"/);
63
+ });
64
+
65
+ it("renders the per-message reply verbs and no intelligence toggle", () => {
66
+ const html = renderToString(createElement(MobileMessageActionBar, base));
67
+ assert.match(html, /aria-label="Reply"/);
68
+ assert.match(html, /aria-label="Reply all"/);
69
+ assert.match(html, /aria-label="Forward"/);
70
+ assert.doesNotMatch(html, /intelligence panel/);
71
+ });
72
+
73
+ it("keeps verbs pressable rather than disabling them", () => {
74
+ const html = renderToString(createElement(MobileMessageActionBar, base));
75
+ assert.doesNotMatch(html, /disabled=""/);
76
+ });
77
+
78
+ it("surfaces the unavailable hint when no message is open", () => {
79
+ const html = renderToString(
80
+ createElement(MobileMessageActionBar, {
81
+ hasThread: false,
82
+ unavailableHint: "Open a message first",
83
+ }),
84
+ );
85
+ assert.match(html, /Open a message first/);
86
+ });
87
+ });
@@ -0,0 +1,57 @@
1
+ import type { Decorator, Meta, StoryObj } from "@storybook/react";
2
+ import { FolderInput } from "lucide-react";
3
+ import { Button } from "./button.js";
4
+ import { MobileMessageActionBar } from "./mobile-message-action-bar.js";
5
+
6
+ const moveSlot = (
7
+ <Button
8
+ variant="ghost"
9
+ size="sm"
10
+ icon={<FolderInput className="size-5" />}
11
+ aria-label="Move to folder"
12
+ title="Move to folder"
13
+ className="min-h-11 min-w-11 px-0"
14
+ />
15
+ );
16
+
17
+ const meta: Meta<typeof MobileMessageActionBar> = {
18
+ title: "Kit/MobileMessageActionBar",
19
+ component: MobileMessageActionBar,
20
+ parameters: { layout: "centered" },
21
+ decorators: [
22
+ ((Story) => (
23
+ <div
24
+ className="overflow-hidden rounded-lg border border-line"
25
+ style={{ width: 390 }}
26
+ >
27
+ <Story />
28
+ </div>
29
+ )) satisfies Decorator,
30
+ ],
31
+ args: {
32
+ hasThread: true,
33
+ moveSlot,
34
+ onReply: () => undefined,
35
+ onReplyAll: () => undefined,
36
+ onForward: () => undefined,
37
+ onToggleStar: () => undefined,
38
+ onDelete: () => undefined,
39
+ onToggleRead: () => undefined,
40
+ },
41
+ };
42
+ export default meta;
43
+
44
+ type Story = StoryObj<typeof MobileMessageActionBar>;
45
+
46
+ export const Default: Story = {};
47
+
48
+ export const Starred: Story = { args: { isStarred: true } };
49
+
50
+ /** No message open: the verbs no-op and the bar surfaces a one-line reason
51
+ * instead of disabling. */
52
+ export const NoMessageOpen: Story = {
53
+ args: {
54
+ hasThread: false,
55
+ unavailableHint: "Open a message first",
56
+ },
57
+ };