@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,173 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Lock, Mail, Search } from "lucide-react";
|
|
3
|
+
import { Avatar } from "./avatar.js";
|
|
4
|
+
import { Badge } from "./badge.js";
|
|
5
|
+
import { Button } from "./button.js";
|
|
6
|
+
import { Card, CardBody, CardHeader, CardTitle } from "./card.js";
|
|
7
|
+
import { FieldLabel } from "./field-label.js";
|
|
8
|
+
import { Input } from "./input.js";
|
|
9
|
+
import { ListItem } from "./list-item.js";
|
|
10
|
+
import { SecuritySelect } from "./security-select.js";
|
|
11
|
+
import { Select } from "./select.js";
|
|
12
|
+
|
|
13
|
+
const meta: Meta = {
|
|
14
|
+
title: "Primitives",
|
|
15
|
+
};
|
|
16
|
+
export default meta;
|
|
17
|
+
|
|
18
|
+
type Story = StoryObj;
|
|
19
|
+
|
|
20
|
+
export const Buttons: Story = {
|
|
21
|
+
render: () => (
|
|
22
|
+
<div className="flex flex-wrap items-center gap-3 p-8">
|
|
23
|
+
<Button variant="primary">Primary</Button>
|
|
24
|
+
<Button variant="secondary">Secondary</Button>
|
|
25
|
+
<Button variant="ghost">Ghost</Button>
|
|
26
|
+
<Button variant="danger">Danger</Button>
|
|
27
|
+
<Button variant="primary" size="sm" icon={<Mail className="size-3.5" />}>
|
|
28
|
+
Compose
|
|
29
|
+
</Button>
|
|
30
|
+
<Button variant="primary" disabled>
|
|
31
|
+
Disabled
|
|
32
|
+
</Button>
|
|
33
|
+
</div>
|
|
34
|
+
),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const Badges: Story = {
|
|
38
|
+
render: () => (
|
|
39
|
+
<div className="flex flex-wrap items-center gap-3 p-8">
|
|
40
|
+
<Badge tone="neutral">newsletter</Badge>
|
|
41
|
+
<Badge tone="accent">personal</Badge>
|
|
42
|
+
<Badge tone="positive" dot>
|
|
43
|
+
Known sender
|
|
44
|
+
</Badge>
|
|
45
|
+
<Badge tone="warning">social</Badge>
|
|
46
|
+
<Badge tone="danger">spam</Badge>
|
|
47
|
+
</div>
|
|
48
|
+
),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const Avatars: Story = {
|
|
52
|
+
render: () => (
|
|
53
|
+
<div className="flex items-center gap-4 p-8">
|
|
54
|
+
<Avatar name="Alice Tan" size="sm" />
|
|
55
|
+
<Avatar name="Priya Natarajan" size="md" />
|
|
56
|
+
<Avatar name="Marcus Webb" size="lg" />
|
|
57
|
+
<Avatar name="Remit CI" email="ci@remit.example" size="md" />
|
|
58
|
+
</div>
|
|
59
|
+
),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const Inputs: Story = {
|
|
63
|
+
render: () => (
|
|
64
|
+
<div className="max-w-sm space-y-3 p-8">
|
|
65
|
+
<Input placeholder="Plain input" />
|
|
66
|
+
<Input icon={<Search className="size-4" />} placeholder="Search mail" />
|
|
67
|
+
</div>
|
|
68
|
+
),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const InlineInputs: Story = {
|
|
72
|
+
render: () => (
|
|
73
|
+
<div className="max-w-sm space-y-3 p-8">
|
|
74
|
+
<div className="border-b border-line px-3 py-2">
|
|
75
|
+
<Input variant="inline" placeholder="Filter folders" />
|
|
76
|
+
</div>
|
|
77
|
+
<div className="border-b border-line px-3 py-2">
|
|
78
|
+
<Input
|
|
79
|
+
variant="inline"
|
|
80
|
+
icon={<Search className="size-4" />}
|
|
81
|
+
placeholder="Move to…"
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const LabeledFields: Story = {
|
|
89
|
+
render: () => (
|
|
90
|
+
<div className="max-w-sm space-y-3 p-8">
|
|
91
|
+
<div>
|
|
92
|
+
<FieldLabel htmlFor="host">Host</FieldLabel>
|
|
93
|
+
<Input id="host" placeholder="imap.example.com" />
|
|
94
|
+
</div>
|
|
95
|
+
<div>
|
|
96
|
+
<FieldLabel htmlFor="security">Security</FieldLabel>
|
|
97
|
+
<SecuritySelect id="security" defaultValue="tls" />
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const SecuritySelects: Story = {
|
|
104
|
+
render: () => (
|
|
105
|
+
<div className="max-w-sm space-y-3 p-8">
|
|
106
|
+
<SecuritySelect defaultValue="tls" />
|
|
107
|
+
<SecuritySelect defaultValue="starttls" />
|
|
108
|
+
<SecuritySelect defaultValue="none" />
|
|
109
|
+
</div>
|
|
110
|
+
),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const Selects: Story = {
|
|
114
|
+
render: () => (
|
|
115
|
+
<div className="max-w-sm space-y-3 p-8">
|
|
116
|
+
<Select defaultValue="tls">
|
|
117
|
+
<option value="tls">TLS/SSL</option>
|
|
118
|
+
<option value="starttls">STARTTLS</option>
|
|
119
|
+
<option value="none">None (insecure)</option>
|
|
120
|
+
</Select>
|
|
121
|
+
<Select icon={<Lock className="size-4" />} defaultValue="starttls">
|
|
122
|
+
<option value="tls">TLS/SSL</option>
|
|
123
|
+
<option value="starttls">STARTTLS</option>
|
|
124
|
+
<option value="none">None (insecure)</option>
|
|
125
|
+
</Select>
|
|
126
|
+
</div>
|
|
127
|
+
),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const Cards: Story = {
|
|
131
|
+
render: () => (
|
|
132
|
+
<div className="max-w-md p-8">
|
|
133
|
+
<Card raised>
|
|
134
|
+
<CardHeader>
|
|
135
|
+
<CardTitle>Card title</CardTitle>
|
|
136
|
+
</CardHeader>
|
|
137
|
+
<CardBody>
|
|
138
|
+
Hairline border, minimal shadow. Surfaces and text are fully
|
|
139
|
+
token-driven so this flips cleanly between light and dark.
|
|
140
|
+
</CardBody>
|
|
141
|
+
</Card>
|
|
142
|
+
</div>
|
|
143
|
+
),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const ListItems: Story = {
|
|
147
|
+
render: () => (
|
|
148
|
+
<div className="max-w-md divide-y divide-line p-8">
|
|
149
|
+
<ListItem
|
|
150
|
+
unread
|
|
151
|
+
leading={<Avatar name="Priya Natarajan" size="md" />}
|
|
152
|
+
trailing="08:52"
|
|
153
|
+
>
|
|
154
|
+
<div className="text-sm font-semibold text-fg">Priya Natarajan</div>
|
|
155
|
+
<div className="text-sm text-fg">Q3 roadmap review</div>
|
|
156
|
+
<p className="line-clamp-1 text-xs text-fg-subtle">
|
|
157
|
+
Sharing the agenda ahead of Thursday…
|
|
158
|
+
</p>
|
|
159
|
+
</ListItem>
|
|
160
|
+
<ListItem
|
|
161
|
+
active
|
|
162
|
+
leading={<Avatar name="Marcus Webb" size="md" />}
|
|
163
|
+
trailing="Wed"
|
|
164
|
+
>
|
|
165
|
+
<div className="text-sm font-medium text-fg-muted">Marcus Webb</div>
|
|
166
|
+
<div className="text-sm text-fg-muted">Re: Reading pane density</div>
|
|
167
|
+
<p className="line-clamp-1 text-xs text-fg-subtle">
|
|
168
|
+
Strong +1 on tightening the rows…
|
|
169
|
+
</p>
|
|
170
|
+
</ListItem>
|
|
171
|
+
</div>
|
|
172
|
+
),
|
|
173
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
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 { PullToRefresh } from "./pull-to-refresh.js";
|
|
6
|
+
|
|
7
|
+
const noop = () => Promise.resolve();
|
|
8
|
+
const list = createElement("ul", null, "messages");
|
|
9
|
+
|
|
10
|
+
describe("PullToRefresh", () => {
|
|
11
|
+
it("renders its children inside the pull wrapper", () => {
|
|
12
|
+
const html = renderToString(
|
|
13
|
+
createElement(PullToRefresh, {
|
|
14
|
+
onRefresh: noop,
|
|
15
|
+
// biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
|
|
16
|
+
children: list,
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
assert.match(html, /messages/);
|
|
20
|
+
assert.match(html, /ptr/);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("keeps the gesture pullable while not refreshing", () => {
|
|
24
|
+
const html = renderToString(
|
|
25
|
+
createElement(PullToRefresh, {
|
|
26
|
+
onRefresh: noop,
|
|
27
|
+
isRefreshing: false,
|
|
28
|
+
// biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
|
|
29
|
+
children: list,
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
assert.match(html, /ptr/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("still renders its children while a refresh is in flight", () => {
|
|
36
|
+
const html = renderToString(
|
|
37
|
+
createElement(PullToRefresh, {
|
|
38
|
+
onRefresh: noop,
|
|
39
|
+
isRefreshing: true,
|
|
40
|
+
// biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
|
|
41
|
+
children: list,
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
assert.match(html, /messages/);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { PullToRefresh } from "./pull-to-refresh.js";
|
|
3
|
+
|
|
4
|
+
const rows = Array.from({ length: 20 }, (_, i) => `Message ${i + 1}`);
|
|
5
|
+
|
|
6
|
+
function MessageList() {
|
|
7
|
+
return (
|
|
8
|
+
<ul className="divide-y divide-line bg-surface">
|
|
9
|
+
{rows.map((row) => (
|
|
10
|
+
<li key={row} className="px-4 py-3 text-sm text-fg">
|
|
11
|
+
{row}
|
|
12
|
+
</li>
|
|
13
|
+
))}
|
|
14
|
+
</ul>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function PhoneFrame({ children }: { children: React.ReactNode }) {
|
|
19
|
+
return (
|
|
20
|
+
<div className="h-[480px] max-w-md overflow-y-auto rounded-lg border border-line">
|
|
21
|
+
{children}
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const meta: Meta<typeof PullToRefresh> = {
|
|
27
|
+
title: "Primitives/PullToRefresh",
|
|
28
|
+
component: PullToRefresh,
|
|
29
|
+
parameters: { layout: "padded" },
|
|
30
|
+
render: (args) => (
|
|
31
|
+
<PhoneFrame>
|
|
32
|
+
<PullToRefresh {...args}>
|
|
33
|
+
<MessageList />
|
|
34
|
+
</PullToRefresh>
|
|
35
|
+
</PhoneFrame>
|
|
36
|
+
),
|
|
37
|
+
};
|
|
38
|
+
export default meta;
|
|
39
|
+
|
|
40
|
+
type Story = StoryObj<typeof PullToRefresh>;
|
|
41
|
+
|
|
42
|
+
export const Idle: Story = {
|
|
43
|
+
name: "Idle — pull to refresh",
|
|
44
|
+
args: {
|
|
45
|
+
onRefresh: () => new Promise((resolve) => setTimeout(resolve, 1200)),
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const Refreshing: Story = {
|
|
50
|
+
name: "Refreshing — gesture suspended",
|
|
51
|
+
args: {
|
|
52
|
+
onRefresh: () => Promise.resolve(),
|
|
53
|
+
isRefreshing: true,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const DesktopNoop: Story = {
|
|
58
|
+
name: "Desktop — gesture inert",
|
|
59
|
+
parameters: {
|
|
60
|
+
viewport: { defaultViewport: "desktop" },
|
|
61
|
+
docs: {
|
|
62
|
+
description: {
|
|
63
|
+
story:
|
|
64
|
+
"At desktop widths (lg, 1024px and up) the gesture is inert and the list renders directly — there is no touch list to pull.",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
args: {
|
|
69
|
+
onRefresh: () => Promise.resolve(),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import ReactPullToRefresh from "react-simple-pull-to-refresh";
|
|
4
|
+
|
|
5
|
+
export interface PullToRefreshProps {
|
|
6
|
+
children: ReactElement;
|
|
7
|
+
onRefresh: () => Promise<unknown>;
|
|
8
|
+
isRefreshing?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const useMatchMedia = (query: string): boolean => {
|
|
12
|
+
const [matches, setMatches] = useState(() => {
|
|
13
|
+
if (typeof window === "undefined" || !window.matchMedia) return false;
|
|
14
|
+
return window.matchMedia(query).matches;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
19
|
+
const mql = window.matchMedia(query);
|
|
20
|
+
setMatches(mql.matches);
|
|
21
|
+
const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
|
|
22
|
+
mql.addEventListener("change", handler);
|
|
23
|
+
return () => mql.removeEventListener("change", handler);
|
|
24
|
+
}, [query]);
|
|
25
|
+
|
|
26
|
+
return matches;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wraps a scrollable list with a pull-to-refresh gesture on mobile. Below the
|
|
31
|
+
* desktop breakpoint (Tailwind `lg`, 1024px) a downward pull at the top of the
|
|
32
|
+
* list fires `onRefresh`; at desktop widths the gesture is inert and children
|
|
33
|
+
* render directly, since there is no touch list to pull.
|
|
34
|
+
*
|
|
35
|
+
* Presentational: the caller owns what refreshing means via `onRefresh` and
|
|
36
|
+
* surfaces in-flight state through `isRefreshing`.
|
|
37
|
+
*/
|
|
38
|
+
export const PullToRefresh = ({
|
|
39
|
+
children,
|
|
40
|
+
onRefresh,
|
|
41
|
+
isRefreshing,
|
|
42
|
+
}: PullToRefreshProps): ReactElement => {
|
|
43
|
+
const isDesktop = useMatchMedia("(min-width: 1024px)");
|
|
44
|
+
|
|
45
|
+
if (isDesktop) {
|
|
46
|
+
return children;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<ReactPullToRefresh onRefresh={onRefresh} isPullable={!isRefreshing}>
|
|
51
|
+
{children}
|
|
52
|
+
</ReactPullToRefresh>
|
|
53
|
+
);
|
|
54
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
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 { QuotedText } from "./quoted-text.js";
|
|
6
|
+
|
|
7
|
+
describe("QuotedText", () => {
|
|
8
|
+
it("renders nothing when there is no text or html", () => {
|
|
9
|
+
assert.equal(renderToString(createElement(QuotedText, { text: "" })), "");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("shows the attribution and a collapse toggle by default", () => {
|
|
13
|
+
const html = renderToString(
|
|
14
|
+
createElement(QuotedText, {
|
|
15
|
+
text: "hello",
|
|
16
|
+
senderName: "Dana Whitfield",
|
|
17
|
+
date: "Jun 24, 2026",
|
|
18
|
+
}),
|
|
19
|
+
);
|
|
20
|
+
assert.match(html, /Dana Whitfield on Jun 24, 2026 wrote:/);
|
|
21
|
+
assert.match(html, /aria-expanded="false"/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("falls back to a generic label when there is no attribution", () => {
|
|
25
|
+
const html = renderToString(createElement(QuotedText, { text: "hello" }));
|
|
26
|
+
assert.match(html, /Show quoted text/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("keeps the quoted body collapsed initially", () => {
|
|
30
|
+
const html = renderToString(
|
|
31
|
+
createElement(QuotedText, {
|
|
32
|
+
text: "secret quoted line",
|
|
33
|
+
html: "<p>secret quoted html</p>",
|
|
34
|
+
senderName: "Dana",
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
assert.doesNotMatch(html, /secret quoted/);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { QuotedText } from "./quoted-text.js";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof QuotedText> = {
|
|
5
|
+
title: "Mail/QuotedText",
|
|
6
|
+
component: QuotedText,
|
|
7
|
+
parameters: { layout: "padded" },
|
|
8
|
+
};
|
|
9
|
+
export default meta;
|
|
10
|
+
|
|
11
|
+
type Story = StoryObj<typeof QuotedText>;
|
|
12
|
+
|
|
13
|
+
const quotedHtml = `
|
|
14
|
+
<p>Thanks for the quick turnaround on the proposal.</p>
|
|
15
|
+
<p>A couple of follow-ups before Friday:</p>
|
|
16
|
+
<ul>
|
|
17
|
+
<li>Can we confirm the <strong>delivery window</strong>?</li>
|
|
18
|
+
<li>See the earlier thread for the pricing notes:</li>
|
|
19
|
+
</ul>
|
|
20
|
+
<blockquote><p>The original quote stands through end of quarter.</p></blockquote>
|
|
21
|
+
<p>More context on the <a href="https://example.com/brief">shared brief</a>.</p>
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
const quotedPlainText = [
|
|
25
|
+
"Thanks for the quick turnaround on the proposal.",
|
|
26
|
+
"",
|
|
27
|
+
"A couple of follow-ups before Friday:",
|
|
28
|
+
"- Can we confirm the delivery window?",
|
|
29
|
+
"- See the earlier thread for the pricing notes.",
|
|
30
|
+
].join("\n");
|
|
31
|
+
|
|
32
|
+
export const Collapsed: Story = {
|
|
33
|
+
args: {
|
|
34
|
+
text: quotedPlainText,
|
|
35
|
+
html: quotedHtml,
|
|
36
|
+
senderName: "Dana Whitfield",
|
|
37
|
+
date: "Jun 24, 2026, 9:14 AM",
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const ExpandedHtml: Story = {
|
|
42
|
+
args: {
|
|
43
|
+
text: quotedPlainText,
|
|
44
|
+
html: quotedHtml,
|
|
45
|
+
senderName: "Dana Whitfield",
|
|
46
|
+
date: "Jun 24, 2026, 9:14 AM",
|
|
47
|
+
},
|
|
48
|
+
play: async ({ canvasElement }) => {
|
|
49
|
+
canvasElement.querySelector("button")?.click();
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const PlainTextOnly: Story = {
|
|
54
|
+
args: {
|
|
55
|
+
text: quotedPlainText,
|
|
56
|
+
senderName: "Dana Whitfield",
|
|
57
|
+
},
|
|
58
|
+
play: async ({ canvasElement }) => {
|
|
59
|
+
canvasElement.querySelector("button")?.click();
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const NoAttribution: Story = {
|
|
64
|
+
args: {
|
|
65
|
+
text: quotedPlainText,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
|
|
4
|
+
export interface QuotedTextProps {
|
|
5
|
+
/** Plain-text quote, used when no `html` is supplied. */
|
|
6
|
+
text: string;
|
|
7
|
+
/**
|
|
8
|
+
* Sanitized quoted HTML. Must already be DOMPurify'd by the caller — this
|
|
9
|
+
* component renders it via `dangerouslySetInnerHTML` and never sanitizes.
|
|
10
|
+
* Never pass raw, untrusted HTML here.
|
|
11
|
+
*/
|
|
12
|
+
html?: string;
|
|
13
|
+
senderName?: string;
|
|
14
|
+
date?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Collapsible quoted-message block for reply/forward compose. Renders a muted
|
|
19
|
+
* attribution toggle ("<sender> on <date> wrote:") that expands to reveal the
|
|
20
|
+
* quoted body. Presentational and self-contained: collapse state is internal,
|
|
21
|
+
* sanitization is the caller's responsibility.
|
|
22
|
+
*/
|
|
23
|
+
export const QuotedText = ({
|
|
24
|
+
text,
|
|
25
|
+
html,
|
|
26
|
+
senderName,
|
|
27
|
+
date,
|
|
28
|
+
}: QuotedTextProps) => {
|
|
29
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
30
|
+
|
|
31
|
+
if (!text && !html) return null;
|
|
32
|
+
|
|
33
|
+
const attribution = [senderName, date].filter(Boolean).join(" on ");
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="mt-2 px-3 pb-2">
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
40
|
+
aria-expanded={isExpanded}
|
|
41
|
+
className="flex items-center gap-1 text-xs text-fg-muted hover:text-fg transition-colors"
|
|
42
|
+
>
|
|
43
|
+
{isExpanded ? (
|
|
44
|
+
<ChevronDown className="size-3" />
|
|
45
|
+
) : (
|
|
46
|
+
<ChevronRight className="size-3" />
|
|
47
|
+
)}
|
|
48
|
+
{attribution ? `${attribution} wrote:` : "Show quoted text"}
|
|
49
|
+
</button>
|
|
50
|
+
{isExpanded && (
|
|
51
|
+
<blockquote className="mt-2 pl-3 border-l-2 border-fg-subtle/30 text-sm text-fg-muted [&_a]:text-accent [&_a]:underline [&_blockquote]:pl-3 [&_blockquote]:border-l-2 [&_blockquote]:border-fg-subtle/30">
|
|
52
|
+
{html ? (
|
|
53
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: rendering sanitized HTML via email-sanitizer
|
|
54
|
+
<div dangerouslySetInnerHTML={{ __html: html }} />
|
|
55
|
+
) : (
|
|
56
|
+
text.split("\n").map((line, i) => (
|
|
57
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: list is static, no stable id
|
|
58
|
+
<p key={i} className="min-h-[1.2em]">
|
|
59
|
+
{line}
|
|
60
|
+
</p>
|
|
61
|
+
))
|
|
62
|
+
)}
|
|
63
|
+
</blockquote>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Inbox } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { Kbd } from "./kbd.js";
|
|
5
|
+
|
|
6
|
+
export interface ReadingPaneEmptyProps {
|
|
7
|
+
/** Headline. */
|
|
8
|
+
message?: string;
|
|
9
|
+
/** Override the centered icon. */
|
|
10
|
+
icon?: ReactNode;
|
|
11
|
+
/** Hide the keyboard hint line (e.g. on touch surfaces). */
|
|
12
|
+
showHints?: boolean;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The reading pane's zero-state: a centered Inbox icon, a prompt, and the
|
|
18
|
+
* j/k/Enter navigation hints. One source of truth for both the brief and the
|
|
19
|
+
* mailbox routes (they used to drift between a bare text EmptyState and the
|
|
20
|
+
* designed treatment).
|
|
21
|
+
*/
|
|
22
|
+
export function ReadingPaneEmpty({
|
|
23
|
+
message = "Select a thread to read",
|
|
24
|
+
icon,
|
|
25
|
+
showHints = true,
|
|
26
|
+
className,
|
|
27
|
+
}: ReadingPaneEmptyProps) {
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
className={cn(
|
|
31
|
+
"flex flex-1 flex-col items-center justify-center text-center",
|
|
32
|
+
className,
|
|
33
|
+
)}
|
|
34
|
+
>
|
|
35
|
+
{icon ?? <Inbox className="size-10 text-fg-subtle" />}
|
|
36
|
+
<p className="mt-3 text-sm text-fg-muted">{message}</p>
|
|
37
|
+
{showHints && (
|
|
38
|
+
<p className="text-2xs text-fg-subtle">
|
|
39
|
+
<Kbd>j</Kbd> / <Kbd>k</Kbd> to move, <Kbd>Enter</Kbd> to open
|
|
40
|
+
</p>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|