@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,321 @@
|
|
|
1
|
+
import { Check } from "lucide-react";
|
|
2
|
+
import { type ReactNode, useId, useState } from "react";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { Badge, type BadgeProps } from "./badge.js";
|
|
5
|
+
import { Card } from "./card.js";
|
|
6
|
+
import { FieldLabel } from "./field-label.js";
|
|
7
|
+
import { Input } from "./input.js";
|
|
8
|
+
import { SecuritySelect, type ServerSecurity } from "./security-select.js";
|
|
9
|
+
|
|
10
|
+
/* ------------------------------------------------------------------ */
|
|
11
|
+
/* WizardShell: centered card + step rail used by onboarding and the */
|
|
12
|
+
/* settings add-account flow. Any flow with >1-2 decisions is a */
|
|
13
|
+
/* wizard — this is the one implementation. */
|
|
14
|
+
/* ------------------------------------------------------------------ */
|
|
15
|
+
|
|
16
|
+
export interface WizardShellProps {
|
|
17
|
+
/** Ordered step labels for the rail. */
|
|
18
|
+
steps: string[];
|
|
19
|
+
/** Zero-based index of the active step. */
|
|
20
|
+
activeStep: number;
|
|
21
|
+
title: string;
|
|
22
|
+
subtitle?: string;
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
/** Footer slot: back/continue buttons, hints. */
|
|
25
|
+
footer?: ReactNode;
|
|
26
|
+
/** Hide the rail (welcome screen). */
|
|
27
|
+
hideSteps?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function StepRail({
|
|
31
|
+
steps,
|
|
32
|
+
activeStep,
|
|
33
|
+
}: Pick<WizardShellProps, "steps" | "activeStep">) {
|
|
34
|
+
return (
|
|
35
|
+
<ol className="flex items-center gap-1">
|
|
36
|
+
{steps.map((label, i) => {
|
|
37
|
+
const done = i < activeStep;
|
|
38
|
+
const active = i === activeStep;
|
|
39
|
+
return (
|
|
40
|
+
<li key={label} className="flex items-center gap-1">
|
|
41
|
+
{i > 0 && <span className="h-px w-5 bg-line" aria-hidden />}
|
|
42
|
+
<span className="flex items-center gap-1.5">
|
|
43
|
+
<span
|
|
44
|
+
className={cn(
|
|
45
|
+
"flex size-5 items-center justify-center rounded-full text-2xs font-semibold tabular-nums",
|
|
46
|
+
done && "bg-accent text-accent-fg",
|
|
47
|
+
active && "bg-accent-soft text-accent ring-1 ring-accent",
|
|
48
|
+
!done && !active && "bg-surface-sunken text-fg-subtle",
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{done ? <Check className="size-3" /> : i + 1}
|
|
52
|
+
</span>
|
|
53
|
+
<span
|
|
54
|
+
className={cn(
|
|
55
|
+
"hidden text-2xs sm:inline",
|
|
56
|
+
active ? "font-medium text-fg" : "text-fg-subtle",
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
{label}
|
|
60
|
+
</span>
|
|
61
|
+
</span>
|
|
62
|
+
</li>
|
|
63
|
+
);
|
|
64
|
+
})}
|
|
65
|
+
</ol>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function WizardShell({
|
|
70
|
+
steps,
|
|
71
|
+
activeStep,
|
|
72
|
+
title,
|
|
73
|
+
subtitle,
|
|
74
|
+
children,
|
|
75
|
+
footer,
|
|
76
|
+
hideSteps,
|
|
77
|
+
}: WizardShellProps) {
|
|
78
|
+
return (
|
|
79
|
+
// First-run is a focal moment: the wizard column is horizontally
|
|
80
|
+
// centered with its top edge anchored ~30% down the viewport
|
|
81
|
+
// (optical center) on larger screens. On phone the top padding is
|
|
82
|
+
// minimal so taller steps (connector picker, servers) don't push the
|
|
83
|
+
// CTA bar below the fold — the shell scrolls if content overflows.
|
|
84
|
+
<div className="flex min-h-dvh w-full flex-col items-center overflow-y-auto bg-canvas px-8 pb-8 pt-8 font-sans text-fg sm:pt-[30vh]">
|
|
85
|
+
{!hideSteps && (
|
|
86
|
+
<div className="mb-5 w-full max-w-xl">
|
|
87
|
+
<StepRail steps={steps} activeStep={activeStep} />
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
<Card raised className="w-full max-w-xl">
|
|
91
|
+
<div className="px-5 pt-5">
|
|
92
|
+
<h1 className="text-xl font-semibold text-fg">{title}</h1>
|
|
93
|
+
{subtitle && <p className="mt-1 text-sm text-fg-muted">{subtitle}</p>}
|
|
94
|
+
</div>
|
|
95
|
+
<div className="px-5 py-5">{children}</div>
|
|
96
|
+
{footer && (
|
|
97
|
+
<div className="flex items-center justify-between gap-3 border-t border-line px-5 py-3">
|
|
98
|
+
{footer}
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
</Card>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* ------------------------------------------------------------------ */
|
|
107
|
+
/* ConnectorTile: connector picker option (IMAP now, OAuth later). */
|
|
108
|
+
/* ------------------------------------------------------------------ */
|
|
109
|
+
|
|
110
|
+
export interface ConnectorTileProps {
|
|
111
|
+
name: string;
|
|
112
|
+
description: string;
|
|
113
|
+
icon: ReactNode;
|
|
114
|
+
/** "coming soon" connectors render muted with a badge — still pressable. */
|
|
115
|
+
comingSoon?: boolean;
|
|
116
|
+
selected?: boolean;
|
|
117
|
+
onSelect?: () => void;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Per the never-disable tenet a "soon" tile stays pressable: with no handler it
|
|
121
|
+
// surfaces a one-line explainer instead of going dead.
|
|
122
|
+
const COMING_SOON_HINT = "Coming soon — use IMAP for now.";
|
|
123
|
+
|
|
124
|
+
export function ConnectorTile({
|
|
125
|
+
name,
|
|
126
|
+
description,
|
|
127
|
+
icon,
|
|
128
|
+
comingSoon,
|
|
129
|
+
selected,
|
|
130
|
+
onSelect,
|
|
131
|
+
}: ConnectorTileProps) {
|
|
132
|
+
const [showHint, setShowHint] = useState(false);
|
|
133
|
+
|
|
134
|
+
const handleClick = () => {
|
|
135
|
+
if (onSelect) {
|
|
136
|
+
onSelect();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (comingSoon) setShowHint(true);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div className="flex flex-col gap-1.5">
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
onClick={handleClick}
|
|
147
|
+
aria-disabled={comingSoon || undefined}
|
|
148
|
+
className={cn(
|
|
149
|
+
"flex flex-col items-start gap-2 rounded-sm border p-3 text-left transition-colors",
|
|
150
|
+
selected
|
|
151
|
+
? "border-accent-2 bg-accent-2-soft"
|
|
152
|
+
: "border-line bg-surface hover:border-line-strong",
|
|
153
|
+
comingSoon && "opacity-55",
|
|
154
|
+
)}
|
|
155
|
+
>
|
|
156
|
+
<span className={cn("text-fg-muted", selected && "text-accent-2")}>
|
|
157
|
+
{icon}
|
|
158
|
+
</span>
|
|
159
|
+
<span className="flex items-center gap-2">
|
|
160
|
+
<span className="text-sm font-semibold text-fg">{name}</span>
|
|
161
|
+
{comingSoon && (
|
|
162
|
+
<span className="rounded-full bg-surface-sunken px-1.5 py-0.5 text-2xs text-fg-subtle">
|
|
163
|
+
soon
|
|
164
|
+
</span>
|
|
165
|
+
)}
|
|
166
|
+
</span>
|
|
167
|
+
<span className="text-xs text-fg-subtle">{description}</span>
|
|
168
|
+
</button>
|
|
169
|
+
{showHint && (
|
|
170
|
+
<p className="text-2xs text-fg-subtle">{COMING_SOON_HINT}</p>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* ------------------------------------------------------------------ */
|
|
177
|
+
/* Server settings field group: host / port / security for one */
|
|
178
|
+
/* protocol (IMAP or SMTP). Responsive — stacks to one column on phone */
|
|
179
|
+
/* so the Security select is never clipped — and shared by the */
|
|
180
|
+
/* onboarding flow and the settings add-account form. */
|
|
181
|
+
/* ------------------------------------------------------------------ */
|
|
182
|
+
|
|
183
|
+
export interface ServerFieldsProps {
|
|
184
|
+
/** "IMAP — incoming" / "SMTP — outgoing". */
|
|
185
|
+
legend: string;
|
|
186
|
+
/** Optional badge after the legend ("detected" / "preset"). */
|
|
187
|
+
badge?: { label: string; tone: BadgeProps["tone"] };
|
|
188
|
+
host: string;
|
|
189
|
+
port: string;
|
|
190
|
+
security: ServerSecurity;
|
|
191
|
+
/** Locked presets render read-only and omit change handlers. */
|
|
192
|
+
readOnly?: boolean;
|
|
193
|
+
onHostChange?: (value: string) => void;
|
|
194
|
+
onPortChange?: (value: string) => void;
|
|
195
|
+
onSecurityChange?: (value: ServerSecurity) => void;
|
|
196
|
+
hostPlaceholder?: string;
|
|
197
|
+
portPlaceholder?: string;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function ServerFields({
|
|
201
|
+
legend,
|
|
202
|
+
badge,
|
|
203
|
+
host,
|
|
204
|
+
port,
|
|
205
|
+
security,
|
|
206
|
+
readOnly,
|
|
207
|
+
onHostChange,
|
|
208
|
+
onPortChange,
|
|
209
|
+
onSecurityChange,
|
|
210
|
+
hostPlaceholder,
|
|
211
|
+
portPlaceholder,
|
|
212
|
+
}: ServerFieldsProps) {
|
|
213
|
+
const hostId = useId();
|
|
214
|
+
const portId = useId();
|
|
215
|
+
const securityId = useId();
|
|
216
|
+
return (
|
|
217
|
+
<fieldset>
|
|
218
|
+
<legend className="flex items-center gap-2 text-sm font-semibold text-fg">
|
|
219
|
+
{legend}
|
|
220
|
+
{badge && <Badge tone={badge.tone}>{badge.label}</Badge>}
|
|
221
|
+
</legend>
|
|
222
|
+
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-[1fr_6rem_8rem] sm:gap-2">
|
|
223
|
+
<div>
|
|
224
|
+
<FieldLabel htmlFor={hostId}>Host</FieldLabel>
|
|
225
|
+
{onHostChange ? (
|
|
226
|
+
<Input
|
|
227
|
+
id={hostId}
|
|
228
|
+
readOnly={readOnly}
|
|
229
|
+
value={host}
|
|
230
|
+
onChange={(e) => onHostChange(e.target.value)}
|
|
231
|
+
placeholder={hostPlaceholder}
|
|
232
|
+
/>
|
|
233
|
+
) : (
|
|
234
|
+
<Input
|
|
235
|
+
id={hostId}
|
|
236
|
+
readOnly={readOnly}
|
|
237
|
+
defaultValue={host}
|
|
238
|
+
placeholder={hostPlaceholder}
|
|
239
|
+
/>
|
|
240
|
+
)}
|
|
241
|
+
</div>
|
|
242
|
+
<div>
|
|
243
|
+
<FieldLabel htmlFor={portId}>Port</FieldLabel>
|
|
244
|
+
{onPortChange ? (
|
|
245
|
+
<Input
|
|
246
|
+
id={portId}
|
|
247
|
+
readOnly={readOnly}
|
|
248
|
+
value={port}
|
|
249
|
+
onChange={(e) => onPortChange(e.target.value)}
|
|
250
|
+
placeholder={portPlaceholder}
|
|
251
|
+
/>
|
|
252
|
+
) : (
|
|
253
|
+
<Input
|
|
254
|
+
id={portId}
|
|
255
|
+
readOnly={readOnly}
|
|
256
|
+
defaultValue={port}
|
|
257
|
+
placeholder={portPlaceholder}
|
|
258
|
+
/>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
<div>
|
|
262
|
+
<FieldLabel htmlFor={securityId}>Security</FieldLabel>
|
|
263
|
+
{onSecurityChange ? (
|
|
264
|
+
<SecuritySelect
|
|
265
|
+
id={securityId}
|
|
266
|
+
value={security}
|
|
267
|
+
onValueChange={onSecurityChange}
|
|
268
|
+
/>
|
|
269
|
+
) : (
|
|
270
|
+
<SecuritySelect id={securityId} defaultValue={security} />
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
</fieldset>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* ------------------------------------------------------------------ */
|
|
279
|
+
/* CheckRow: live status line for connection tests & sync checks. */
|
|
280
|
+
/* ------------------------------------------------------------------ */
|
|
281
|
+
|
|
282
|
+
export interface CheckRowProps {
|
|
283
|
+
label: string;
|
|
284
|
+
detail?: string;
|
|
285
|
+
state: "pending" | "running" | "ok" | "failed";
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function CheckRow({ label, detail, state }: CheckRowProps) {
|
|
289
|
+
return (
|
|
290
|
+
<div className="flex items-start gap-3 py-2">
|
|
291
|
+
<span
|
|
292
|
+
className={cn(
|
|
293
|
+
"mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-2xs",
|
|
294
|
+
state === "ok" && "bg-positive text-accent-fg",
|
|
295
|
+
state === "failed" && "bg-danger text-accent-fg",
|
|
296
|
+
state === "running" && "border border-accent text-accent",
|
|
297
|
+
state === "pending" && "border border-line text-fg-subtle",
|
|
298
|
+
)}
|
|
299
|
+
>
|
|
300
|
+
{state === "ok" && <Check className="size-3" />}
|
|
301
|
+
{state === "failed" && "!"}
|
|
302
|
+
{state === "running" && (
|
|
303
|
+
<span className="size-2 animate-pulse rounded-full bg-accent" />
|
|
304
|
+
)}
|
|
305
|
+
</span>
|
|
306
|
+
<div className="min-w-0">
|
|
307
|
+
<div
|
|
308
|
+
className={cn(
|
|
309
|
+
"text-sm",
|
|
310
|
+
state === "failed" ? "font-medium text-danger" : "text-fg",
|
|
311
|
+
)}
|
|
312
|
+
>
|
|
313
|
+
{label}
|
|
314
|
+
</div>
|
|
315
|
+
{detail && (
|
|
316
|
+
<div className="mt-0.5 text-xs text-fg-subtle">{detail}</div>
|
|
317
|
+
)}
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
briefFilterConfig,
|
|
5
|
+
type FilterAccount,
|
|
6
|
+
flaggedFilterConfig,
|
|
7
|
+
inboxFilterConfig,
|
|
8
|
+
} from "./filter-presets.js";
|
|
9
|
+
|
|
10
|
+
const accounts: FilterAccount[] = [
|
|
11
|
+
{ id: "all", label: "All", active: true },
|
|
12
|
+
{ id: "personal", label: "Personal", count: 9 },
|
|
13
|
+
{ id: "work", label: "Work", count: 14 },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
describe("briefFilterConfig", () => {
|
|
17
|
+
it("offers the message categories with a leading All", () => {
|
|
18
|
+
const { categories } = briefFilterConfig();
|
|
19
|
+
assert.equal(categories[0]?.id, "all");
|
|
20
|
+
assert.deepEqual(
|
|
21
|
+
categories.map((c) => c.id),
|
|
22
|
+
[
|
|
23
|
+
"all",
|
|
24
|
+
"personal",
|
|
25
|
+
"transactional",
|
|
26
|
+
"newsletter",
|
|
27
|
+
"marketing",
|
|
28
|
+
"social",
|
|
29
|
+
"automated",
|
|
30
|
+
],
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("offers the BriefSections chip set", () => {
|
|
35
|
+
assert.deepEqual(
|
|
36
|
+
briefFilterConfig().filters.map((f) => f.id),
|
|
37
|
+
["unread", "attachment", "contacts", "today"],
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("includes the accounts source group when more than one account", () => {
|
|
42
|
+
const { sources } = briefFilterConfig(accounts);
|
|
43
|
+
assert.deepEqual(
|
|
44
|
+
sources?.map((s) => s.id),
|
|
45
|
+
["all", "personal", "work"],
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("omits the accounts source group for a single account", () => {
|
|
50
|
+
assert.equal(briefFilterConfig(accounts.slice(0, 1)).sources, undefined);
|
|
51
|
+
assert.equal(briefFilterConfig().sources, undefined);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("inboxFilterConfig", () => {
|
|
56
|
+
it("offers the same message categories as the brief", () => {
|
|
57
|
+
assert.deepEqual(
|
|
58
|
+
inboxFilterConfig().categories.map((c) => c.id),
|
|
59
|
+
briefFilterConfig().categories.map((c) => c.id),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("adds Has attachment to Unread and Flagged", () => {
|
|
64
|
+
assert.deepEqual(
|
|
65
|
+
inboxFilterConfig().filters.map((f) => f.id),
|
|
66
|
+
["unread", "flagged", "attachment"],
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("never offers an accounts source group", () => {
|
|
71
|
+
assert.equal(inboxFilterConfig().sources, undefined);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("flaggedFilterConfig", () => {
|
|
76
|
+
it("offers the same message categories as the brief", () => {
|
|
77
|
+
assert.deepEqual(
|
|
78
|
+
flaggedFilterConfig().categories.map((c) => c.id),
|
|
79
|
+
briefFilterConfig().categories.map((c) => c.id),
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("offers Unread and Has attachment, never the redundant Flagged", () => {
|
|
84
|
+
assert.deepEqual(
|
|
85
|
+
flaggedFilterConfig().filters.map((f) => f.id),
|
|
86
|
+
["unread", "attachment"],
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("never offers an accounts source group", () => {
|
|
91
|
+
assert.equal(flaggedFilterConfig().sources, undefined);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { briefFilterChips } from "./components/brief-sections.js";
|
|
2
|
+
import type {
|
|
3
|
+
FilterSheetCategory,
|
|
4
|
+
FilterSheetFilter,
|
|
5
|
+
FilterSheetSource,
|
|
6
|
+
} from "./components/filter-sheet.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* An account as a filter dimension. Accounts segment the unified daily brief
|
|
10
|
+
* (the aggregate of every account); an inbox is already scoped to one account,
|
|
11
|
+
* so the inbox preset never offers them.
|
|
12
|
+
*/
|
|
13
|
+
export interface FilterAccount {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
count?: number;
|
|
17
|
+
active?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A FilterSheet configuration: the category, attribute, and (brief-only) account
|
|
22
|
+
* groups for one view. Feed straight into `<FilterSheet {...preset} />`.
|
|
23
|
+
*/
|
|
24
|
+
export interface FilterPreset {
|
|
25
|
+
categories: FilterSheetCategory[];
|
|
26
|
+
filters: FilterSheetFilter[];
|
|
27
|
+
sources?: FilterSheetSource[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Content-type categories, mirroring the `MessageCategory` enum
|
|
32
|
+
* (@remit/remit-imap) by value. The leading "all" clears the category. Per
|
|
33
|
+
* message, not per mailbox — so they apply in the brief and an inbox alike.
|
|
34
|
+
*/
|
|
35
|
+
const MESSAGE_CATEGORIES: FilterSheetCategory[] = [
|
|
36
|
+
{ id: "all", label: "All", tone: "neutral" },
|
|
37
|
+
{ id: "personal", label: "Personal", tone: "accent" },
|
|
38
|
+
{ id: "transactional", label: "Transactional", tone: "positive" },
|
|
39
|
+
{ id: "newsletter", label: "Newsletter", tone: "neutral" },
|
|
40
|
+
{ id: "marketing", label: "Marketing", tone: "warning" },
|
|
41
|
+
{ id: "social", label: "Social", tone: "warning" },
|
|
42
|
+
{ id: "automated", label: "Automated", tone: "neutral" },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const UNREAD: FilterSheetFilter = { id: "unread", label: "Unread" };
|
|
46
|
+
const FLAGGED: FilterSheetFilter = { id: "flagged", label: "Flagged" };
|
|
47
|
+
const HAS_ATTACHMENT: FilterSheetFilter = {
|
|
48
|
+
id: "attachment",
|
|
49
|
+
label: "Has attachment",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Accounts become a source group only when there is more than one to pick. */
|
|
53
|
+
function accountSources(
|
|
54
|
+
accounts: FilterAccount[],
|
|
55
|
+
): FilterSheetSource[] | undefined {
|
|
56
|
+
if (accounts.length <= 1) return undefined;
|
|
57
|
+
return accounts.map((account) => ({
|
|
58
|
+
id: account.id,
|
|
59
|
+
label: account.label,
|
|
60
|
+
count: account.count,
|
|
61
|
+
active: account.active,
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Daily-brief filter: categories + the BriefSections chip set (the single source
|
|
67
|
+
* of truth for the brief's attribute chips), plus an account source group when
|
|
68
|
+
* more than one account feeds the brief (the brief aggregates them all).
|
|
69
|
+
*/
|
|
70
|
+
export function briefFilterConfig(
|
|
71
|
+
accounts: FilterAccount[] = [],
|
|
72
|
+
): FilterPreset {
|
|
73
|
+
return {
|
|
74
|
+
categories: [...MESSAGE_CATEGORIES],
|
|
75
|
+
filters: briefFilterChips,
|
|
76
|
+
sources: accountSources(accounts),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Inbox filter: categories + Unread/Flagged/Has attachment. No account group —
|
|
82
|
+
* an inbox is already scoped to a single account/mailbox.
|
|
83
|
+
*/
|
|
84
|
+
export function inboxFilterConfig(): FilterPreset {
|
|
85
|
+
return {
|
|
86
|
+
categories: [...MESSAGE_CATEGORIES],
|
|
87
|
+
filters: [UNREAD, FLAGGED, HAS_ATTACHMENT],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Flagged virtual-mailbox filter: categories + Unread/Has attachment. The view
|
|
93
|
+
* is already scoped to starred mail, so it never offers the redundant Flagged
|
|
94
|
+
* filter, and the flat starred list carries no account source group.
|
|
95
|
+
*/
|
|
96
|
+
export function flaggedFilterConfig(): FilterPreset {
|
|
97
|
+
return {
|
|
98
|
+
categories: [...MESSAGE_CATEGORIES],
|
|
99
|
+
filters: [UNREAD, HAS_ATTACHMENT],
|
|
100
|
+
};
|
|
101
|
+
}
|