@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.
- package/package.json +50 -0
- package/src/components/address-display.render.test.ts +93 -0
- package/src/components/address-display.stories.tsx +55 -0
- package/src/components/address-display.tsx +132 -0
- package/src/components/address-tag.render.test.ts +39 -0
- package/src/components/address-tag.stories.tsx +41 -0
- package/src/components/address-tag.tsx +38 -0
- package/src/components/app-password-hint.render.test.ts +23 -0
- package/src/components/app-password-hint.stories.tsx +19 -0
- package/src/components/app-password-hint.tsx +24 -0
- package/src/components/app-shell-slotted.render.test.ts +128 -0
- package/src/components/app-shell-slotted.tsx +267 -0
- package/src/components/app-shell-types.ts +328 -0
- package/src/components/app-shell.render.test.ts +415 -0
- package/src/components/app-shell.tsx +277 -0
- package/src/components/auth-card.render.test.ts +24 -0
- package/src/components/auth-card.stories.tsx +50 -0
- package/src/components/auth-card.tsx +32 -0
- package/src/components/auth-footer.render.test.ts +17 -0
- package/src/components/auth-footer.stories.tsx +23 -0
- package/src/components/auth-footer.tsx +14 -0
- package/src/components/auth-hero.render.test.ts +31 -0
- package/src/components/auth-hero.stories.tsx +19 -0
- package/src/components/auth-hero.tsx +41 -0
- package/src/components/auto-moved-badge.render.test.ts +42 -0
- package/src/components/auto-moved-badge.stories.tsx +48 -0
- package/src/components/auto-moved-badge.tsx +57 -0
- package/src/components/avatar.tsx +60 -0
- package/src/components/badge.tsx +49 -0
- package/src/components/banner.render.test.ts +58 -0
- package/src/components/banner.stories.tsx +92 -0
- package/src/components/banner.tsx +81 -0
- package/src/components/bottom-sheet.render.test.ts +48 -0
- package/src/components/bottom-sheet.stories.tsx +71 -0
- package/src/components/bottom-sheet.tsx +146 -0
- package/src/components/brief-section.render.test.ts +98 -0
- package/src/components/brief-section.stories.tsx +68 -0
- package/src/components/brief-section.tsx +102 -0
- package/src/components/brief-sections.render.test.ts +72 -0
- package/src/components/brief-sections.stories.tsx +178 -0
- package/src/components/brief-sections.tsx +201 -0
- package/src/components/button.tsx +48 -0
- package/src/components/card.tsx +48 -0
- package/src/components/category-badge.render.test.ts +40 -0
- package/src/components/category-badge.stories.tsx +37 -0
- package/src/components/category-badge.tsx +71 -0
- package/src/components/checkbox.render.test.ts +65 -0
- package/src/components/checkbox.stories.tsx +77 -0
- package/src/components/checkbox.tsx +78 -0
- package/src/components/compose-action-bar.render.test.ts +47 -0
- package/src/components/compose-action-bar.stories.tsx +35 -0
- package/src/components/compose-action-bar.tsx +95 -0
- package/src/components/compose-form-shell.render.test.ts +54 -0
- package/src/components/compose-form-shell.stories.tsx +96 -0
- package/src/components/compose-form-shell.tsx +49 -0
- package/src/components/danger-zone-section.tsx +36 -0
- package/src/components/dialog.render.test.ts +55 -0
- package/src/components/dialog.tsx +104 -0
- package/src/components/email-frame-css.ts +137 -0
- package/src/components/field-label.render.test.ts +16 -0
- package/src/components/field-label.tsx +19 -0
- package/src/components/filter-sheet.render.test.ts +76 -0
- package/src/components/filter-sheet.stories.tsx +146 -0
- package/src/components/filter-sheet.tsx +316 -0
- package/src/components/folder-role.tsx +66 -0
- package/src/components/input.tsx +44 -0
- package/src/components/intelligence-panel.stories.tsx +149 -0
- package/src/components/intelligence-panel.tsx +546 -0
- package/src/components/isolated-email-frame.render.test.ts +147 -0
- package/src/components/isolated-email-frame.stories.tsx +192 -0
- package/src/components/isolated-email-frame.tsx +265 -0
- package/src/components/kbd.tsx +17 -0
- package/src/components/keyboard-hint-bar.tsx +58 -0
- package/src/components/list-item.tsx +39 -0
- package/src/components/mail-action-toolbar.render.test.ts +43 -0
- package/src/components/mail-action-toolbar.tsx +180 -0
- package/src/components/mail-header.render.test.ts +69 -0
- package/src/components/mail-header.stories.tsx +233 -0
- package/src/components/mail-header.tsx +123 -0
- package/src/components/message-body-view.render.test.ts +59 -0
- package/src/components/message-body-view.stories.tsx +146 -0
- package/src/components/message-body-view.tsx +150 -0
- package/src/components/message-header.render.test.ts +58 -0
- package/src/components/message-header.stories.tsx +79 -0
- package/src/components/message-header.tsx +59 -0
- package/src/components/message-list-pane.render.test.ts +136 -0
- package/src/components/message-list-pane.stories.tsx +246 -0
- package/src/components/message-list-pane.tsx +248 -0
- package/src/components/message-list-state.render.test.ts +72 -0
- package/src/components/message-list-state.tsx +97 -0
- package/src/components/message-row.render.test.ts +48 -0
- package/src/components/message-row.stories.tsx +121 -0
- package/src/components/message-row.tsx +231 -0
- package/src/components/mobile-message-action-bar.render.test.ts +87 -0
- package/src/components/mobile-message-action-bar.stories.tsx +57 -0
- package/src/components/mobile-message-action-bar.tsx +175 -0
- package/src/components/mobile-reading-pane.render.test.ts +102 -0
- package/src/components/mobile-reading-pane.tsx +188 -0
- package/src/components/mobile-search-view.stories.tsx +247 -0
- package/src/components/mobile-search-view.tsx +100 -0
- package/src/components/move-mailbox-picker.render.test.ts +113 -0
- package/src/components/move-mailbox-picker.stories.tsx +76 -0
- package/src/components/move-mailbox-picker.tsx +324 -0
- package/src/components/nav-sidebar.render.test.ts +351 -0
- package/src/components/nav-sidebar.stories.tsx +216 -0
- package/src/components/nav-sidebar.tsx +625 -0
- package/src/components/outbox-row.render.test.ts +61 -0
- package/src/components/outbox-row.stories.tsx +53 -0
- package/src/components/outbox-row.tsx +120 -0
- package/src/components/outbox-status-badge.tsx +60 -0
- package/src/components/pane-layout.render.test.ts +105 -0
- package/src/components/popover-menu.render.test.ts +32 -0
- package/src/components/popover-menu.stories.tsx +57 -0
- package/src/components/popover-menu.tsx +110 -0
- package/src/components/primitives.stories.tsx +173 -0
- package/src/components/pull-to-refresh.render.test.ts +46 -0
- package/src/components/pull-to-refresh.stories.tsx +71 -0
- package/src/components/pull-to-refresh.tsx +54 -0
- package/src/components/quoted-text.render.test.ts +39 -0
- package/src/components/quoted-text.stories.tsx +67 -0
- package/src/components/quoted-text.tsx +67 -0
- package/src/components/reading-pane-empty.tsx +44 -0
- package/src/components/reading-pane.render.test.ts +148 -0
- package/src/components/reading-pane.stories.tsx +194 -0
- package/src/components/reading-pane.tsx +351 -0
- package/src/components/rescue-banner.render.test.ts +32 -0
- package/src/components/rescue-banner.stories.tsx +31 -0
- package/src/components/rescue-banner.tsx +50 -0
- package/src/components/rescue-candidate-row.render.test.ts +68 -0
- package/src/components/rescue-candidate-row.stories.tsx +108 -0
- package/src/components/rescue-candidate-row.tsx +94 -0
- package/src/components/rescue-from-spam-flow.render.test.ts +100 -0
- package/src/components/rescue-from-spam-flow.stories.tsx +92 -0
- package/src/components/rescue-from-spam-flow.tsx +342 -0
- package/src/components/resizable.tsx +55 -0
- package/src/components/role-appointment-list.render.test.ts +97 -0
- package/src/components/role-appointment-list.stories.tsx +138 -0
- package/src/components/role-appointment-list.tsx +227 -0
- package/src/components/row-actions.render.test.ts +83 -0
- package/src/components/row-actions.stories.tsx +87 -0
- package/src/components/row-actions.tsx +139 -0
- package/src/components/search-bar.render.test.ts +55 -0
- package/src/components/search-bar.stories.tsx +46 -0
- package/src/components/search-bar.tsx +118 -0
- package/src/components/search-result-row.tsx +139 -0
- package/src/components/search-results.render.test.ts +89 -0
- package/src/components/search-results.stories.tsx +126 -0
- package/src/components/search-results.tsx +215 -0
- package/src/components/search-token-chip.render.test.ts +42 -0
- package/src/components/search-token-chip.stories.tsx +42 -0
- package/src/components/search-token-chip.tsx +66 -0
- package/src/components/security-select.render.test.ts +33 -0
- package/src/components/security-select.tsx +53 -0
- package/src/components/segmented-control.tsx +70 -0
- package/src/components/select.tsx +33 -0
- package/src/components/selection-top-bar.render.test.ts +67 -0
- package/src/components/selection-top-bar.stories.tsx +39 -0
- package/src/components/selection-top-bar.tsx +95 -0
- package/src/components/sender-group-switch.tsx +63 -0
- package/src/components/sender-trust-indicator.render.test.ts +67 -0
- package/src/components/sender-trust-indicator.stories.tsx +58 -0
- package/src/components/sender-trust-indicator.tsx +87 -0
- package/src/components/settings-primitives.render.test.ts +160 -0
- package/src/components/settings-primitives.stories.tsx +80 -0
- package/src/components/settings-screen.tsx +354 -0
- package/src/components/swipeable-row.render.test.ts +106 -0
- package/src/components/swipeable-row.stories.tsx +117 -0
- package/src/components/swipeable-row.tsx +268 -0
- package/src/components/touch-list-body.render.test.ts +66 -0
- package/src/components/touch-list-body.stories.tsx +76 -0
- package/src/components/touch-list.tsx +87 -0
- package/src/components/wizard.render.test.ts +49 -0
- package/src/components/wizard.stories.tsx +113 -0
- package/src/components/wizard.tsx +321 -0
- package/src/filter-presets.test.ts +93 -0
- package/src/filter-presets.ts +101 -0
- package/src/index.ts +378 -0
- package/src/lib/cid-resolver.ts +35 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/email-layout-clamp.test.ts +109 -0
- package/src/lib/email-layout-clamp.ts +64 -0
- package/src/lib/email-render-treatment.test.ts +82 -0
- package/src/lib/email-render-treatment.ts +53 -0
- package/src/lib/email-sanitizer.test.ts +362 -0
- package/src/lib/email-sanitizer.ts +298 -0
- package/src/lib/row-keyboard.test.ts +41 -0
- package/src/lib/row-keyboard.ts +20 -0
- 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
|
+
};
|