@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,316 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { isSelfRowActivation } from "../lib/row-keyboard.js";
|
|
4
|
+
import { Badge } from "./badge.js";
|
|
5
|
+
|
|
6
|
+
export type CategoryTone =
|
|
7
|
+
| "neutral"
|
|
8
|
+
| "accent"
|
|
9
|
+
| "positive"
|
|
10
|
+
| "warning"
|
|
11
|
+
| "danger";
|
|
12
|
+
|
|
13
|
+
export interface FilterSheetCategory {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
tone?: CategoryTone;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FilterSheetFilter {
|
|
20
|
+
id: string;
|
|
21
|
+
label: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface FilterSheetSource {
|
|
25
|
+
id: string;
|
|
26
|
+
label: string;
|
|
27
|
+
count?: number;
|
|
28
|
+
active?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FilterSheetProps {
|
|
32
|
+
/** Available category options. The first one is treated as "all" (clears category). */
|
|
33
|
+
categories: FilterSheetCategory[];
|
|
34
|
+
/** Available attribute filters for multi-toggle. */
|
|
35
|
+
filters: FilterSheetFilter[];
|
|
36
|
+
/**
|
|
37
|
+
* Source/account pills. Single-select scoping the brief to one account.
|
|
38
|
+
* Omitted (or a single entry) hides the source row.
|
|
39
|
+
*/
|
|
40
|
+
sources?: FilterSheetSource[];
|
|
41
|
+
/** Note rendered alongside the source pills (e.g. "+2 muted"). */
|
|
42
|
+
sourcesNote?: string;
|
|
43
|
+
/** Currently selected category id. */
|
|
44
|
+
selectedCategory: string;
|
|
45
|
+
/** Currently active filter ids. */
|
|
46
|
+
activeFilters: ReadonlySet<string>;
|
|
47
|
+
/**
|
|
48
|
+
* Whether the filter panel is expanded.
|
|
49
|
+
* When omitted the component manages this state internally (collapsed by default).
|
|
50
|
+
*/
|
|
51
|
+
expanded?: boolean;
|
|
52
|
+
/** Called when the user selects a category. */
|
|
53
|
+
onSelectCategory: (id: string) => void;
|
|
54
|
+
/** Called when the user selects a source/account pill. */
|
|
55
|
+
onSelectSource?: (id: string) => void;
|
|
56
|
+
/** Called when the user toggles a filter on or off. */
|
|
57
|
+
onToggleFilter: (id: string) => void;
|
|
58
|
+
/** Called when the user clears all selections back to defaults. */
|
|
59
|
+
onClear: () => void;
|
|
60
|
+
/**
|
|
61
|
+
* Called when the expanded state changes.
|
|
62
|
+
* Required when `expanded` is a controlled prop.
|
|
63
|
+
*/
|
|
64
|
+
onExpandedChange?: (expanded: boolean) => void;
|
|
65
|
+
/**
|
|
66
|
+
* The list rendered below the filter. The expanded panel is inline: it pushes
|
|
67
|
+
* this content down (the list reflows), it does not overlay it.
|
|
68
|
+
*/
|
|
69
|
+
children?: React.ReactNode;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function ChevronDown({ className }: { className?: string }) {
|
|
73
|
+
return (
|
|
74
|
+
<svg
|
|
75
|
+
viewBox="0 0 12 12"
|
|
76
|
+
className={className}
|
|
77
|
+
fill="none"
|
|
78
|
+
stroke="currentColor"
|
|
79
|
+
strokeWidth={1.5}
|
|
80
|
+
aria-hidden="true"
|
|
81
|
+
>
|
|
82
|
+
<path d="M2 4l4 4 4-4" />
|
|
83
|
+
</svg>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function Close({ className }: { className?: string }) {
|
|
88
|
+
return (
|
|
89
|
+
<svg
|
|
90
|
+
viewBox="0 0 12 12"
|
|
91
|
+
className={className}
|
|
92
|
+
fill="none"
|
|
93
|
+
stroke="currentColor"
|
|
94
|
+
strokeWidth={1.5}
|
|
95
|
+
aria-hidden="true"
|
|
96
|
+
>
|
|
97
|
+
<path d="M3 3l6 6M9 3l-6 6" />
|
|
98
|
+
</svg>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function FilterSheet({
|
|
103
|
+
categories,
|
|
104
|
+
filters,
|
|
105
|
+
sources,
|
|
106
|
+
sourcesNote,
|
|
107
|
+
selectedCategory,
|
|
108
|
+
activeFilters,
|
|
109
|
+
expanded: expandedProp,
|
|
110
|
+
onSelectCategory,
|
|
111
|
+
onSelectSource,
|
|
112
|
+
onToggleFilter,
|
|
113
|
+
onClear,
|
|
114
|
+
onExpandedChange,
|
|
115
|
+
children,
|
|
116
|
+
}: FilterSheetProps) {
|
|
117
|
+
const isControlled = expandedProp !== undefined;
|
|
118
|
+
const [internalOpen, setInternalOpen] = useState(false);
|
|
119
|
+
|
|
120
|
+
const open = isControlled ? expandedProp : internalOpen;
|
|
121
|
+
|
|
122
|
+
const setOpen = useCallback(
|
|
123
|
+
(next: boolean) => {
|
|
124
|
+
if (!isControlled) setInternalOpen(next);
|
|
125
|
+
onExpandedChange?.(next);
|
|
126
|
+
},
|
|
127
|
+
[isControlled, onExpandedChange],
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const defaultCategory = categories[0];
|
|
131
|
+
const isDefault =
|
|
132
|
+
defaultCategory !== undefined && selectedCategory === defaultCategory.id;
|
|
133
|
+
|
|
134
|
+
const defaultSource = sources?.[0];
|
|
135
|
+
const activeSource = sources?.find((s) => s.active);
|
|
136
|
+
const isDefaultSource =
|
|
137
|
+
defaultSource === undefined || activeSource?.id === defaultSource.id;
|
|
138
|
+
|
|
139
|
+
const hasActive =
|
|
140
|
+
!isDefault ||
|
|
141
|
+
activeFilters.size > 0 ||
|
|
142
|
+
(!isDefaultSource && !!activeSource);
|
|
143
|
+
|
|
144
|
+
const clearSource = useCallback(() => {
|
|
145
|
+
if (defaultSource) onSelectSource?.(defaultSource.id);
|
|
146
|
+
}, [defaultSource, onSelectSource]);
|
|
147
|
+
|
|
148
|
+
const clearAll = useCallback(() => {
|
|
149
|
+
onClear();
|
|
150
|
+
clearSource();
|
|
151
|
+
}, [onClear, clearSource]);
|
|
152
|
+
|
|
153
|
+
const activeCategory = categories.find((c) => c.id === selectedCategory);
|
|
154
|
+
|
|
155
|
+
const summaryChips = hasActive ? (
|
|
156
|
+
<>
|
|
157
|
+
{!isDefaultSource && activeSource && (
|
|
158
|
+
<span className="inline-flex items-center rounded-full border border-accent-2 bg-accent-2-soft px-2 py-0.5 text-2xs font-medium text-accent-2">
|
|
159
|
+
{activeSource.label}
|
|
160
|
+
</span>
|
|
161
|
+
)}
|
|
162
|
+
{!isDefault && activeCategory && (
|
|
163
|
+
<Badge tone={activeCategory.tone ?? "neutral"}>
|
|
164
|
+
{activeCategory.label}
|
|
165
|
+
</Badge>
|
|
166
|
+
)}
|
|
167
|
+
{filters
|
|
168
|
+
.filter((f) => activeFilters.has(f.id))
|
|
169
|
+
.map((f) => (
|
|
170
|
+
<span
|
|
171
|
+
key={f.id}
|
|
172
|
+
className="inline-flex items-center rounded-full border border-accent-2 bg-accent-2-soft px-2 py-0.5 text-2xs font-medium text-accent-2"
|
|
173
|
+
>
|
|
174
|
+
{f.label}
|
|
175
|
+
</span>
|
|
176
|
+
))}
|
|
177
|
+
</>
|
|
178
|
+
) : (
|
|
179
|
+
<span className="text-2xs text-fg-subtle">Filters</span>
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const sourceRow = sources && sources.length > 1 && (
|
|
183
|
+
<div className="flex flex-wrap items-center gap-1.5 border-b border-line px-row-inset pb-2 pt-2">
|
|
184
|
+
{sources.map((source) => (
|
|
185
|
+
<button
|
|
186
|
+
key={source.id}
|
|
187
|
+
type="button"
|
|
188
|
+
onClick={() => onSelectSource?.(source.id)}
|
|
189
|
+
className={cn(
|
|
190
|
+
"flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-2xs transition-colors",
|
|
191
|
+
source.active
|
|
192
|
+
? "border-accent-2 bg-accent-2-soft font-medium text-accent-2"
|
|
193
|
+
: "border-line text-fg-muted hover:border-line-strong",
|
|
194
|
+
)}
|
|
195
|
+
>
|
|
196
|
+
{source.label}
|
|
197
|
+
{source.count != null && source.count > 0 && (
|
|
198
|
+
<span className="tabular-nums opacity-70">{source.count}</span>
|
|
199
|
+
)}
|
|
200
|
+
</button>
|
|
201
|
+
))}
|
|
202
|
+
{sourcesNote && (
|
|
203
|
+
<span className="ml-auto shrink-0 text-2xs text-fg-subtle">
|
|
204
|
+
{sourcesNote}
|
|
205
|
+
</span>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const categoryRow = (
|
|
211
|
+
<div className="flex flex-wrap items-center gap-1.5 border-b border-line px-row-inset pb-1.5 pt-1.5">
|
|
212
|
+
{categories.map((cat) => {
|
|
213
|
+
const selected = selectedCategory === cat.id;
|
|
214
|
+
return (
|
|
215
|
+
<button
|
|
216
|
+
key={cat.id}
|
|
217
|
+
type="button"
|
|
218
|
+
onClick={() => onSelectCategory(cat.id)}
|
|
219
|
+
className={cn(
|
|
220
|
+
"shrink-0 rounded-full transition-opacity",
|
|
221
|
+
selected
|
|
222
|
+
? "opacity-100 ring-1 ring-accent-2"
|
|
223
|
+
: "opacity-60 hover:opacity-100",
|
|
224
|
+
)}
|
|
225
|
+
>
|
|
226
|
+
<Badge tone={cat.tone ?? "neutral"}>{cat.label}</Badge>
|
|
227
|
+
</button>
|
|
228
|
+
);
|
|
229
|
+
})}
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const filterRow = (
|
|
234
|
+
<div className="flex flex-wrap items-center gap-1.5 border-b border-line px-row-inset pb-2 pt-1.5">
|
|
235
|
+
{filters.map((f) => {
|
|
236
|
+
const on = activeFilters.has(f.id);
|
|
237
|
+
return (
|
|
238
|
+
<button
|
|
239
|
+
key={f.id}
|
|
240
|
+
type="button"
|
|
241
|
+
onClick={() => onToggleFilter(f.id)}
|
|
242
|
+
className={cn(
|
|
243
|
+
"shrink-0 rounded-full border px-2.5 py-0.5 text-2xs transition-colors",
|
|
244
|
+
on
|
|
245
|
+
? "border-accent-2 bg-accent-2-soft font-medium text-accent-2"
|
|
246
|
+
: "border-line text-fg-muted hover:border-line-strong",
|
|
247
|
+
)}
|
|
248
|
+
>
|
|
249
|
+
{f.label}
|
|
250
|
+
</button>
|
|
251
|
+
);
|
|
252
|
+
})}
|
|
253
|
+
{hasActive && (
|
|
254
|
+
<button
|
|
255
|
+
type="button"
|
|
256
|
+
onClick={clearAll}
|
|
257
|
+
className="ml-auto flex items-center gap-1 px-1 text-2xs text-fg-subtle hover:text-fg-muted"
|
|
258
|
+
>
|
|
259
|
+
<Close className="size-3" />
|
|
260
|
+
Clear
|
|
261
|
+
</button>
|
|
262
|
+
)}
|
|
263
|
+
</div>
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<div className="flex h-full w-full flex-col overflow-hidden">
|
|
268
|
+
{/* The toggle is a div-button (not a real <button>) so the Clear
|
|
269
|
+
control can be a real nested <button> without invalid
|
|
270
|
+
button-in-button nesting. */}
|
|
271
|
+
{/* biome-ignore lint/a11y/useSemanticElements: nested <button> inside would be invalid button-in-button */}
|
|
272
|
+
<div
|
|
273
|
+
role="button"
|
|
274
|
+
tabIndex={0}
|
|
275
|
+
aria-expanded={open}
|
|
276
|
+
aria-label={open ? "Collapse filters" : "Expand filters"}
|
|
277
|
+
onClick={() => setOpen(!open)}
|
|
278
|
+
onKeyDown={(e) => {
|
|
279
|
+
if (!isSelfRowActivation(e)) return;
|
|
280
|
+
e.preventDefault();
|
|
281
|
+
setOpen(!open);
|
|
282
|
+
}}
|
|
283
|
+
className="flex h-section-row w-full shrink-0 cursor-pointer items-center gap-1.5 overflow-x-hidden border-b border-line bg-surface-sunken px-row-inset text-left hover:bg-surface"
|
|
284
|
+
>
|
|
285
|
+
{summaryChips}
|
|
286
|
+
{hasActive && (
|
|
287
|
+
<button
|
|
288
|
+
type="button"
|
|
289
|
+
aria-label="Clear filters"
|
|
290
|
+
onClick={(e) => {
|
|
291
|
+
e.stopPropagation();
|
|
292
|
+
clearAll();
|
|
293
|
+
}}
|
|
294
|
+
className="flex size-6 items-center justify-center text-fg-subtle hover:text-fg-muted"
|
|
295
|
+
>
|
|
296
|
+
<Close className="size-3" />
|
|
297
|
+
</button>
|
|
298
|
+
)}
|
|
299
|
+
<ChevronDown
|
|
300
|
+
className={cn(
|
|
301
|
+
"ml-auto size-3 shrink-0 text-fg-subtle transition-transform duration-200",
|
|
302
|
+
open ? "rotate-180" : "rotate-0",
|
|
303
|
+
)}
|
|
304
|
+
/>
|
|
305
|
+
</div>
|
|
306
|
+
{open && (
|
|
307
|
+
<div className="shrink-0">
|
|
308
|
+
{sourceRow}
|
|
309
|
+
{categoryRow}
|
|
310
|
+
{filterRow}
|
|
311
|
+
</div>
|
|
312
|
+
)}
|
|
313
|
+
<div className="flex-1 overflow-y-auto">{children}</div>
|
|
314
|
+
</div>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Archive,
|
|
3
|
+
FileText,
|
|
4
|
+
Inbox,
|
|
5
|
+
Mails,
|
|
6
|
+
OctagonAlert,
|
|
7
|
+
Send,
|
|
8
|
+
Star,
|
|
9
|
+
Trash2,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import type { ReactNode } from "react";
|
|
12
|
+
|
|
13
|
+
/* ------------------------------------------------------------------ */
|
|
14
|
+
/* Folder role vocabulary (RFC 032 exclusive-folder-appointment, */
|
|
15
|
+
/* issue #976). The canonical roles are a fixed anchor set the user */
|
|
16
|
+
/* appoints an existing folder to — see RoleAppointmentList. A folder */
|
|
17
|
+
/* the account exposes but nobody appointed has no role at all; there */
|
|
18
|
+
/* is no "custom" role member here (unlike the per-folder role picker */
|
|
19
|
+
/* this replaced) because a plain folder simply carries no role. */
|
|
20
|
+
/* ------------------------------------------------------------------ */
|
|
21
|
+
|
|
22
|
+
export type FolderRole =
|
|
23
|
+
| "inbox"
|
|
24
|
+
| "drafts"
|
|
25
|
+
| "sent"
|
|
26
|
+
| "archive"
|
|
27
|
+
| "junk"
|
|
28
|
+
| "trash"
|
|
29
|
+
| "flagged"
|
|
30
|
+
| "all";
|
|
31
|
+
|
|
32
|
+
/* Canonical English labels, matching the `sidebar.${role}` i18n keys
|
|
33
|
+
(mail.json). At the app boundary these get localized; the kit ships the
|
|
34
|
+
English defaults so stories read true. */
|
|
35
|
+
const ROLE_LABEL: Record<FolderRole, string> = {
|
|
36
|
+
inbox: "Inbox",
|
|
37
|
+
drafts: "Drafts",
|
|
38
|
+
sent: "Sent",
|
|
39
|
+
archive: "Archive",
|
|
40
|
+
junk: "Spam",
|
|
41
|
+
trash: "Trash",
|
|
42
|
+
flagged: "Starred",
|
|
43
|
+
all: "All Mail",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Canonical label for a role. */
|
|
47
|
+
export function canonicalRoleLabel(role: FolderRole): string {
|
|
48
|
+
return ROLE_LABEL[role];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Leaf segment of a provider path (`INBOX/Spam` → `Spam`). */
|
|
52
|
+
export function providerLeaf(providerPath: string): string {
|
|
53
|
+
const parts = providerPath.split("/");
|
|
54
|
+
return parts[parts.length - 1] || providerPath;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function roleIcon(role: FolderRole): ReactNode {
|
|
58
|
+
if (role === "inbox") return <Inbox className="size-4" />;
|
|
59
|
+
if (role === "drafts") return <FileText className="size-4" />;
|
|
60
|
+
if (role === "sent") return <Send className="size-4" />;
|
|
61
|
+
if (role === "archive") return <Archive className="size-4" />;
|
|
62
|
+
if (role === "junk") return <OctagonAlert className="size-4" />;
|
|
63
|
+
if (role === "trash") return <Trash2 className="size-4" />;
|
|
64
|
+
if (role === "flagged") return <Star className="size-4" />;
|
|
65
|
+
return <Mails className="size-4" />;
|
|
66
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { InputHTMLAttributes, ReactNode, Ref } from "react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
|
|
4
|
+
export type InputVariant = "default" | "inline";
|
|
5
|
+
|
|
6
|
+
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
7
|
+
/** Optional leading icon (lucide-react element). */
|
|
8
|
+
icon?: ReactNode;
|
|
9
|
+
/**
|
|
10
|
+
* `default` renders the boxed field (border, sunken surface, focus ring).
|
|
11
|
+
* `inline` is a borderless, transparent field for inline filter/search
|
|
12
|
+
* rows where the surrounding container owns the chrome.
|
|
13
|
+
*/
|
|
14
|
+
variant?: InputVariant;
|
|
15
|
+
/** Forwarded to the underlying `<input>`. */
|
|
16
|
+
ref?: Ref<HTMLInputElement>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const wrapperVariants: Record<InputVariant, string> = {
|
|
20
|
+
default: cn(
|
|
21
|
+
"flex h-9 items-center gap-2 rounded-md border border-line bg-surface-sunken px-3 text-sm",
|
|
22
|
+
"focus-within:border-line-strong focus-within:ring-2 focus-within:ring-ring/30 transition-colors",
|
|
23
|
+
),
|
|
24
|
+
inline: "flex items-center gap-2 bg-transparent text-sm",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function Input({
|
|
28
|
+
icon,
|
|
29
|
+
className,
|
|
30
|
+
variant = "default",
|
|
31
|
+
ref,
|
|
32
|
+
...props
|
|
33
|
+
}: InputProps) {
|
|
34
|
+
return (
|
|
35
|
+
<div className={cn(wrapperVariants[variant], className)}>
|
|
36
|
+
{icon && <span className="text-fg-subtle shrink-0">{icon}</span>}
|
|
37
|
+
<input
|
|
38
|
+
ref={ref}
|
|
39
|
+
className="min-w-0 flex-1 bg-transparent text-fg placeholder:text-fg-subtle outline-none"
|
|
40
|
+
{...props}
|
|
41
|
+
/>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import type { IntelligenceData } from "./intelligence-panel.js";
|
|
3
|
+
import { IntelligencePanel } from "./intelligence-panel.js";
|
|
4
|
+
|
|
5
|
+
const base: IntelligenceData = {
|
|
6
|
+
sender: {
|
|
7
|
+
name: "Alex Rivera",
|
|
8
|
+
email: "alex@example.com",
|
|
9
|
+
trust: "wellknown",
|
|
10
|
+
firstSeenLabel: "Jan 2025",
|
|
11
|
+
},
|
|
12
|
+
authenticity: {
|
|
13
|
+
verdict: "aligned",
|
|
14
|
+
fromDomain: "example.com",
|
|
15
|
+
dkimDomain: "example.com",
|
|
16
|
+
summary: "We verified this message was really sent by example.com.",
|
|
17
|
+
},
|
|
18
|
+
category: { value: "Personal" },
|
|
19
|
+
similar: [],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const meta: Meta<typeof IntelligencePanel> = {
|
|
23
|
+
title: "Screens/Kit/IntelligencePanel",
|
|
24
|
+
component: IntelligencePanel,
|
|
25
|
+
parameters: { layout: "centered" },
|
|
26
|
+
};
|
|
27
|
+
export default meta;
|
|
28
|
+
|
|
29
|
+
type Story = StoryObj<typeof IntelligencePanel>;
|
|
30
|
+
|
|
31
|
+
export const Aligned: Story = {
|
|
32
|
+
args: { data: base },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const CautionNoSignal: Story = {
|
|
36
|
+
args: {
|
|
37
|
+
data: {
|
|
38
|
+
...base,
|
|
39
|
+
sender: {
|
|
40
|
+
name: "Notifications",
|
|
41
|
+
email: "no-reply@unknown-source.example",
|
|
42
|
+
trust: "unknown",
|
|
43
|
+
firstSeenLabel: "today",
|
|
44
|
+
},
|
|
45
|
+
authenticity: {
|
|
46
|
+
verdict: "caution",
|
|
47
|
+
fromDomain: "unknown-source.example",
|
|
48
|
+
summary:
|
|
49
|
+
"We can't verify the sender of this email, which could mean it's from an insecure source.",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const Impersonation: Story = {
|
|
56
|
+
args: {
|
|
57
|
+
data: {
|
|
58
|
+
...base,
|
|
59
|
+
sender: {
|
|
60
|
+
name: "Your Bank",
|
|
61
|
+
email: "security@your-bank.example",
|
|
62
|
+
trust: "unknown",
|
|
63
|
+
firstSeenLabel: "today",
|
|
64
|
+
},
|
|
65
|
+
category: { value: "Phishing" },
|
|
66
|
+
authenticity: {
|
|
67
|
+
verdict: "mismatch",
|
|
68
|
+
fromDomain: "your-bank.example",
|
|
69
|
+
dkimDomain: "mailer.suspicious.example",
|
|
70
|
+
claimedBrand: "Your Bank",
|
|
71
|
+
summary:
|
|
72
|
+
'The display name claims "Your Bank", but this message was actually sent from mailer.suspicious.example — not your-bank.example. Real senders use their own address.',
|
|
73
|
+
similarCount: 4,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const UnreadableSender: Story = {
|
|
80
|
+
args: {
|
|
81
|
+
data: {
|
|
82
|
+
...base,
|
|
83
|
+
sender: {
|
|
84
|
+
name: "Mailbox Admin",
|
|
85
|
+
email: "missing_mailbox@missing_domain",
|
|
86
|
+
trust: "unknown",
|
|
87
|
+
firstSeenLabel: "today",
|
|
88
|
+
addressUnverified: true,
|
|
89
|
+
},
|
|
90
|
+
category: { value: "Phishing" },
|
|
91
|
+
authenticity: {
|
|
92
|
+
verdict: "mismatch",
|
|
93
|
+
fromDomain: "",
|
|
94
|
+
addressUnreadable: true,
|
|
95
|
+
summary:
|
|
96
|
+
"We couldn't read this sender's address, so we can't confirm who really sent this message.",
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const WithSimilarMessages: Story = {
|
|
103
|
+
args: {
|
|
104
|
+
similarLinkComponent: ({
|
|
105
|
+
mailboxId,
|
|
106
|
+
messageId,
|
|
107
|
+
className,
|
|
108
|
+
ariaLabel,
|
|
109
|
+
children,
|
|
110
|
+
}) => (
|
|
111
|
+
<a
|
|
112
|
+
href={`/mail/${mailboxId}?selectedMessageId=${messageId}`}
|
|
113
|
+
className={className}
|
|
114
|
+
aria-label={ariaLabel}
|
|
115
|
+
>
|
|
116
|
+
{children}
|
|
117
|
+
</a>
|
|
118
|
+
),
|
|
119
|
+
data: {
|
|
120
|
+
...base,
|
|
121
|
+
similar: [
|
|
122
|
+
{
|
|
123
|
+
id: "msg-1",
|
|
124
|
+
mailboxId: "mbx-1",
|
|
125
|
+
fromName: "Alex Rivera",
|
|
126
|
+
subject: "Re: Q3 planning notes",
|
|
127
|
+
timeLabel: "Jan 17",
|
|
128
|
+
matched: "subject",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: "msg-2",
|
|
132
|
+
mailboxId: "mbx-1",
|
|
133
|
+
fromName: "Billing",
|
|
134
|
+
subject: "Your invoice is ready",
|
|
135
|
+
timeLabel: "Yesterday",
|
|
136
|
+
matched: "body",
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: "msg-3",
|
|
140
|
+
mailboxId: "mbx-2",
|
|
141
|
+
fromName: "",
|
|
142
|
+
subject: "(No subject)",
|
|
143
|
+
timeLabel: "Dec 4, 2024",
|
|
144
|
+
matched: "sender",
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
};
|