@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,173 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Lock, Mail, Search } from "lucide-react";
3
+ import { Avatar } from "./avatar.js";
4
+ import { Badge } from "./badge.js";
5
+ import { Button } from "./button.js";
6
+ import { Card, CardBody, CardHeader, CardTitle } from "./card.js";
7
+ import { FieldLabel } from "./field-label.js";
8
+ import { Input } from "./input.js";
9
+ import { ListItem } from "./list-item.js";
10
+ import { SecuritySelect } from "./security-select.js";
11
+ import { Select } from "./select.js";
12
+
13
+ const meta: Meta = {
14
+ title: "Primitives",
15
+ };
16
+ export default meta;
17
+
18
+ type Story = StoryObj;
19
+
20
+ export const Buttons: Story = {
21
+ render: () => (
22
+ <div className="flex flex-wrap items-center gap-3 p-8">
23
+ <Button variant="primary">Primary</Button>
24
+ <Button variant="secondary">Secondary</Button>
25
+ <Button variant="ghost">Ghost</Button>
26
+ <Button variant="danger">Danger</Button>
27
+ <Button variant="primary" size="sm" icon={<Mail className="size-3.5" />}>
28
+ Compose
29
+ </Button>
30
+ <Button variant="primary" disabled>
31
+ Disabled
32
+ </Button>
33
+ </div>
34
+ ),
35
+ };
36
+
37
+ export const Badges: Story = {
38
+ render: () => (
39
+ <div className="flex flex-wrap items-center gap-3 p-8">
40
+ <Badge tone="neutral">newsletter</Badge>
41
+ <Badge tone="accent">personal</Badge>
42
+ <Badge tone="positive" dot>
43
+ Known sender
44
+ </Badge>
45
+ <Badge tone="warning">social</Badge>
46
+ <Badge tone="danger">spam</Badge>
47
+ </div>
48
+ ),
49
+ };
50
+
51
+ export const Avatars: Story = {
52
+ render: () => (
53
+ <div className="flex items-center gap-4 p-8">
54
+ <Avatar name="Alice Tan" size="sm" />
55
+ <Avatar name="Priya Natarajan" size="md" />
56
+ <Avatar name="Marcus Webb" size="lg" />
57
+ <Avatar name="Remit CI" email="ci@remit.example" size="md" />
58
+ </div>
59
+ ),
60
+ };
61
+
62
+ export const Inputs: Story = {
63
+ render: () => (
64
+ <div className="max-w-sm space-y-3 p-8">
65
+ <Input placeholder="Plain input" />
66
+ <Input icon={<Search className="size-4" />} placeholder="Search mail" />
67
+ </div>
68
+ ),
69
+ };
70
+
71
+ export const InlineInputs: Story = {
72
+ render: () => (
73
+ <div className="max-w-sm space-y-3 p-8">
74
+ <div className="border-b border-line px-3 py-2">
75
+ <Input variant="inline" placeholder="Filter folders" />
76
+ </div>
77
+ <div className="border-b border-line px-3 py-2">
78
+ <Input
79
+ variant="inline"
80
+ icon={<Search className="size-4" />}
81
+ placeholder="Move to…"
82
+ />
83
+ </div>
84
+ </div>
85
+ ),
86
+ };
87
+
88
+ export const LabeledFields: Story = {
89
+ render: () => (
90
+ <div className="max-w-sm space-y-3 p-8">
91
+ <div>
92
+ <FieldLabel htmlFor="host">Host</FieldLabel>
93
+ <Input id="host" placeholder="imap.example.com" />
94
+ </div>
95
+ <div>
96
+ <FieldLabel htmlFor="security">Security</FieldLabel>
97
+ <SecuritySelect id="security" defaultValue="tls" />
98
+ </div>
99
+ </div>
100
+ ),
101
+ };
102
+
103
+ export const SecuritySelects: Story = {
104
+ render: () => (
105
+ <div className="max-w-sm space-y-3 p-8">
106
+ <SecuritySelect defaultValue="tls" />
107
+ <SecuritySelect defaultValue="starttls" />
108
+ <SecuritySelect defaultValue="none" />
109
+ </div>
110
+ ),
111
+ };
112
+
113
+ export const Selects: Story = {
114
+ render: () => (
115
+ <div className="max-w-sm space-y-3 p-8">
116
+ <Select defaultValue="tls">
117
+ <option value="tls">TLS/SSL</option>
118
+ <option value="starttls">STARTTLS</option>
119
+ <option value="none">None (insecure)</option>
120
+ </Select>
121
+ <Select icon={<Lock className="size-4" />} defaultValue="starttls">
122
+ <option value="tls">TLS/SSL</option>
123
+ <option value="starttls">STARTTLS</option>
124
+ <option value="none">None (insecure)</option>
125
+ </Select>
126
+ </div>
127
+ ),
128
+ };
129
+
130
+ export const Cards: Story = {
131
+ render: () => (
132
+ <div className="max-w-md p-8">
133
+ <Card raised>
134
+ <CardHeader>
135
+ <CardTitle>Card title</CardTitle>
136
+ </CardHeader>
137
+ <CardBody>
138
+ Hairline border, minimal shadow. Surfaces and text are fully
139
+ token-driven so this flips cleanly between light and dark.
140
+ </CardBody>
141
+ </Card>
142
+ </div>
143
+ ),
144
+ };
145
+
146
+ export const ListItems: Story = {
147
+ render: () => (
148
+ <div className="max-w-md divide-y divide-line p-8">
149
+ <ListItem
150
+ unread
151
+ leading={<Avatar name="Priya Natarajan" size="md" />}
152
+ trailing="08:52"
153
+ >
154
+ <div className="text-sm font-semibold text-fg">Priya Natarajan</div>
155
+ <div className="text-sm text-fg">Q3 roadmap review</div>
156
+ <p className="line-clamp-1 text-xs text-fg-subtle">
157
+ Sharing the agenda ahead of Thursday…
158
+ </p>
159
+ </ListItem>
160
+ <ListItem
161
+ active
162
+ leading={<Avatar name="Marcus Webb" size="md" />}
163
+ trailing="Wed"
164
+ >
165
+ <div className="text-sm font-medium text-fg-muted">Marcus Webb</div>
166
+ <div className="text-sm text-fg-muted">Re: Reading pane density</div>
167
+ <p className="line-clamp-1 text-xs text-fg-subtle">
168
+ Strong +1 on tightening the rows…
169
+ </p>
170
+ </ListItem>
171
+ </div>
172
+ ),
173
+ };
@@ -0,0 +1,46 @@
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 { PullToRefresh } from "./pull-to-refresh.js";
6
+
7
+ const noop = () => Promise.resolve();
8
+ const list = createElement("ul", null, "messages");
9
+
10
+ describe("PullToRefresh", () => {
11
+ it("renders its children inside the pull wrapper", () => {
12
+ const html = renderToString(
13
+ createElement(PullToRefresh, {
14
+ onRefresh: noop,
15
+ // biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
16
+ children: list,
17
+ }),
18
+ );
19
+ assert.match(html, /messages/);
20
+ assert.match(html, /ptr/);
21
+ });
22
+
23
+ it("keeps the gesture pullable while not refreshing", () => {
24
+ const html = renderToString(
25
+ createElement(PullToRefresh, {
26
+ onRefresh: noop,
27
+ isRefreshing: false,
28
+ // biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
29
+ children: list,
30
+ }),
31
+ );
32
+ assert.match(html, /ptr/);
33
+ });
34
+
35
+ it("still renders its children while a refresh is in flight", () => {
36
+ const html = renderToString(
37
+ createElement(PullToRefresh, {
38
+ onRefresh: noop,
39
+ isRefreshing: true,
40
+ // biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
41
+ children: list,
42
+ }),
43
+ );
44
+ assert.match(html, /messages/);
45
+ });
46
+ });
@@ -0,0 +1,71 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { PullToRefresh } from "./pull-to-refresh.js";
3
+
4
+ const rows = Array.from({ length: 20 }, (_, i) => `Message ${i + 1}`);
5
+
6
+ function MessageList() {
7
+ return (
8
+ <ul className="divide-y divide-line bg-surface">
9
+ {rows.map((row) => (
10
+ <li key={row} className="px-4 py-3 text-sm text-fg">
11
+ {row}
12
+ </li>
13
+ ))}
14
+ </ul>
15
+ );
16
+ }
17
+
18
+ function PhoneFrame({ children }: { children: React.ReactNode }) {
19
+ return (
20
+ <div className="h-[480px] max-w-md overflow-y-auto rounded-lg border border-line">
21
+ {children}
22
+ </div>
23
+ );
24
+ }
25
+
26
+ const meta: Meta<typeof PullToRefresh> = {
27
+ title: "Primitives/PullToRefresh",
28
+ component: PullToRefresh,
29
+ parameters: { layout: "padded" },
30
+ render: (args) => (
31
+ <PhoneFrame>
32
+ <PullToRefresh {...args}>
33
+ <MessageList />
34
+ </PullToRefresh>
35
+ </PhoneFrame>
36
+ ),
37
+ };
38
+ export default meta;
39
+
40
+ type Story = StoryObj<typeof PullToRefresh>;
41
+
42
+ export const Idle: Story = {
43
+ name: "Idle — pull to refresh",
44
+ args: {
45
+ onRefresh: () => new Promise((resolve) => setTimeout(resolve, 1200)),
46
+ },
47
+ };
48
+
49
+ export const Refreshing: Story = {
50
+ name: "Refreshing — gesture suspended",
51
+ args: {
52
+ onRefresh: () => Promise.resolve(),
53
+ isRefreshing: true,
54
+ },
55
+ };
56
+
57
+ export const DesktopNoop: Story = {
58
+ name: "Desktop — gesture inert",
59
+ parameters: {
60
+ viewport: { defaultViewport: "desktop" },
61
+ docs: {
62
+ description: {
63
+ story:
64
+ "At desktop widths (lg, 1024px and up) the gesture is inert and the list renders directly — there is no touch list to pull.",
65
+ },
66
+ },
67
+ },
68
+ args: {
69
+ onRefresh: () => Promise.resolve(),
70
+ },
71
+ };
@@ -0,0 +1,54 @@
1
+ import type { ReactElement } from "react";
2
+ import { useEffect, useState } from "react";
3
+ import ReactPullToRefresh from "react-simple-pull-to-refresh";
4
+
5
+ export interface PullToRefreshProps {
6
+ children: ReactElement;
7
+ onRefresh: () => Promise<unknown>;
8
+ isRefreshing?: boolean;
9
+ }
10
+
11
+ const useMatchMedia = (query: string): boolean => {
12
+ const [matches, setMatches] = useState(() => {
13
+ if (typeof window === "undefined" || !window.matchMedia) return false;
14
+ return window.matchMedia(query).matches;
15
+ });
16
+
17
+ useEffect(() => {
18
+ if (typeof window === "undefined" || !window.matchMedia) return;
19
+ const mql = window.matchMedia(query);
20
+ setMatches(mql.matches);
21
+ const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
22
+ mql.addEventListener("change", handler);
23
+ return () => mql.removeEventListener("change", handler);
24
+ }, [query]);
25
+
26
+ return matches;
27
+ };
28
+
29
+ /**
30
+ * Wraps a scrollable list with a pull-to-refresh gesture on mobile. Below the
31
+ * desktop breakpoint (Tailwind `lg`, 1024px) a downward pull at the top of the
32
+ * list fires `onRefresh`; at desktop widths the gesture is inert and children
33
+ * render directly, since there is no touch list to pull.
34
+ *
35
+ * Presentational: the caller owns what refreshing means via `onRefresh` and
36
+ * surfaces in-flight state through `isRefreshing`.
37
+ */
38
+ export const PullToRefresh = ({
39
+ children,
40
+ onRefresh,
41
+ isRefreshing,
42
+ }: PullToRefreshProps): ReactElement => {
43
+ const isDesktop = useMatchMedia("(min-width: 1024px)");
44
+
45
+ if (isDesktop) {
46
+ return children;
47
+ }
48
+
49
+ return (
50
+ <ReactPullToRefresh onRefresh={onRefresh} isPullable={!isRefreshing}>
51
+ {children}
52
+ </ReactPullToRefresh>
53
+ );
54
+ };
@@ -0,0 +1,39 @@
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 { QuotedText } from "./quoted-text.js";
6
+
7
+ describe("QuotedText", () => {
8
+ it("renders nothing when there is no text or html", () => {
9
+ assert.equal(renderToString(createElement(QuotedText, { text: "" })), "");
10
+ });
11
+
12
+ it("shows the attribution and a collapse toggle by default", () => {
13
+ const html = renderToString(
14
+ createElement(QuotedText, {
15
+ text: "hello",
16
+ senderName: "Dana Whitfield",
17
+ date: "Jun 24, 2026",
18
+ }),
19
+ );
20
+ assert.match(html, /Dana Whitfield on Jun 24, 2026 wrote:/);
21
+ assert.match(html, /aria-expanded="false"/);
22
+ });
23
+
24
+ it("falls back to a generic label when there is no attribution", () => {
25
+ const html = renderToString(createElement(QuotedText, { text: "hello" }));
26
+ assert.match(html, /Show quoted text/);
27
+ });
28
+
29
+ it("keeps the quoted body collapsed initially", () => {
30
+ const html = renderToString(
31
+ createElement(QuotedText, {
32
+ text: "secret quoted line",
33
+ html: "<p>secret quoted html</p>",
34
+ senderName: "Dana",
35
+ }),
36
+ );
37
+ assert.doesNotMatch(html, /secret quoted/);
38
+ });
39
+ });
@@ -0,0 +1,67 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { QuotedText } from "./quoted-text.js";
3
+
4
+ const meta: Meta<typeof QuotedText> = {
5
+ title: "Mail/QuotedText",
6
+ component: QuotedText,
7
+ parameters: { layout: "padded" },
8
+ };
9
+ export default meta;
10
+
11
+ type Story = StoryObj<typeof QuotedText>;
12
+
13
+ const quotedHtml = `
14
+ <p>Thanks for the quick turnaround on the proposal.</p>
15
+ <p>A couple of follow-ups before Friday:</p>
16
+ <ul>
17
+ <li>Can we confirm the <strong>delivery window</strong>?</li>
18
+ <li>See the earlier thread for the pricing notes:</li>
19
+ </ul>
20
+ <blockquote><p>The original quote stands through end of quarter.</p></blockquote>
21
+ <p>More context on the <a href="https://example.com/brief">shared brief</a>.</p>
22
+ `;
23
+
24
+ const quotedPlainText = [
25
+ "Thanks for the quick turnaround on the proposal.",
26
+ "",
27
+ "A couple of follow-ups before Friday:",
28
+ "- Can we confirm the delivery window?",
29
+ "- See the earlier thread for the pricing notes.",
30
+ ].join("\n");
31
+
32
+ export const Collapsed: Story = {
33
+ args: {
34
+ text: quotedPlainText,
35
+ html: quotedHtml,
36
+ senderName: "Dana Whitfield",
37
+ date: "Jun 24, 2026, 9:14 AM",
38
+ },
39
+ };
40
+
41
+ export const ExpandedHtml: Story = {
42
+ args: {
43
+ text: quotedPlainText,
44
+ html: quotedHtml,
45
+ senderName: "Dana Whitfield",
46
+ date: "Jun 24, 2026, 9:14 AM",
47
+ },
48
+ play: async ({ canvasElement }) => {
49
+ canvasElement.querySelector("button")?.click();
50
+ },
51
+ };
52
+
53
+ export const PlainTextOnly: Story = {
54
+ args: {
55
+ text: quotedPlainText,
56
+ senderName: "Dana Whitfield",
57
+ },
58
+ play: async ({ canvasElement }) => {
59
+ canvasElement.querySelector("button")?.click();
60
+ },
61
+ };
62
+
63
+ export const NoAttribution: Story = {
64
+ args: {
65
+ text: quotedPlainText,
66
+ },
67
+ };
@@ -0,0 +1,67 @@
1
+ import { ChevronDown, ChevronRight } from "lucide-react";
2
+ import { useState } from "react";
3
+
4
+ export interface QuotedTextProps {
5
+ /** Plain-text quote, used when no `html` is supplied. */
6
+ text: string;
7
+ /**
8
+ * Sanitized quoted HTML. Must already be DOMPurify'd by the caller — this
9
+ * component renders it via `dangerouslySetInnerHTML` and never sanitizes.
10
+ * Never pass raw, untrusted HTML here.
11
+ */
12
+ html?: string;
13
+ senderName?: string;
14
+ date?: string;
15
+ }
16
+
17
+ /**
18
+ * Collapsible quoted-message block for reply/forward compose. Renders a muted
19
+ * attribution toggle ("<sender> on <date> wrote:") that expands to reveal the
20
+ * quoted body. Presentational and self-contained: collapse state is internal,
21
+ * sanitization is the caller's responsibility.
22
+ */
23
+ export const QuotedText = ({
24
+ text,
25
+ html,
26
+ senderName,
27
+ date,
28
+ }: QuotedTextProps) => {
29
+ const [isExpanded, setIsExpanded] = useState(false);
30
+
31
+ if (!text && !html) return null;
32
+
33
+ const attribution = [senderName, date].filter(Boolean).join(" on ");
34
+
35
+ return (
36
+ <div className="mt-2 px-3 pb-2">
37
+ <button
38
+ type="button"
39
+ onClick={() => setIsExpanded(!isExpanded)}
40
+ aria-expanded={isExpanded}
41
+ className="flex items-center gap-1 text-xs text-fg-muted hover:text-fg transition-colors"
42
+ >
43
+ {isExpanded ? (
44
+ <ChevronDown className="size-3" />
45
+ ) : (
46
+ <ChevronRight className="size-3" />
47
+ )}
48
+ {attribution ? `${attribution} wrote:` : "Show quoted text"}
49
+ </button>
50
+ {isExpanded && (
51
+ <blockquote className="mt-2 pl-3 border-l-2 border-fg-subtle/30 text-sm text-fg-muted [&_a]:text-accent [&_a]:underline [&_blockquote]:pl-3 [&_blockquote]:border-l-2 [&_blockquote]:border-fg-subtle/30">
52
+ {html ? (
53
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: rendering sanitized HTML via email-sanitizer
54
+ <div dangerouslySetInnerHTML={{ __html: html }} />
55
+ ) : (
56
+ text.split("\n").map((line, i) => (
57
+ // biome-ignore lint/suspicious/noArrayIndexKey: list is static, no stable id
58
+ <p key={i} className="min-h-[1.2em]">
59
+ {line}
60
+ </p>
61
+ ))
62
+ )}
63
+ </blockquote>
64
+ )}
65
+ </div>
66
+ );
67
+ };
@@ -0,0 +1,44 @@
1
+ import { Inbox } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { cn } from "../lib/cn.js";
4
+ import { Kbd } from "./kbd.js";
5
+
6
+ export interface ReadingPaneEmptyProps {
7
+ /** Headline. */
8
+ message?: string;
9
+ /** Override the centered icon. */
10
+ icon?: ReactNode;
11
+ /** Hide the keyboard hint line (e.g. on touch surfaces). */
12
+ showHints?: boolean;
13
+ className?: string;
14
+ }
15
+
16
+ /**
17
+ * The reading pane's zero-state: a centered Inbox icon, a prompt, and the
18
+ * j/k/Enter navigation hints. One source of truth for both the brief and the
19
+ * mailbox routes (they used to drift between a bare text EmptyState and the
20
+ * designed treatment).
21
+ */
22
+ export function ReadingPaneEmpty({
23
+ message = "Select a thread to read",
24
+ icon,
25
+ showHints = true,
26
+ className,
27
+ }: ReadingPaneEmptyProps) {
28
+ return (
29
+ <div
30
+ className={cn(
31
+ "flex flex-1 flex-col items-center justify-center text-center",
32
+ className,
33
+ )}
34
+ >
35
+ {icon ?? <Inbox className="size-10 text-fg-subtle" />}
36
+ <p className="mt-3 text-sm text-fg-muted">{message}</p>
37
+ {showHints && (
38
+ <p className="text-2xs text-fg-subtle">
39
+ <Kbd>j</Kbd> / <Kbd>k</Kbd> to move, <Kbd>Enter</Kbd> to open
40
+ </p>
41
+ )}
42
+ </div>
43
+ );
44
+ }