@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,354 @@
|
|
|
1
|
+
import { ChevronLeft, CircleHelp, Menu, X } from "lucide-react";
|
|
2
|
+
import { type ReactNode, useState } from "react";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { Avatar } from "./avatar.js";
|
|
5
|
+
import { Badge } from "./badge.js";
|
|
6
|
+
import { Button } from "./button.js";
|
|
7
|
+
|
|
8
|
+
/* ------------------------------------------------------------------ */
|
|
9
|
+
/* Settings shell: shallow IA (Accounts / Senders & Rules / */
|
|
10
|
+
/* Appearance / Advanced) with the same density discipline as mail. */
|
|
11
|
+
/* Content is left-aligned against the nav (whitespace goes right). */
|
|
12
|
+
/* The optional help rail mirrors the app IA: right rail = context */
|
|
13
|
+
/* (intelligence in mail, tips & tricks in settings). All pane */
|
|
14
|
+
/* headers sit on the shared pane-header datum. */
|
|
15
|
+
/* */
|
|
16
|
+
/* Below desktop (lg) the rigid three columns collapse: the nav rail */
|
|
17
|
+
/* becomes a drawer reached from a header menu button, the active */
|
|
18
|
+
/* pane owns the full width, and the tips rail folds into a header */
|
|
19
|
+
/* disclosure so it never crowds the pane the user came for. */
|
|
20
|
+
/* ------------------------------------------------------------------ */
|
|
21
|
+
|
|
22
|
+
export interface SettingsNavItem {
|
|
23
|
+
id: string;
|
|
24
|
+
label: string;
|
|
25
|
+
icon?: ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SettingsShellProps {
|
|
29
|
+
items: SettingsNavItem[];
|
|
30
|
+
activeId: string;
|
|
31
|
+
title: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
children: ReactNode;
|
|
34
|
+
/** Contextual tips & tricks rail content; quiet, collapsible. */
|
|
35
|
+
help?: ReactNode;
|
|
36
|
+
/** Help rail visible on desktop (defaults to true when help is provided). */
|
|
37
|
+
helpOpen?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Flush pages (e.g. the senders table) own the full content area and
|
|
40
|
+
* its scrolling; non-flush pages get a padded, left-aligned column.
|
|
41
|
+
*/
|
|
42
|
+
flush?: boolean;
|
|
43
|
+
onSelect?: (id: string) => void;
|
|
44
|
+
onToggleHelp?: () => void;
|
|
45
|
+
/** Navigate back to the mail area (the "Back to mail" header button). */
|
|
46
|
+
onBackToMail?: () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function SettingsShell({
|
|
50
|
+
items,
|
|
51
|
+
activeId,
|
|
52
|
+
title,
|
|
53
|
+
description,
|
|
54
|
+
children,
|
|
55
|
+
help,
|
|
56
|
+
helpOpen = true,
|
|
57
|
+
flush,
|
|
58
|
+
onSelect,
|
|
59
|
+
onToggleHelp,
|
|
60
|
+
onBackToMail,
|
|
61
|
+
}: SettingsShellProps) {
|
|
62
|
+
const [navOpen, setNavOpen] = useState(false);
|
|
63
|
+
const [mobileHelpOpen, setMobileHelpOpen] = useState(false);
|
|
64
|
+
const showHelp = Boolean(help) && helpOpen;
|
|
65
|
+
|
|
66
|
+
const nav = (
|
|
67
|
+
<>
|
|
68
|
+
<div className="px-3 pt-3 pb-1 text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
|
|
69
|
+
Settings
|
|
70
|
+
</div>
|
|
71
|
+
<nav className="flex-1 overflow-y-auto px-2 py-1">
|
|
72
|
+
{items.map((item) => (
|
|
73
|
+
<button
|
|
74
|
+
key={item.id}
|
|
75
|
+
type="button"
|
|
76
|
+
onClick={() => {
|
|
77
|
+
onSelect?.(item.id);
|
|
78
|
+
setNavOpen(false);
|
|
79
|
+
}}
|
|
80
|
+
className={cn(
|
|
81
|
+
"flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors lg:min-h-0",
|
|
82
|
+
item.id === activeId
|
|
83
|
+
? "bg-accent-2-soft font-medium text-accent-2"
|
|
84
|
+
: "text-fg-muted hover:bg-surface hover:text-fg",
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{item.icon && (
|
|
88
|
+
<span
|
|
89
|
+
className={cn(
|
|
90
|
+
"shrink-0",
|
|
91
|
+
item.id === activeId ? "text-accent-2" : "text-fg-subtle",
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
{item.icon}
|
|
95
|
+
</span>
|
|
96
|
+
)}
|
|
97
|
+
{item.label}
|
|
98
|
+
</button>
|
|
99
|
+
))}
|
|
100
|
+
</nav>
|
|
101
|
+
</>
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className="flex h-dvh w-full overflow-hidden bg-canvas font-sans text-fg">
|
|
106
|
+
{/* Desktop nav rail */}
|
|
107
|
+
<aside className="hidden w-60 shrink-0 flex-col border-r border-line bg-surface-sunken lg:flex">
|
|
108
|
+
<div className="flex h-pane-header shrink-0 items-center border-b border-line px-2">
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
onClick={onBackToMail}
|
|
112
|
+
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm text-fg-muted hover:bg-surface hover:text-fg"
|
|
113
|
+
>
|
|
114
|
+
<ChevronLeft className="size-4 text-fg-subtle" />
|
|
115
|
+
Back to mail
|
|
116
|
+
</button>
|
|
117
|
+
</div>
|
|
118
|
+
{nav}
|
|
119
|
+
</aside>
|
|
120
|
+
|
|
121
|
+
{/* Below-desktop nav drawer */}
|
|
122
|
+
{navOpen && (
|
|
123
|
+
<div className="fixed inset-0 z-50 flex lg:hidden">
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
aria-label="Close menu"
|
|
127
|
+
className="absolute inset-0 bg-canvas/60"
|
|
128
|
+
onClick={() => setNavOpen(false)}
|
|
129
|
+
/>
|
|
130
|
+
<aside className="relative flex w-72 max-w-[85%] flex-col border-r border-line bg-surface-sunken">
|
|
131
|
+
<div className="flex h-pane-header shrink-0 items-center justify-between border-b border-line px-2">
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
onClick={() => {
|
|
135
|
+
onBackToMail?.();
|
|
136
|
+
setNavOpen(false);
|
|
137
|
+
}}
|
|
138
|
+
className="flex items-center gap-2 rounded-md px-2 py-1 text-left text-sm text-fg-muted hover:bg-surface hover:text-fg"
|
|
139
|
+
>
|
|
140
|
+
<ChevronLeft className="size-4 text-fg-subtle" />
|
|
141
|
+
Back to mail
|
|
142
|
+
</button>
|
|
143
|
+
<Button
|
|
144
|
+
variant="ghost"
|
|
145
|
+
size="sm"
|
|
146
|
+
icon={<X className="size-4" />}
|
|
147
|
+
onClick={() => setNavOpen(false)}
|
|
148
|
+
aria-label="Close menu"
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
{nav}
|
|
152
|
+
</aside>
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
<main className="flex min-w-0 flex-1 flex-col">
|
|
157
|
+
{/* pane-header datum: title row, hairline at the shared y */}
|
|
158
|
+
<header className="flex h-pane-header shrink-0 items-center gap-3 border-b border-line px-3 lg:px-5">
|
|
159
|
+
<Button
|
|
160
|
+
variant="ghost"
|
|
161
|
+
size="sm"
|
|
162
|
+
icon={<Menu className="size-4" />}
|
|
163
|
+
className="lg:hidden"
|
|
164
|
+
onClick={() => setNavOpen(true)}
|
|
165
|
+
aria-label="Open settings menu"
|
|
166
|
+
/>
|
|
167
|
+
<h1 className="text-md font-semibold text-fg">{title}</h1>
|
|
168
|
+
{description && (
|
|
169
|
+
<p className="hidden min-w-0 truncate text-xs text-fg-subtle lg:block">
|
|
170
|
+
{description}
|
|
171
|
+
</p>
|
|
172
|
+
)}
|
|
173
|
+
{help && (
|
|
174
|
+
<Button
|
|
175
|
+
variant="ghost"
|
|
176
|
+
size="sm"
|
|
177
|
+
icon={<CircleHelp className="size-4" />}
|
|
178
|
+
className="ml-auto lg:hidden"
|
|
179
|
+
onClick={() => setMobileHelpOpen((v) => !v)}
|
|
180
|
+
aria-label="Tips"
|
|
181
|
+
aria-expanded={mobileHelpOpen}
|
|
182
|
+
/>
|
|
183
|
+
)}
|
|
184
|
+
{help && !showHelp && (
|
|
185
|
+
<Button
|
|
186
|
+
variant="ghost"
|
|
187
|
+
size="sm"
|
|
188
|
+
icon={<CircleHelp className="size-4" />}
|
|
189
|
+
className="hidden ml-auto lg:inline-flex"
|
|
190
|
+
onClick={onToggleHelp}
|
|
191
|
+
aria-label="Show tips"
|
|
192
|
+
/>
|
|
193
|
+
)}
|
|
194
|
+
</header>
|
|
195
|
+
|
|
196
|
+
{/* Below-desktop tips disclosure */}
|
|
197
|
+
{help && mobileHelpOpen && (
|
|
198
|
+
<div className="border-b border-line bg-surface-sunken px-row-inset py-3 text-xs leading-relaxed text-fg-muted lg:hidden">
|
|
199
|
+
{help}
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
{flush ? (
|
|
204
|
+
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
|
|
205
|
+
) : (
|
|
206
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
207
|
+
{/* left-aligned content column; whitespace goes right */}
|
|
208
|
+
<div className="max-w-2xl px-3 py-5 lg:px-5">
|
|
209
|
+
<div className="space-y-5">{children}</div>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
</main>
|
|
214
|
+
|
|
215
|
+
{showHelp && (
|
|
216
|
+
<aside className="hidden w-64 shrink-0 flex-col border-l border-line bg-surface-sunken lg:flex">
|
|
217
|
+
{/* pane-header datum */}
|
|
218
|
+
<header className="flex h-pane-header shrink-0 items-center justify-between border-b border-line px-row-inset">
|
|
219
|
+
<span className="text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
|
|
220
|
+
Tips & tricks
|
|
221
|
+
</span>
|
|
222
|
+
<Button
|
|
223
|
+
variant="ghost"
|
|
224
|
+
size="sm"
|
|
225
|
+
icon={<X className="size-3.5" />}
|
|
226
|
+
onClick={onToggleHelp}
|
|
227
|
+
aria-label="Collapse tips"
|
|
228
|
+
/>
|
|
229
|
+
</header>
|
|
230
|
+
<div className="flex-1 overflow-y-auto px-row-inset py-3 text-xs leading-relaxed text-fg-muted">
|
|
231
|
+
{help}
|
|
232
|
+
</div>
|
|
233
|
+
</aside>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* ------------------------------------------------------------------ */
|
|
240
|
+
/* SenderFlagRow: one row in the Senders & Rules lists. The human- */
|
|
241
|
+
/* readable face of AddressFlags. Dense, full-bleed hover, content */
|
|
242
|
+
/* on the shared row inset. */
|
|
243
|
+
/* ------------------------------------------------------------------ */
|
|
244
|
+
|
|
245
|
+
export interface SenderFlagRowProps {
|
|
246
|
+
name: string;
|
|
247
|
+
email: string;
|
|
248
|
+
/** Engagement line, e.g. "34 received · you replied 12×". */
|
|
249
|
+
meta: string;
|
|
250
|
+
/** Flag provenance, e.g. "muted Mar 2026 — too chatty". */
|
|
251
|
+
caption?: string;
|
|
252
|
+
/** Danger ring (blocked senders). */
|
|
253
|
+
danger?: boolean;
|
|
254
|
+
trailing?: ReactNode;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function SenderFlagRow({
|
|
258
|
+
name,
|
|
259
|
+
email,
|
|
260
|
+
meta,
|
|
261
|
+
caption,
|
|
262
|
+
danger,
|
|
263
|
+
trailing,
|
|
264
|
+
}: SenderFlagRowProps) {
|
|
265
|
+
return (
|
|
266
|
+
<div className="flex items-center gap-3 px-row-inset py-2 hover:bg-surface-sunken">
|
|
267
|
+
<span className={cn(danger && "rounded-full ring-2 ring-danger/40")}>
|
|
268
|
+
<Avatar name={name} email={email} size="sm" />
|
|
269
|
+
</span>
|
|
270
|
+
<div className="min-w-0 flex-1">
|
|
271
|
+
<div className="flex flex-col gap-0 sm:flex-row sm:items-baseline sm:gap-2">
|
|
272
|
+
<span className="truncate text-sm font-medium text-fg">{name}</span>
|
|
273
|
+
<span className="truncate text-2xs text-fg-subtle">{email}</span>
|
|
274
|
+
</div>
|
|
275
|
+
<div className="text-xs text-fg-subtle">
|
|
276
|
+
{meta}
|
|
277
|
+
{caption && <span className="text-fg-muted"> · {caption}</span>}
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
{trailing && <div className="shrink-0">{trailing}</div>}
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/* ------------------------------------------------------------------ */
|
|
286
|
+
/* AccountHealthCard: account row with sync status + health chips. */
|
|
287
|
+
/* ------------------------------------------------------------------ */
|
|
288
|
+
|
|
289
|
+
export interface AccountHealthCardProps {
|
|
290
|
+
label: string;
|
|
291
|
+
email: string;
|
|
292
|
+
connector: string;
|
|
293
|
+
syncLabel: string;
|
|
294
|
+
state: "healthy" | "error" | "muted";
|
|
295
|
+
/** lastError summary when state = error. */
|
|
296
|
+
errorDetail?: string;
|
|
297
|
+
trailing?: ReactNode;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const stateBadge: Record<
|
|
301
|
+
AccountHealthCardProps["state"],
|
|
302
|
+
{ tone: "positive" | "danger" | "neutral"; label: string }
|
|
303
|
+
> = {
|
|
304
|
+
healthy: { tone: "positive", label: "healthy" },
|
|
305
|
+
error: { tone: "danger", label: "error" },
|
|
306
|
+
muted: { tone: "neutral", label: "muted" },
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
export function AccountHealthCard({
|
|
310
|
+
label,
|
|
311
|
+
email,
|
|
312
|
+
connector,
|
|
313
|
+
syncLabel,
|
|
314
|
+
state,
|
|
315
|
+
errorDetail,
|
|
316
|
+
trailing,
|
|
317
|
+
}: AccountHealthCardProps) {
|
|
318
|
+
const badge = stateBadge[state];
|
|
319
|
+
return (
|
|
320
|
+
<div
|
|
321
|
+
className={cn(
|
|
322
|
+
"rounded-sm border border-line bg-surface",
|
|
323
|
+
state === "muted" && "opacity-65",
|
|
324
|
+
)}
|
|
325
|
+
>
|
|
326
|
+
<div className="flex flex-col gap-3 px-row-inset py-3 sm:flex-row sm:items-center">
|
|
327
|
+
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
328
|
+
<Avatar name={label} email={email} size="md" />
|
|
329
|
+
<div className="min-w-0 flex-1">
|
|
330
|
+
<div className="flex items-center gap-2">
|
|
331
|
+
<span className="truncate text-sm font-semibold text-fg">
|
|
332
|
+
{label}
|
|
333
|
+
</span>
|
|
334
|
+
<Badge tone={badge.tone} dot>
|
|
335
|
+
{badge.label}
|
|
336
|
+
</Badge>
|
|
337
|
+
</div>
|
|
338
|
+
<div className="truncate text-xs text-fg-subtle">
|
|
339
|
+
{email} · {connector} · {syncLabel}
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
{trailing && <div className="shrink-0">{trailing}</div>}
|
|
344
|
+
</div>
|
|
345
|
+
{errorDetail && (
|
|
346
|
+
<div className="border-t border-line px-row-inset py-2">
|
|
347
|
+
<code className="block rounded-xs bg-danger-soft px-2 py-1 text-2xs text-danger">
|
|
348
|
+
{errorDetail}
|
|
349
|
+
</code>
|
|
350
|
+
</div>
|
|
351
|
+
)}
|
|
352
|
+
</div>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import type { ThreadRowData } from "./app-shell-types.js";
|
|
6
|
+
import {
|
|
7
|
+
SwipeableRow,
|
|
8
|
+
type SwipeableRowOpenProps,
|
|
9
|
+
type SwipePeek,
|
|
10
|
+
} from "./swipeable-row.js";
|
|
11
|
+
|
|
12
|
+
const thread: ThreadRowData = {
|
|
13
|
+
id: "thread-1",
|
|
14
|
+
accountId: "account-1",
|
|
15
|
+
fromName: "Alex Rivera",
|
|
16
|
+
fromEmail: "alex@example.com",
|
|
17
|
+
subject: "Q3 planning notes",
|
|
18
|
+
snippet: "Notes from the planning session.",
|
|
19
|
+
timeLabel: "9:42",
|
|
20
|
+
isRead: false,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const baseProps = {
|
|
24
|
+
thread,
|
|
25
|
+
selectionMode: false,
|
|
26
|
+
checked: false,
|
|
27
|
+
active: false,
|
|
28
|
+
onPeek: () => undefined,
|
|
29
|
+
onToggleCheck: () => undefined,
|
|
30
|
+
onLongPress: () => undefined,
|
|
31
|
+
onOpen: () => undefined,
|
|
32
|
+
onAct: () => undefined,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function render(peek: SwipePeek, override?: Partial<typeof baseProps>) {
|
|
36
|
+
return renderToString(
|
|
37
|
+
createElement(SwipeableRow, { ...baseProps, ...override, peek }),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("SwipeableRow", () => {
|
|
42
|
+
it("renders no action background at rest", () => {
|
|
43
|
+
const html = render("none");
|
|
44
|
+
assert.doesNotMatch(html, /bg-danger/);
|
|
45
|
+
assert.doesNotMatch(html, /bg-accent-2(?!-soft)/);
|
|
46
|
+
assert.doesNotMatch(html, /aria-label="Delete message"/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("reveals the delete action when peeked trailing", () => {
|
|
50
|
+
const html = render("trailing");
|
|
51
|
+
assert.match(html, /aria-label="Delete message"/);
|
|
52
|
+
assert.match(html, /bg-danger/);
|
|
53
|
+
assert.match(html, /translateX\(-72px\)/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("reveals the toggle-read action when peeked leading", () => {
|
|
57
|
+
const unread = render("leading");
|
|
58
|
+
assert.match(unread, /aria-label="Mark as read"/);
|
|
59
|
+
assert.match(unread, /bg-accent-2/);
|
|
60
|
+
assert.match(unread, /translateX\(72px\)/);
|
|
61
|
+
|
|
62
|
+
const read = render("leading", { thread: { ...thread, isRead: true } });
|
|
63
|
+
assert.match(read, /aria-label="Mark as unread"/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("renders the open affordance as a button by default", () => {
|
|
67
|
+
const html = render("none");
|
|
68
|
+
assert.match(html, /<button[^>]*>/);
|
|
69
|
+
assert.doesNotMatch(html, /<a /);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("renders the open affordance as a real anchor when linkComponent is given", () => {
|
|
73
|
+
const html = renderToString(
|
|
74
|
+
createElement(SwipeableRow, {
|
|
75
|
+
...baseProps,
|
|
76
|
+
peek: "none",
|
|
77
|
+
linkComponent: ({ className, children }: SwipeableRowOpenProps) =>
|
|
78
|
+
createElement(
|
|
79
|
+
"a",
|
|
80
|
+
{ href: "/mail/m1?selectedMessageId=t1", className },
|
|
81
|
+
children,
|
|
82
|
+
),
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
assert.match(html, /<a [^>]*href="\/mail\/m1\?selectedMessageId=t1"/);
|
|
86
|
+
assert.match(html, /Q3 planning notes/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("keeps the button (not the anchor) in selection mode even with a linkComponent", () => {
|
|
90
|
+
const html = renderToString(
|
|
91
|
+
createElement(SwipeableRow, {
|
|
92
|
+
...baseProps,
|
|
93
|
+
selectionMode: true,
|
|
94
|
+
peek: "none",
|
|
95
|
+
linkComponent: ({ className, children }: SwipeableRowOpenProps) =>
|
|
96
|
+
createElement(
|
|
97
|
+
"a",
|
|
98
|
+
{ href: "/should-not-render", className },
|
|
99
|
+
children,
|
|
100
|
+
),
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
assert.doesNotMatch(html, /<a /);
|
|
104
|
+
assert.match(html, /<button[^>]*>/);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import type { ThreadRowData } from "./app-shell-types.js";
|
|
4
|
+
import { SwipeableRow, type SwipePeek } from "./swipeable-row.js";
|
|
5
|
+
|
|
6
|
+
const sampleThread: ThreadRowData = {
|
|
7
|
+
id: "thread-1",
|
|
8
|
+
accountId: "account-1",
|
|
9
|
+
fromName: "Alex Rivera",
|
|
10
|
+
fromEmail: "alex@example.com",
|
|
11
|
+
subject: "Q3 planning notes",
|
|
12
|
+
snippet: "Here are the notes from our planning session earlier today.",
|
|
13
|
+
timeLabel: "9:42",
|
|
14
|
+
isRead: false,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const baseArgs = {
|
|
18
|
+
thread: sampleThread,
|
|
19
|
+
selectionMode: false,
|
|
20
|
+
checked: false,
|
|
21
|
+
active: false,
|
|
22
|
+
onPeek: () => undefined,
|
|
23
|
+
onToggleCheck: () => undefined,
|
|
24
|
+
onLongPress: () => undefined,
|
|
25
|
+
onOpen: () => undefined,
|
|
26
|
+
onAct: () => undefined,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function PhoneFrame({ children }: { children: React.ReactNode }) {
|
|
30
|
+
return (
|
|
31
|
+
<div className="max-w-md overflow-hidden rounded-lg border border-line">
|
|
32
|
+
{children}
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const meta: Meta<typeof SwipeableRow> = {
|
|
38
|
+
title: "Primitives/SwipeableRow",
|
|
39
|
+
component: SwipeableRow,
|
|
40
|
+
parameters: { layout: "padded" },
|
|
41
|
+
args: baseArgs,
|
|
42
|
+
render: (args) => (
|
|
43
|
+
<PhoneFrame>
|
|
44
|
+
<SwipeableRow {...args} />
|
|
45
|
+
</PhoneFrame>
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
export default meta;
|
|
49
|
+
|
|
50
|
+
type Story = StoryObj<typeof SwipeableRow>;
|
|
51
|
+
|
|
52
|
+
export const Rest: Story = { args: { peek: "none" } };
|
|
53
|
+
|
|
54
|
+
export const PeekedLeading: Story = { args: { peek: "leading" } };
|
|
55
|
+
|
|
56
|
+
export const PeekedTrailing: Story = { args: { peek: "trailing" } };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The open affordance is rendered as a real `<a href>` via `linkComponent`,
|
|
60
|
+
* so deep-link, middle-click and open-in-new-tab work. Consumers pass their
|
|
61
|
+
* router's Link; here a plain anchor stands in. Inspect the DOM: the row is an
|
|
62
|
+
* anchor, not a button.
|
|
63
|
+
*/
|
|
64
|
+
export const AsAnchor: Story = {
|
|
65
|
+
name: "As anchor (linkComponent)",
|
|
66
|
+
args: {
|
|
67
|
+
peek: "none",
|
|
68
|
+
linkComponent: ({ onOpenClick, children, ...rowProps }) => (
|
|
69
|
+
<a
|
|
70
|
+
{...rowProps}
|
|
71
|
+
href="/mail/inbox?selectedMessageId=thread-1"
|
|
72
|
+
onClick={(e) => {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
onOpenClick(e);
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
{children}
|
|
78
|
+
</a>
|
|
79
|
+
),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const Acting: Story = {
|
|
84
|
+
name: "Acting (interactive)",
|
|
85
|
+
render: () => {
|
|
86
|
+
const [thread, setThread] = useState<ThreadRowData>(sampleThread);
|
|
87
|
+
const [peek, setPeek] = useState<SwipePeek>("trailing");
|
|
88
|
+
const [deleted, setDeleted] = useState(false);
|
|
89
|
+
if (deleted) {
|
|
90
|
+
return (
|
|
91
|
+
<PhoneFrame>
|
|
92
|
+
<div className="flex h-16 items-center justify-center text-sm text-fg-muted">
|
|
93
|
+
Message deleted
|
|
94
|
+
</div>
|
|
95
|
+
</PhoneFrame>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return (
|
|
99
|
+
<PhoneFrame>
|
|
100
|
+
<SwipeableRow
|
|
101
|
+
{...baseArgs}
|
|
102
|
+
thread={thread}
|
|
103
|
+
peek={peek}
|
|
104
|
+
onPeek={setPeek}
|
|
105
|
+
onAct={(side) => {
|
|
106
|
+
if (side === "trailing") {
|
|
107
|
+
setDeleted(true);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
setThread((prev) => ({ ...prev, isRead: !prev.isRead }));
|
|
111
|
+
setPeek("none");
|
|
112
|
+
}}
|
|
113
|
+
/>
|
|
114
|
+
</PhoneFrame>
|
|
115
|
+
);
|
|
116
|
+
},
|
|
117
|
+
};
|