@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,268 @@
1
+ import { Check, Mail, MailOpen, Trash2 } from "lucide-react";
2
+ import { useRef, useState } from "react";
3
+ import { cn } from "../lib/cn.js";
4
+ import type { ThreadRowData } from "./app-shell-types.js";
5
+ import {
6
+ ComfortableRowBody,
7
+ ComfortableRowTextContent,
8
+ comfortableRowClass,
9
+ } from "./message-row.js";
10
+
11
+ export type SwipePeek = "none" | "leading" | "trailing";
12
+
13
+ /** Width a peeked row settles at to reveal its action; also the drag distance
14
+ * past which a release commits the peek rather than snapping back. */
15
+ const SWIPE_ACTION_WIDTH = 72;
16
+ /** Movement (px) before a pointer drag claims the horizontal (swipe) axis; below
17
+ * this a press is still a tap / long-press and vertical scroll wins. */
18
+ const SWIPE_AXIS_THRESHOLD = 10;
19
+
20
+ function peekOffset(peek: SwipePeek): number {
21
+ if (peek === "leading") return SWIPE_ACTION_WIDTH;
22
+ if (peek === "trailing") return -SWIPE_ACTION_WIDTH;
23
+ return 0;
24
+ }
25
+
26
+ /** Which peek a released drag commits to: past half the action width on a side
27
+ * settles open to that side, otherwise it snaps back. Pure, so the
28
+ * drag-release rule is unit-testable without a DOM. */
29
+ export function commitPeek(offset: number): SwipePeek {
30
+ if (offset >= SWIPE_ACTION_WIDTH / 2) return "leading";
31
+ if (offset <= -SWIPE_ACTION_WIDTH / 2) return "trailing";
32
+ return "none";
33
+ }
34
+
35
+ /**
36
+ * Props the row's interactive (open) element must receive — the swipe gesture
37
+ * handlers, the transform/transition style, the row body, plus an onClick that
38
+ * suppresses navigation when the row is peeked. A consumer passes `linkComponent`
39
+ * to render these on a real anchor (e.g. a router Link) so the open affordance is
40
+ * a true `<a href>` — keeping open-in-new-tab, middle-click, deep-link and a11y
41
+ * — instead of the default JS-only `<button onOpen>`.
42
+ */
43
+ export interface SwipeableRowOpenProps {
44
+ className: string;
45
+ style: React.CSSProperties;
46
+ onPointerDown: (e: React.PointerEvent) => void;
47
+ onPointerMove: (e: React.PointerEvent) => void;
48
+ onPointerUp: () => void;
49
+ onPointerCancel: () => void;
50
+ /** Wire to the anchor's onClick. When the row is peeked it calls
51
+ * preventDefault so a tap closes the peek instead of navigating; otherwise
52
+ * it is a no-op and the anchor's native navigation proceeds. */
53
+ onOpenClick: (e: { preventDefault: () => void }) => void;
54
+ children: React.ReactNode;
55
+ }
56
+
57
+ export function SwipeableRow({
58
+ thread,
59
+ selectionMode,
60
+ checked,
61
+ active,
62
+ peek,
63
+ onPeek,
64
+ onToggleCheck,
65
+ onLongPress,
66
+ onOpen,
67
+ onAct,
68
+ linkComponent,
69
+ }: {
70
+ thread: ThreadRowData;
71
+ selectionMode: boolean;
72
+ checked: boolean;
73
+ active: boolean;
74
+ peek: SwipePeek;
75
+ onPeek: (next: SwipePeek) => void;
76
+ onToggleCheck: () => void;
77
+ onLongPress: () => void;
78
+ onOpen: () => void;
79
+ /** Tapping a revealed action performs it: "leading" = toggle read,
80
+ * "trailing" = delete. */
81
+ onAct: (side: "leading" | "trailing") => void;
82
+ /** Render the open affordance as a real anchor (router Link / `<a href>`)
83
+ * instead of the default `<button onOpen>`. Receives the gesture handlers,
84
+ * style, peek-aware onClickCapture and row body to spread onto the anchor.
85
+ * When provided, a plain tap navigates via the anchor's native click (so
86
+ * deep-link/middle-click work); onOpen is not called for the tap. */
87
+ linkComponent?: (props: SwipeableRowOpenProps) => React.ReactNode;
88
+ }) {
89
+ const pressTimer = useRef<ReturnType<typeof setTimeout> | undefined>(
90
+ undefined,
91
+ );
92
+ const gesture = useRef<{
93
+ startX: number;
94
+ startY: number;
95
+ axis: "none" | "horizontal" | "vertical";
96
+ moved: boolean;
97
+ } | null>(null);
98
+ const [dragX, setDragX] = useState<number | null>(null);
99
+
100
+ const cancelLongPress = () => clearTimeout(pressTimer.current);
101
+
102
+ const onPointerDown = (e: React.PointerEvent) => {
103
+ gesture.current = {
104
+ startX: e.clientX,
105
+ startY: e.clientY,
106
+ axis: "none",
107
+ moved: false,
108
+ };
109
+ // selection mode is tap-to-toggle only — no long-press, no swipe drag
110
+ if (selectionMode) return;
111
+ pressTimer.current = setTimeout(() => {
112
+ gesture.current = null;
113
+ setDragX(null);
114
+ onLongPress();
115
+ }, 500);
116
+ };
117
+
118
+ const onPointerMove = (e: React.PointerEvent) => {
119
+ if (selectionMode) return;
120
+ const g = gesture.current;
121
+ if (!g) return;
122
+ const dx = e.clientX - g.startX;
123
+ const dy = e.clientY - g.startY;
124
+ if (g.axis === "none") {
125
+ if (Math.abs(dy) > SWIPE_AXIS_THRESHOLD && Math.abs(dy) > Math.abs(dx)) {
126
+ // vertical scroll wins: abandon the swipe + long-press, let the list scroll
127
+ g.axis = "vertical";
128
+ cancelLongPress();
129
+ gesture.current = null;
130
+ return;
131
+ }
132
+ if (Math.abs(dx) > SWIPE_AXIS_THRESHOLD) {
133
+ g.axis = "horizontal";
134
+ g.moved = true;
135
+ cancelLongPress();
136
+ e.currentTarget.setPointerCapture(e.pointerId);
137
+ }
138
+ }
139
+ if (g.axis !== "horizontal") return;
140
+ const base = peekOffset(peek);
141
+ const next = Math.max(
142
+ -SWIPE_ACTION_WIDTH,
143
+ Math.min(SWIPE_ACTION_WIDTH, base + dx),
144
+ );
145
+ setDragX(next);
146
+ };
147
+
148
+ const onPointerUp = () => {
149
+ cancelLongPress();
150
+ const g = gesture.current;
151
+ gesture.current = null;
152
+ const offset = dragX;
153
+ setDragX(null);
154
+ // g is null when the gesture was already consumed (long-press fired, or
155
+ // a vertical scroll took over) — those handle themselves, so do nothing.
156
+ if (!g) return;
157
+ if (g.axis === "horizontal" && offset !== null) {
158
+ onPeek(commitPeek(offset));
159
+ return;
160
+ }
161
+ // a tap: no axis claimed
162
+ if (selectionMode) {
163
+ onToggleCheck();
164
+ return;
165
+ }
166
+ if (peek !== "none") {
167
+ onPeek("none");
168
+ return;
169
+ }
170
+ // A real anchor handles the open via its native click — don't double-fire.
171
+ if (linkComponent) return;
172
+ onOpen();
173
+ };
174
+
175
+ // A tap on a peeked anchor must close the peek, not navigate; suppress the
176
+ // native click in that case. onPointerUp already snapped it closed.
177
+ const onOpenClick = (e: { preventDefault: () => void }) => {
178
+ if (peek !== "none") e.preventDefault();
179
+ };
180
+
181
+ const offset = dragX ?? peekOffset(peek);
182
+ const revealed: SwipePeek =
183
+ offset > 0 ? "leading" : offset < 0 ? "trailing" : "none";
184
+
185
+ const interactiveClassName = cn(
186
+ // opaque bg so the row occludes the action behind it until peeked
187
+ "relative touch-pan-y bg-surface",
188
+ comfortableRowClass({ active: checked || active }),
189
+ );
190
+ const interactiveStyle: React.CSSProperties = {
191
+ transform: offset !== 0 ? `translateX(${offset}px)` : undefined,
192
+ transition: dragX === null ? "transform 150ms ease" : "none",
193
+ minHeight: 44,
194
+ };
195
+ const body = selectionMode ? (
196
+ <>
197
+ <span
198
+ className={cn(
199
+ "inline-flex size-7 shrink-0 items-center justify-center rounded-full border",
200
+ checked
201
+ ? "border-accent bg-accent text-accent-fg"
202
+ : "border-line-strong bg-canvas",
203
+ )}
204
+ >
205
+ {checked && <Check className="size-3.5" />}
206
+ </span>
207
+ <ComfortableRowTextContent thread={thread} />
208
+ </>
209
+ ) : (
210
+ <ComfortableRowBody thread={thread} />
211
+ );
212
+
213
+ return (
214
+ <div className="relative overflow-hidden">
215
+ {revealed === "leading" && (
216
+ <button
217
+ type="button"
218
+ onClick={() => onAct("leading")}
219
+ aria-label={thread.isRead ? "Mark as unread" : "Mark as read"}
220
+ className="absolute inset-y-0 left-0 flex items-center justify-start bg-accent-2 px-6 text-accent-fg"
221
+ >
222
+ {thread.isRead ? (
223
+ <Mail className="size-6" />
224
+ ) : (
225
+ <MailOpen className="size-6" />
226
+ )}
227
+ </button>
228
+ )}
229
+ {revealed === "trailing" && (
230
+ <button
231
+ type="button"
232
+ onClick={() => onAct("trailing")}
233
+ aria-label="Delete message"
234
+ className="absolute inset-y-0 right-0 flex items-center justify-end bg-danger px-6 text-canvas"
235
+ >
236
+ <Trash2 className="size-6" />
237
+ </button>
238
+ )}
239
+
240
+ {/* In selection mode the row is tap-to-toggle, never a navigation link —
241
+ keep the button so a checkbox tap can't open a thread. */}
242
+ {linkComponent && !selectionMode ? (
243
+ linkComponent({
244
+ className: interactiveClassName,
245
+ style: interactiveStyle,
246
+ onPointerDown,
247
+ onPointerMove,
248
+ onPointerUp,
249
+ onPointerCancel: onPointerUp,
250
+ onOpenClick,
251
+ children: body,
252
+ })
253
+ ) : (
254
+ <button
255
+ type="button"
256
+ onPointerDown={onPointerDown}
257
+ onPointerMove={onPointerMove}
258
+ onPointerUp={onPointerUp}
259
+ onPointerCancel={onPointerUp}
260
+ className={interactiveClassName}
261
+ style={interactiveStyle}
262
+ >
263
+ {body}
264
+ </button>
265
+ )}
266
+ </div>
267
+ );
268
+ }
@@ -0,0 +1,66 @@
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 { ThreadSection } from "./app-shell-types.js";
6
+ import { TouchListBody } from "./touch-list.js";
7
+
8
+ const sections: ThreadSection[] = [
9
+ {
10
+ id: "today",
11
+ label: "Today",
12
+ threads: [
13
+ {
14
+ id: "t1",
15
+ accountId: "a1",
16
+ fromName: "Priya Nair",
17
+ fromEmail: "priya@example.com",
18
+ subject: "Design review tomorrow",
19
+ snippet: "Can we move it to 2pm?",
20
+ timeLabel: "8:15",
21
+ isRead: false,
22
+ },
23
+ {
24
+ id: "t2",
25
+ accountId: "a1",
26
+ fromName: "Alex Rivera",
27
+ fromEmail: "alex@example.com",
28
+ subject: "Q3 planning notes",
29
+ snippet: "Pushed the deck.",
30
+ timeLabel: "9:42",
31
+ isRead: true,
32
+ },
33
+ ],
34
+ },
35
+ ];
36
+
37
+ const baseProps = {
38
+ sections,
39
+ selectionMode: false,
40
+ checkedIds: new Set<string>(),
41
+ refreshing: false,
42
+ onToggleCheck: () => undefined,
43
+ onEnterSelection: () => undefined,
44
+ onOpenThread: () => undefined,
45
+ onRefresh: () => undefined,
46
+ };
47
+
48
+ describe("TouchListBody", () => {
49
+ it("renders the rows", () => {
50
+ const html = renderToString(createElement(TouchListBody, baseProps));
51
+ assert.match(html, /Priya Nair/);
52
+ assert.match(html, /Alex Rivera/);
53
+ });
54
+
55
+ it("shows the refreshing affordance when refreshing", () => {
56
+ const html = renderToString(
57
+ createElement(TouchListBody, { ...baseProps, refreshing: true }),
58
+ );
59
+ assert.match(html, /Checking for new mail/);
60
+ });
61
+
62
+ it("shows pull to refresh when idle and not in selection mode", () => {
63
+ const html = renderToString(createElement(TouchListBody, baseProps));
64
+ assert.match(html, /Pull to refresh/);
65
+ });
66
+ });
@@ -0,0 +1,76 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import type { ThreadSection } from "./app-shell-types.js";
3
+ import { TouchListBody } from "./touch-list.js";
4
+
5
+ const sections: ThreadSection[] = [
6
+ {
7
+ id: "today",
8
+ label: "Today",
9
+ threads: [
10
+ {
11
+ id: "t1",
12
+ accountId: "a1",
13
+ fromName: "Priya Nair",
14
+ fromEmail: "priya@example.com",
15
+ subject: "Design review tomorrow",
16
+ snippet: "Can we move it to 2pm? I have a conflict in the morning.",
17
+ timeLabel: "8:15",
18
+ isRead: false,
19
+ messageCount: 3,
20
+ },
21
+ {
22
+ id: "t2",
23
+ accountId: "a1",
24
+ fromName: "Alex Rivera",
25
+ fromEmail: "alex@example.com",
26
+ subject: "Re: Q3 planning notes",
27
+ snippet: "Sounds good — pushed the deck to the shared drive.",
28
+ timeLabel: "9:42",
29
+ isRead: true,
30
+ },
31
+ {
32
+ id: "t3",
33
+ accountId: "a1",
34
+ fromName: "Dana Lopez",
35
+ fromEmail: "dana@example.com",
36
+ subject: "Invoice for May",
37
+ snippet: "Please find the attached invoice, due end of month.",
38
+ timeLabel: "Wed",
39
+ isRead: true,
40
+ hasAttachment: true,
41
+ },
42
+ ],
43
+ },
44
+ ];
45
+
46
+ const meta: Meta<typeof TouchListBody> = {
47
+ title: "Screens/Kit/TouchListBody",
48
+ component: TouchListBody,
49
+ parameters: { layout: "padded" },
50
+ args: {
51
+ sections,
52
+ selectionMode: false,
53
+ checkedIds: new Set<string>(),
54
+ refreshing: false,
55
+ onToggleCheck: () => undefined,
56
+ onEnterSelection: () => undefined,
57
+ onOpenThread: () => undefined,
58
+ onRefresh: () => undefined,
59
+ },
60
+ render: (args) => (
61
+ <div className="flex h-[700px] w-[390px] flex-col rounded-md border border-line">
62
+ <TouchListBody {...args} />
63
+ </div>
64
+ ),
65
+ };
66
+ export default meta;
67
+
68
+ type Story = StoryObj<typeof TouchListBody>;
69
+
70
+ export const Default: Story = {};
71
+
72
+ export const Refreshing: Story = { args: { refreshing: true } };
73
+
74
+ export const SelectionMode: Story = {
75
+ args: { selectionMode: true, checkedIds: new Set(["t1", "t3"]) },
76
+ };
@@ -0,0 +1,87 @@
1
+ import { RefreshCw } from "lucide-react";
2
+ import { useState } from "react";
3
+ import type { ThreadSection } from "./app-shell-types.js";
4
+ import { SwipeableRow, type SwipePeek } from "./swipeable-row.js";
5
+
6
+ export function TouchListBody({
7
+ sections,
8
+ selectedThreadId,
9
+ selectionMode,
10
+ checkedIds,
11
+ initialPeek,
12
+ onToggleCheck,
13
+ onEnterSelection,
14
+ onOpenThread,
15
+ onRefresh,
16
+ refreshing,
17
+ }: {
18
+ sections: ThreadSection[];
19
+ selectedThreadId?: string;
20
+ selectionMode: boolean;
21
+ checkedIds: ReadonlySet<string>;
22
+ initialPeek?: SwipePeek;
23
+ onToggleCheck: (id: string) => void;
24
+ onEnterSelection: (id: string) => void;
25
+ onOpenThread: (id: string) => void;
26
+ onRefresh: () => void;
27
+ refreshing: boolean;
28
+ }) {
29
+ // Local copy so the mock can act on a swipe: delete removes the row,
30
+ // toggle-read flips its state. The live client owns real mutation.
31
+ const [items, setItems] = useState(() =>
32
+ sections.flatMap((section) => section.threads),
33
+ );
34
+ const [peek, setPeek] = useState<{ id: string; side: SwipePeek } | null>(
35
+ initialPeek && initialPeek !== "none" && items[1]
36
+ ? { id: items[1].id, side: initialPeek }
37
+ : null,
38
+ );
39
+ const act = (id: string, side: "leading" | "trailing") => {
40
+ if (side === "trailing") {
41
+ setItems((prev) => prev.filter((t) => t.id !== id));
42
+ } else {
43
+ setItems((prev) =>
44
+ prev.map((t) => (t.id === id ? { ...t, isRead: !t.isRead } : t)),
45
+ );
46
+ }
47
+ setPeek(null);
48
+ };
49
+ return (
50
+ <div className="flex-1 overflow-y-auto">
51
+ {refreshing && (
52
+ <div className="flex items-center justify-center gap-2 border-b border-line py-3 text-fg-muted">
53
+ <RefreshCw className="size-4 animate-spin" />
54
+ <span className="text-xs">Checking for new mail…</span>
55
+ </div>
56
+ )}
57
+ <div className="divide-y divide-line">
58
+ {items.map((thread) => (
59
+ <SwipeableRow
60
+ key={thread.id}
61
+ thread={thread}
62
+ selectionMode={selectionMode}
63
+ checked={checkedIds.has(thread.id)}
64
+ active={thread.id === selectedThreadId}
65
+ peek={peek?.id === thread.id ? peek.side : "none"}
66
+ onPeek={(next) =>
67
+ setPeek(next === "none" ? null : { id: thread.id, side: next })
68
+ }
69
+ onToggleCheck={() => onToggleCheck(thread.id)}
70
+ onLongPress={() => onEnterSelection(thread.id)}
71
+ onOpen={() => onOpenThread(thread.id)}
72
+ onAct={(side) => act(thread.id, side)}
73
+ />
74
+ ))}
75
+ </div>
76
+ {!selectionMode && !refreshing && (
77
+ <button
78
+ type="button"
79
+ onClick={onRefresh}
80
+ className="w-full border-t border-line py-2 text-center text-2xs text-fg-subtle transition-colors hover:bg-surface-sunken"
81
+ >
82
+ Pull to refresh
83
+ </button>
84
+ )}
85
+ </div>
86
+ );
87
+ }
@@ -0,0 +1,49 @@
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 { ConnectorTile } from "./wizard.js";
6
+
7
+ // Never-disable tenet (#798): a "coming soon" connector must stay pressable.
8
+ describe("ConnectorTile coming-soon", () => {
9
+ const markup = (props: Parameters<typeof ConnectorTile>[0]) =>
10
+ renderToString(createElement(ConnectorTile, props));
11
+
12
+ it("renders a comingSoon tile that is not disabled", () => {
13
+ const html = markup({
14
+ name: "Gmail",
15
+ description: "Sign in with Google.",
16
+ icon: null,
17
+ comingSoon: true,
18
+ });
19
+ assert.doesNotMatch(html, /\sdisabled[\s=>]/, "tile must not be disabled");
20
+ assert.match(
21
+ html,
22
+ /aria-disabled="true"/,
23
+ "tile signals coming-soon via aria-disabled, not a dead control",
24
+ );
25
+ assert.match(html, /<button/, "tile is a pressable button");
26
+ });
27
+
28
+ it("renders a comingSoon tile without a handler as a safe no-op", () => {
29
+ assert.doesNotThrow(() =>
30
+ markup({
31
+ name: "Gmail",
32
+ description: "Sign in with Google.",
33
+ icon: null,
34
+ comingSoon: true,
35
+ }),
36
+ );
37
+ });
38
+
39
+ it("renders a normal tile without coming-soon affordances", () => {
40
+ const html = markup({
41
+ name: "IMAP / SMTP",
42
+ description: "Any provider.",
43
+ icon: null,
44
+ onSelect: () => undefined,
45
+ });
46
+ assert.doesNotMatch(html, /aria-disabled/);
47
+ assert.doesNotMatch(html, />soon</);
48
+ });
49
+ });
@@ -0,0 +1,113 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { AtSign, Inbox, Server } from "lucide-react";
3
+ import { useState } from "react";
4
+ import type { ServerSecurity } from "./security-select.js";
5
+ import { ConnectorTile, ServerFields } from "./wizard.js";
6
+
7
+ const meta: Meta = {
8
+ title: "Components/Wizard",
9
+ };
10
+ export default meta;
11
+
12
+ type Story = StoryObj;
13
+
14
+ function TileRow({ children }: { children: React.ReactNode }) {
15
+ return (
16
+ <div className="grid max-w-xl grid-cols-1 gap-3 p-6 sm:grid-cols-3">
17
+ {children}
18
+ </div>
19
+ );
20
+ }
21
+
22
+ /** Selectable connectors: one active, one selected, one "soon". */
23
+ export const Connectors: Story = {
24
+ render: () => {
25
+ const [selected, setSelected] = useState("imap");
26
+ return (
27
+ <TileRow>
28
+ <ConnectorTile
29
+ name="IMAP / SMTP"
30
+ description="Any mail provider — Fastmail, iCloud, your own server."
31
+ icon={<Server className="size-5" />}
32
+ selected={selected === "imap"}
33
+ onSelect={() => setSelected("imap")}
34
+ />
35
+ <ConnectorTile
36
+ name="Outlook / Microsoft 365"
37
+ description="Sign in with Microsoft. Works with Outlook.com and work accounts."
38
+ icon={<Inbox className="size-5" />}
39
+ selected={selected === "microsoft"}
40
+ onSelect={() => setSelected("microsoft")}
41
+ />
42
+ <ConnectorTile
43
+ name="Gmail"
44
+ description="Sign in with Google. No app passwords."
45
+ icon={<AtSign className="size-5" />}
46
+ comingSoon
47
+ />
48
+ </TileRow>
49
+ );
50
+ },
51
+ };
52
+
53
+ /**
54
+ * A "soon" tile is muted but never disabled: pressing it surfaces a one-line
55
+ * explainer instead of going dead (never-disable tenet, #798).
56
+ */
57
+ export const ComingSoonTilePressable: Story = {
58
+ render: () => (
59
+ <TileRow>
60
+ <ConnectorTile
61
+ name="Gmail"
62
+ description="Sign in with Google. No app passwords."
63
+ icon={<AtSign className="size-5" />}
64
+ comingSoon
65
+ />
66
+ </TileRow>
67
+ ),
68
+ };
69
+
70
+ function ServerFieldsDemo() {
71
+ const [host, setHost] = useState("imap.fastmail.example");
72
+ const [port, setPort] = useState("993");
73
+ const [security, setSecurity] = useState<ServerSecurity>("tls");
74
+ return (
75
+ <div className="max-w-xl p-6">
76
+ <ServerFields
77
+ legend="IMAP — incoming"
78
+ badge={{ label: "detected", tone: "positive" }}
79
+ host={host}
80
+ port={port}
81
+ security={security}
82
+ onHostChange={setHost}
83
+ onPortChange={setPort}
84
+ onSecurityChange={setSecurity}
85
+ hostPlaceholder="imap.example.com"
86
+ portPlaceholder="993"
87
+ />
88
+ </div>
89
+ );
90
+ }
91
+
92
+ /** Host / port / security for one protocol. */
93
+ export const ServerFieldsStory: Story = {
94
+ name: "ServerFields",
95
+ render: () => <ServerFieldsDemo />,
96
+ };
97
+
98
+ /** Phone width: the grid stacks so the Security select stays reachable (#780). */
99
+ export const ServerFieldsPhone: Story = {
100
+ name: "ServerFields — phone",
101
+ parameters: {
102
+ viewport: {
103
+ options: {
104
+ phone390: {
105
+ name: "Phone 390",
106
+ styles: { width: "390px", height: "844px" },
107
+ },
108
+ },
109
+ defaultViewport: "phone390",
110
+ },
111
+ },
112
+ render: () => <ServerFieldsDemo />,
113
+ };