@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,178 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import type {
|
|
4
|
+
BriefCategoryFilter,
|
|
5
|
+
ThreadRowData,
|
|
6
|
+
ThreadSection,
|
|
7
|
+
} from "./app-shell-types.js";
|
|
8
|
+
import { BriefSections } from "./brief-sections.js";
|
|
9
|
+
import type { FilterSheetSource } from "./filter-sheet.js";
|
|
10
|
+
import { ComfortableRow } from "./message-row.js";
|
|
11
|
+
|
|
12
|
+
function newsletterRow(i: number): ThreadRowData {
|
|
13
|
+
return {
|
|
14
|
+
id: `n${i}`,
|
|
15
|
+
accountId: "a1",
|
|
16
|
+
fromName: `Digest ${i}`,
|
|
17
|
+
fromEmail: `digest${i}@news.example`,
|
|
18
|
+
subject: `This week, edition ${i}`,
|
|
19
|
+
snippet: "Stories you might have missed.",
|
|
20
|
+
timeLabel: "Thu",
|
|
21
|
+
isRead: true,
|
|
22
|
+
category: "newsletter",
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sections: ThreadSection[] = [
|
|
27
|
+
{
|
|
28
|
+
id: "flagged",
|
|
29
|
+
label: "Flagged",
|
|
30
|
+
threads: [
|
|
31
|
+
{
|
|
32
|
+
id: "f1",
|
|
33
|
+
accountId: "a1",
|
|
34
|
+
fromName: "Dana Lopez",
|
|
35
|
+
fromEmail: "dana@example.com",
|
|
36
|
+
subject: "Offsite logistics",
|
|
37
|
+
snippet: "Final headcount for the venue.",
|
|
38
|
+
timeLabel: "Tue",
|
|
39
|
+
isRead: false,
|
|
40
|
+
starred: true,
|
|
41
|
+
category: "personal",
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "personal",
|
|
47
|
+
label: "Personal",
|
|
48
|
+
threads: [
|
|
49
|
+
{
|
|
50
|
+
id: "p1",
|
|
51
|
+
accountId: "a1",
|
|
52
|
+
fromName: "Priya Nair",
|
|
53
|
+
fromEmail: "priya@example.com",
|
|
54
|
+
subject: "Design review tomorrow",
|
|
55
|
+
snippet: "Can we move it to 2pm? I have a conflict.",
|
|
56
|
+
timeLabel: "8:15",
|
|
57
|
+
isRead: false,
|
|
58
|
+
category: "personal",
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "transactional",
|
|
64
|
+
label: "Transactional",
|
|
65
|
+
threads: [
|
|
66
|
+
{
|
|
67
|
+
id: "x1",
|
|
68
|
+
accountId: "a1",
|
|
69
|
+
fromName: "Sam Okafor",
|
|
70
|
+
fromEmail: "sam@example.com",
|
|
71
|
+
subject: "Contract signed",
|
|
72
|
+
snippet: "Attaching the countersigned PDF.",
|
|
73
|
+
timeLabel: "9:01",
|
|
74
|
+
isRead: false,
|
|
75
|
+
hasAttachment: true,
|
|
76
|
+
category: "transactional",
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "newsletter",
|
|
82
|
+
label: "Newsletter",
|
|
83
|
+
threads: Array.from({ length: 14 }, (_, i) => newsletterRow(i + 1)),
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const meta: Meta<typeof BriefSections> = {
|
|
88
|
+
title: "Screens/Kit/BriefSections",
|
|
89
|
+
component: BriefSections,
|
|
90
|
+
parameters: { layout: "fullscreen" },
|
|
91
|
+
args: {
|
|
92
|
+
sections,
|
|
93
|
+
Row: ComfortableRow,
|
|
94
|
+
briefCategory: "all",
|
|
95
|
+
onSelectThread: () => undefined,
|
|
96
|
+
onSelectBriefCategory: () => undefined,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
export default meta;
|
|
100
|
+
|
|
101
|
+
type Story = StoryObj<typeof BriefSections>;
|
|
102
|
+
|
|
103
|
+
export const Desktop: Story = {
|
|
104
|
+
render: (args) => (
|
|
105
|
+
<div className="flex h-screen w-96 flex-col border-r border-line">
|
|
106
|
+
<BriefSections {...args} />
|
|
107
|
+
</div>
|
|
108
|
+
),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const Mobile: Story = {
|
|
112
|
+
render: (args) => (
|
|
113
|
+
<div className="flex h-[844px] w-[390px] flex-col border border-line">
|
|
114
|
+
<BriefSections {...args} />
|
|
115
|
+
</div>
|
|
116
|
+
),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* (a) "All" scope: one capped section per category, each with its header. This
|
|
121
|
+
* is the cross-account aggregate where the section headers earn their keep.
|
|
122
|
+
*/
|
|
123
|
+
export const AllScopeWithHeaders: Story = {
|
|
124
|
+
args: { briefCategory: "all" },
|
|
125
|
+
render: (args) => (
|
|
126
|
+
<div className="flex h-screen w-96 flex-col border-r border-line">
|
|
127
|
+
<BriefSections {...args} />
|
|
128
|
+
</div>
|
|
129
|
+
),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* (b) Single-category filter: narrowed to Newsletter, the list renders FLAT with
|
|
134
|
+
* NO section header — the header would be redundant once a single category is
|
|
135
|
+
* selected. This is the behavior the live brief now inherits from the kit.
|
|
136
|
+
*/
|
|
137
|
+
export const SingleCategoryFlat: Story = {
|
|
138
|
+
args: { briefCategory: "newsletter" },
|
|
139
|
+
render: (args) => (
|
|
140
|
+
<div className="flex h-screen w-96 flex-col border-r border-line">
|
|
141
|
+
<BriefSections {...args} />
|
|
142
|
+
</div>
|
|
143
|
+
),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const accountSources: FilterSheetSource[] = [
|
|
147
|
+
{ id: "all", label: "All", active: true },
|
|
148
|
+
{ id: "a1", label: "work", count: 3 },
|
|
149
|
+
{ id: "a2", label: "personal", count: 8 },
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* (c) Account-source filtering (n>1): the cross-account brief exposes an account
|
|
154
|
+
* pill row above the categories. The row only appears with more than one source.
|
|
155
|
+
* Selecting a source is single-select (encoded via each source's `active` flag).
|
|
156
|
+
*/
|
|
157
|
+
export const AccountSources: Story = {
|
|
158
|
+
render: (args) => {
|
|
159
|
+
const [source, setSource] = useState("all");
|
|
160
|
+
const [category, setCategory] = useState<BriefCategoryFilter>("all");
|
|
161
|
+
return (
|
|
162
|
+
<div className="flex h-screen w-96 flex-col border-r border-line">
|
|
163
|
+
<BriefSections
|
|
164
|
+
{...args}
|
|
165
|
+
briefCategory={category}
|
|
166
|
+
onSelectBriefCategory={setCategory}
|
|
167
|
+
sources={accountSources.map((s) => ({
|
|
168
|
+
...s,
|
|
169
|
+
active: s.id === source,
|
|
170
|
+
}))}
|
|
171
|
+
sourcesNote="+1 muted"
|
|
172
|
+
onSelectSource={setSource}
|
|
173
|
+
defaultExpanded
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
BriefCategoryFilter,
|
|
4
|
+
ThreadRowData,
|
|
5
|
+
ThreadSection,
|
|
6
|
+
} from "./app-shell-types.js";
|
|
7
|
+
import { briefCategories, categoryTone } from "./app-shell-types.js";
|
|
8
|
+
import { BriefSection } from "./brief-section.js";
|
|
9
|
+
import {
|
|
10
|
+
FilterSheet,
|
|
11
|
+
type FilterSheetCategory,
|
|
12
|
+
type FilterSheetFilter,
|
|
13
|
+
type FilterSheetSource,
|
|
14
|
+
} from "./filter-sheet.js";
|
|
15
|
+
import type { BriefRowComponent } from "./message-row.js";
|
|
16
|
+
|
|
17
|
+
/* Composable brief filters — each is an additive predicate over a thread row. */
|
|
18
|
+
type BriefFilterId = "unread" | "attachment" | "contacts" | "today";
|
|
19
|
+
|
|
20
|
+
/* "Today" prefers the real `sentDate` timestamp; it falls back to the fixture
|
|
21
|
+
convention that same-day rows render a HH:MM timeLabel (fixtures carry no
|
|
22
|
+
sentDate). */
|
|
23
|
+
function isTodayRow(t: ThreadRowData): boolean {
|
|
24
|
+
if (t.sentDate != null) {
|
|
25
|
+
return new Date(t.sentDate).toDateString() === new Date().toDateString();
|
|
26
|
+
}
|
|
27
|
+
return /^\d{1,2}:\d{2}$/.test(t.timeLabel);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const briefFilterDefs: ReadonlyArray<{
|
|
31
|
+
id: BriefFilterId;
|
|
32
|
+
label: string;
|
|
33
|
+
match: (t: ThreadRowData) => boolean;
|
|
34
|
+
}> = [
|
|
35
|
+
{ id: "unread", label: "Unread", match: (t) => !t.isRead },
|
|
36
|
+
{
|
|
37
|
+
id: "attachment",
|
|
38
|
+
label: "Has attachment",
|
|
39
|
+
match: (t) => !!t.hasAttachment,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "contacts",
|
|
43
|
+
label: "From contacts",
|
|
44
|
+
match: (t) => t.trust === "vip" || t.trust === "wellknown",
|
|
45
|
+
},
|
|
46
|
+
{ id: "today", label: "Today", match: isTodayRow },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The brief's attribute chips as plain `{ id, label }` (no predicates) — the
|
|
51
|
+
* single source the `briefFilterConfig` preset reuses so the live filter row and
|
|
52
|
+
* the preset can never diverge.
|
|
53
|
+
*/
|
|
54
|
+
export const briefFilterChips: FilterSheetFilter[] = briefFilterDefs.map(
|
|
55
|
+
({ id, label }) => ({ id, label }),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
export interface BriefSectionsProps {
|
|
59
|
+
sections: ThreadSection[];
|
|
60
|
+
briefCategory?: BriefCategoryFilter;
|
|
61
|
+
selectedThreadId?: string;
|
|
62
|
+
Row: BriefRowComponent;
|
|
63
|
+
onSelectThread?: (id: string) => void;
|
|
64
|
+
onSelectBriefCategory?: (category: BriefCategoryFilter) => void;
|
|
65
|
+
/**
|
|
66
|
+
* Account/source pills, passed straight through to the FilterSheet. Selection
|
|
67
|
+
* is encoded per source via `active`; the row only renders when more than one
|
|
68
|
+
* source is supplied (the cross-account brief). Single-account views omit it.
|
|
69
|
+
*/
|
|
70
|
+
sources?: FilterSheetSource[];
|
|
71
|
+
/** Note rendered alongside the source pills (e.g. "+1 muted"). */
|
|
72
|
+
sourcesNote?: string;
|
|
73
|
+
/** Called when the user selects a source/account pill. */
|
|
74
|
+
onSelectSource?: (id: string) => void;
|
|
75
|
+
/** Seeds the filter panel open on first render (stories / deep links). */
|
|
76
|
+
defaultExpanded?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The daily-brief list body: category pills (single-select) + attribute chips
|
|
81
|
+
* (additive) + one capped section per category (see {@link BriefSection}). Owns
|
|
82
|
+
* its own filter state; the category axis is controlled via
|
|
83
|
+
* `briefCategory`/`onSelectBriefCategory`. Consumers pre-filter `sections`
|
|
84
|
+
* (e.g. by search) and pass a `Row` renderer; the web client reuses this so the
|
|
85
|
+
* real brief and the Storybook prototype stay in lockstep.
|
|
86
|
+
*/
|
|
87
|
+
export function BriefSections({
|
|
88
|
+
sections,
|
|
89
|
+
briefCategory = "all",
|
|
90
|
+
selectedThreadId,
|
|
91
|
+
Row,
|
|
92
|
+
onSelectThread,
|
|
93
|
+
onSelectBriefCategory,
|
|
94
|
+
sources,
|
|
95
|
+
sourcesNote,
|
|
96
|
+
onSelectSource,
|
|
97
|
+
defaultExpanded = false,
|
|
98
|
+
}: BriefSectionsProps) {
|
|
99
|
+
const [active, setActive] = useState<ReadonlySet<BriefFilterId>>(new Set());
|
|
100
|
+
const [sheetExpanded, setSheetExpanded] = useState(defaultExpanded);
|
|
101
|
+
|
|
102
|
+
const toggleFilter = (id: BriefFilterId) => {
|
|
103
|
+
setActive((prev) => {
|
|
104
|
+
const next = new Set(prev);
|
|
105
|
+
if (next.has(id)) next.delete(id);
|
|
106
|
+
else next.add(id);
|
|
107
|
+
return next;
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const predicates = briefFilterDefs.filter((f) => active.has(f.id));
|
|
112
|
+
const matches = (t: ThreadRowData) =>
|
|
113
|
+
(briefCategory === "all" || t.category === briefCategory) &&
|
|
114
|
+
predicates.every((f) => f.match(t));
|
|
115
|
+
|
|
116
|
+
// One section per category only earns its keep at the "all" scope. Narrow to
|
|
117
|
+
// a single category and the headers are redundant: render a plain flat list.
|
|
118
|
+
const showSections = briefCategory === "all";
|
|
119
|
+
|
|
120
|
+
const filtered = sections
|
|
121
|
+
.map((section) => ({
|
|
122
|
+
...section,
|
|
123
|
+
threads: section.threads.filter(matches),
|
|
124
|
+
}))
|
|
125
|
+
.filter((section) => section.threads.length > 0);
|
|
126
|
+
|
|
127
|
+
const flatRows = sections.flatMap((s) => s.threads).filter(matches);
|
|
128
|
+
|
|
129
|
+
const sheetCategories: FilterSheetCategory[] = briefCategories.map((cat) => ({
|
|
130
|
+
id: cat.id,
|
|
131
|
+
label: cat.label,
|
|
132
|
+
tone: cat.id === "all" ? "neutral" : categoryTone[cat.id],
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
const sheetFilters = briefFilterChips;
|
|
136
|
+
|
|
137
|
+
const clearFilters = () => {
|
|
138
|
+
onSelectBriefCategory?.("all");
|
|
139
|
+
setActive(new Set());
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const empty = showSections ? filtered.length === 0 : flatRows.length === 0;
|
|
143
|
+
|
|
144
|
+
const listBody = (
|
|
145
|
+
<>
|
|
146
|
+
{showSections ? (
|
|
147
|
+
filtered.map((section) => (
|
|
148
|
+
<BriefSection
|
|
149
|
+
key={section.id}
|
|
150
|
+
section={section}
|
|
151
|
+
Row={Row}
|
|
152
|
+
selectedThreadId={selectedThreadId}
|
|
153
|
+
onSelectThread={onSelectThread}
|
|
154
|
+
/>
|
|
155
|
+
))
|
|
156
|
+
) : (
|
|
157
|
+
<div className="divide-y divide-line">
|
|
158
|
+
{flatRows.map((t) => (
|
|
159
|
+
<Row
|
|
160
|
+
key={t.id}
|
|
161
|
+
thread={t}
|
|
162
|
+
active={t.id === selectedThreadId}
|
|
163
|
+
onClick={() => onSelectThread?.(t.id)}
|
|
164
|
+
/>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
{empty && (
|
|
169
|
+
<div className="px-row-inset py-6 text-center text-2xs text-fg-subtle">
|
|
170
|
+
No threads match these filters.
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
</>
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// One source of truth for both breakpoints: a click-to-expand Filters bar
|
|
177
|
+
// that pushes the list down. The desktop brief used to render three
|
|
178
|
+
// permanently-expanded pill rows above the list with two redundant "All"
|
|
179
|
+
// pills (#783); the FilterSheet renders a single Filters control and one
|
|
180
|
+
// category scope.
|
|
181
|
+
return (
|
|
182
|
+
<FilterSheet
|
|
183
|
+
categories={sheetCategories}
|
|
184
|
+
filters={sheetFilters}
|
|
185
|
+
sources={sources}
|
|
186
|
+
sourcesNote={sourcesNote}
|
|
187
|
+
selectedCategory={briefCategory}
|
|
188
|
+
activeFilters={active}
|
|
189
|
+
expanded={sheetExpanded}
|
|
190
|
+
onExpandedChange={setSheetExpanded}
|
|
191
|
+
onSelectCategory={(id) =>
|
|
192
|
+
onSelectBriefCategory?.(id as BriefCategoryFilter)
|
|
193
|
+
}
|
|
194
|
+
onSelectSource={onSelectSource}
|
|
195
|
+
onToggleFilter={(id) => toggleFilter(id as BriefFilterId)}
|
|
196
|
+
onClear={clearFilters}
|
|
197
|
+
>
|
|
198
|
+
{listBody}
|
|
199
|
+
</FilterSheet>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
|
|
4
|
+
type Variant = "primary" | "secondary" | "ghost" | "danger";
|
|
5
|
+
type Size = "sm" | "md";
|
|
6
|
+
|
|
7
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
8
|
+
variant?: Variant;
|
|
9
|
+
size?: Size;
|
|
10
|
+
/** Optional leading icon (lucide-react element). */
|
|
11
|
+
icon?: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const base =
|
|
15
|
+
"inline-flex items-center justify-center gap-2 rounded-md font-medium whitespace-nowrap transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-surface disabled:opacity-50 disabled:pointer-events-none";
|
|
16
|
+
|
|
17
|
+
const variants: Record<Variant, string> = {
|
|
18
|
+
primary: "bg-accent text-accent-fg hover:bg-accent-hover",
|
|
19
|
+
secondary:
|
|
20
|
+
"bg-surface text-fg border border-line hover:bg-surface-sunken hover:border-line-strong",
|
|
21
|
+
ghost: "text-fg-muted hover:bg-surface-sunken hover:text-fg",
|
|
22
|
+
danger: "bg-danger text-accent-fg hover:opacity-90",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const sizes: Record<Size, string> = {
|
|
26
|
+
sm: "h-7 px-2.5 text-xs",
|
|
27
|
+
md: "h-9 px-3.5 text-sm",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function Button({
|
|
31
|
+
variant = "primary",
|
|
32
|
+
size = "md",
|
|
33
|
+
icon,
|
|
34
|
+
className,
|
|
35
|
+
children,
|
|
36
|
+
...props
|
|
37
|
+
}: ButtonProps) {
|
|
38
|
+
return (
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
className={cn(base, variants[variant], sizes[size], className)}
|
|
42
|
+
{...props}
|
|
43
|
+
>
|
|
44
|
+
{icon}
|
|
45
|
+
{children}
|
|
46
|
+
</button>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { HTMLAttributes } from "react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
|
|
4
|
+
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
/** Slightly lifted surface with a hairline border. */
|
|
6
|
+
raised?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Card({ raised, className, ...props }: CardProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
className={cn(
|
|
13
|
+
"rounded-sm border border-line bg-surface",
|
|
14
|
+
raised ? "bg-surface-raised shadow-sm" : "",
|
|
15
|
+
className,
|
|
16
|
+
)}
|
|
17
|
+
{...props}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function CardHeader({
|
|
23
|
+
className,
|
|
24
|
+
...props
|
|
25
|
+
}: HTMLAttributes<HTMLDivElement>) {
|
|
26
|
+
return <div className={cn("px-4 pt-3 pb-2", className)} {...props} />;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function CardTitle({
|
|
30
|
+
className,
|
|
31
|
+
...props
|
|
32
|
+
}: HTMLAttributes<HTMLHeadingElement>) {
|
|
33
|
+
return (
|
|
34
|
+
<h3 className={cn("text-md font-semibold text-fg", className)} {...props} />
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function CardBody({
|
|
39
|
+
className,
|
|
40
|
+
...props
|
|
41
|
+
}: HTMLAttributes<HTMLDivElement>) {
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
className={cn("px-4 pb-3 text-sm text-fg-muted", className)}
|
|
45
|
+
{...props}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
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 { CategoryBadge, getCategoryLabel } from "./category-badge.js";
|
|
6
|
+
|
|
7
|
+
describe("getCategoryLabel", () => {
|
|
8
|
+
it("returns null for undefined (no badge)", () => {
|
|
9
|
+
assert.equal(getCategoryLabel(undefined), null);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns null for 'personal' (default fallback, no badge)", () => {
|
|
13
|
+
assert.equal(getCategoryLabel("personal"), null);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("maps each non-personal category to its label", () => {
|
|
17
|
+
assert.equal(getCategoryLabel("newsletter"), "newsletter");
|
|
18
|
+
assert.equal(getCategoryLabel("marketing"), "marketing");
|
|
19
|
+
assert.equal(getCategoryLabel("automated"), "notification");
|
|
20
|
+
assert.equal(getCategoryLabel("transactional"), "receipt");
|
|
21
|
+
assert.equal(getCategoryLabel("social"), "social");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("CategoryBadge", () => {
|
|
26
|
+
it("renders nothing for personal", () => {
|
|
27
|
+
assert.equal(
|
|
28
|
+
renderToString(createElement(CategoryBadge, { category: "personal" })),
|
|
29
|
+
"",
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("renders the label with an accessible category name", () => {
|
|
34
|
+
const html = renderToString(
|
|
35
|
+
createElement(CategoryBadge, { category: "newsletter" }),
|
|
36
|
+
);
|
|
37
|
+
assert.match(html, /newsletter/);
|
|
38
|
+
assert.match(html, /aria-label="Category: newsletter"/);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { CategoryBadge, type MessageCategory } from "./category-badge.js";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof CategoryBadge> = {
|
|
5
|
+
title: "Mail/CategoryBadge",
|
|
6
|
+
component: CategoryBadge,
|
|
7
|
+
parameters: { layout: "centered" },
|
|
8
|
+
};
|
|
9
|
+
export default meta;
|
|
10
|
+
|
|
11
|
+
type Story = StoryObj<typeof CategoryBadge>;
|
|
12
|
+
|
|
13
|
+
const categories: MessageCategory[] = [
|
|
14
|
+
"newsletter",
|
|
15
|
+
"marketing",
|
|
16
|
+
"automated",
|
|
17
|
+
"transactional",
|
|
18
|
+
"social",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const AllCategories: Story = {
|
|
22
|
+
render: () => (
|
|
23
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
24
|
+
{categories.map((category) => (
|
|
25
|
+
<CategoryBadge key={category} category={category} size="md" />
|
|
26
|
+
))}
|
|
27
|
+
</div>
|
|
28
|
+
),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const PersonalRendersNothing: Story = {
|
|
32
|
+
args: { category: "personal", size: "md" },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const ListRowSize: Story = {
|
|
36
|
+
args: { category: "newsletter", size: "sm" },
|
|
37
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
|
|
3
|
+
export type MessageCategory =
|
|
4
|
+
| "personal"
|
|
5
|
+
| "newsletter"
|
|
6
|
+
| "marketing"
|
|
7
|
+
| "automated"
|
|
8
|
+
| "transactional"
|
|
9
|
+
| "social";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Display label for a message category badge.
|
|
13
|
+
*
|
|
14
|
+
* Per EDD #232: `personal` is the default fallback and never renders a badge,
|
|
15
|
+
* so it has no entry here. `transactional` shows as "receipt" and `automated`
|
|
16
|
+
* shows as "notification" — wording chosen so the badge reads naturally next
|
|
17
|
+
* to a subject line.
|
|
18
|
+
*/
|
|
19
|
+
const CATEGORY_LABELS: Record<Exclude<MessageCategory, "personal">, string> = {
|
|
20
|
+
newsletter: "newsletter",
|
|
21
|
+
marketing: "marketing",
|
|
22
|
+
automated: "notification",
|
|
23
|
+
transactional: "receipt",
|
|
24
|
+
social: "social",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const getCategoryLabel = (
|
|
28
|
+
category: MessageCategory | undefined,
|
|
29
|
+
): string | null => {
|
|
30
|
+
if (!category || category === "personal") return null;
|
|
31
|
+
return CATEGORY_LABELS[category];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface CategoryBadgeProps {
|
|
35
|
+
category: MessageCategory | undefined;
|
|
36
|
+
/** Larger size for the open-message header. List rows use the default. */
|
|
37
|
+
size?: "sm" | "md";
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Inline category label for non-personal mail. Renders nothing for `personal`
|
|
43
|
+
* or absent values so the existing list-row layout is unaffected for the
|
|
44
|
+
* common case.
|
|
45
|
+
*
|
|
46
|
+
* Visual style matches the muted, low-emphasis aesthetic of the surrounding
|
|
47
|
+
* row chrome — no color tabs, no full-width pill backgrounds.
|
|
48
|
+
*/
|
|
49
|
+
export const CategoryBadge = ({
|
|
50
|
+
category,
|
|
51
|
+
size = "sm",
|
|
52
|
+
className,
|
|
53
|
+
}: CategoryBadgeProps) => {
|
|
54
|
+
const label = getCategoryLabel(category);
|
|
55
|
+
if (!label) return null;
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label provides useful context for assistive tech despite generic span role
|
|
59
|
+
<span
|
|
60
|
+
className={cn(
|
|
61
|
+
"inline-flex items-center rounded border border-line bg-surface-sunken/50 font-medium uppercase tracking-wide text-fg-muted shrink-0",
|
|
62
|
+
size === "sm" && "px-1.5 py-0 text-2xs leading-4",
|
|
63
|
+
size === "md" && "px-2 py-0.5 text-xs",
|
|
64
|
+
className,
|
|
65
|
+
)}
|
|
66
|
+
aria-label={`Category: ${label}`}
|
|
67
|
+
>
|
|
68
|
+
{label}
|
|
69
|
+
</span>
|
|
70
|
+
);
|
|
71
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
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 { Checkbox } from "./checkbox.js";
|
|
6
|
+
|
|
7
|
+
const noop = () => {};
|
|
8
|
+
|
|
9
|
+
describe("Checkbox", () => {
|
|
10
|
+
it("renders a real checkbox input", () => {
|
|
11
|
+
const html = renderToString(
|
|
12
|
+
createElement(Checkbox, {
|
|
13
|
+
"aria-label": "Select row",
|
|
14
|
+
checked: false,
|
|
15
|
+
onChange: noop,
|
|
16
|
+
}),
|
|
17
|
+
);
|
|
18
|
+
assert.match(html, /type="checkbox"/);
|
|
19
|
+
assert.match(html, /aria-label="Select row"/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("reflects the checked state on the input", () => {
|
|
23
|
+
const checked = renderToString(
|
|
24
|
+
createElement(Checkbox, {
|
|
25
|
+
"aria-label": "x",
|
|
26
|
+
checked: true,
|
|
27
|
+
onChange: noop,
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
assert.match(checked, /checked=""/);
|
|
31
|
+
|
|
32
|
+
const unchecked = renderToString(
|
|
33
|
+
createElement(Checkbox, {
|
|
34
|
+
"aria-label": "x",
|
|
35
|
+
checked: false,
|
|
36
|
+
onChange: noop,
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
assert.doesNotMatch(unchecked, /checked=""/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("wraps in a label with the touch target when given label text", () => {
|
|
43
|
+
const html = renderToString(
|
|
44
|
+
createElement(Checkbox, {
|
|
45
|
+
label: "Keep me posted",
|
|
46
|
+
checked: false,
|
|
47
|
+
onChange: noop,
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
assert.match(html, /<label/);
|
|
51
|
+
assert.match(html, /min-h-11/);
|
|
52
|
+
assert.match(html, /Keep me posted/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("renders just the control when label and description are omitted", () => {
|
|
56
|
+
const html = renderToString(
|
|
57
|
+
createElement(Checkbox, {
|
|
58
|
+
"aria-label": "bare",
|
|
59
|
+
checked: false,
|
|
60
|
+
onChange: noop,
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
assert.doesNotMatch(html, /<label/);
|
|
64
|
+
});
|
|
65
|
+
});
|