@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,77 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Checkbox } from "./checkbox.js";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Checkbox> = {
|
|
6
|
+
title: "Components/Checkbox",
|
|
7
|
+
component: Checkbox,
|
|
8
|
+
parameters: { layout: "padded" },
|
|
9
|
+
decorators: [
|
|
10
|
+
(Story) => (
|
|
11
|
+
<div className="mx-auto max-w-sm rounded-xl border border-line bg-surface p-4">
|
|
12
|
+
<Story />
|
|
13
|
+
</div>
|
|
14
|
+
),
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
export default meta;
|
|
18
|
+
|
|
19
|
+
type Story = StoryObj<typeof Checkbox>;
|
|
20
|
+
|
|
21
|
+
export const Labelled: Story = {
|
|
22
|
+
render: () => {
|
|
23
|
+
const [checked, setChecked] = useState(true);
|
|
24
|
+
return (
|
|
25
|
+
<Checkbox
|
|
26
|
+
label="Move these out of Spam"
|
|
27
|
+
description="You can undo this later"
|
|
28
|
+
checked={checked}
|
|
29
|
+
onChange={(e) => setChecked(e.target.checked)}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const Unchecked: Story = {
|
|
36
|
+
render: () => {
|
|
37
|
+
const [checked, setChecked] = useState(false);
|
|
38
|
+
return (
|
|
39
|
+
<Checkbox
|
|
40
|
+
label="Keep me posted"
|
|
41
|
+
checked={checked}
|
|
42
|
+
onChange={(e) => setChecked(e.target.checked)}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const Indeterminate: Story = {
|
|
49
|
+
render: () => (
|
|
50
|
+
<Checkbox
|
|
51
|
+
label="Some selected"
|
|
52
|
+
description="Tri-state, e.g. a select-all header"
|
|
53
|
+
indeterminate
|
|
54
|
+
checked={false}
|
|
55
|
+
onChange={() => {}}
|
|
56
|
+
/>
|
|
57
|
+
),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const BareControl: Story = {
|
|
61
|
+
name: "Bare control (no label)",
|
|
62
|
+
render: () => {
|
|
63
|
+
const [checked, setChecked] = useState(true);
|
|
64
|
+
return (
|
|
65
|
+
<div className="flex items-center gap-3">
|
|
66
|
+
<Checkbox
|
|
67
|
+
aria-label="Select row"
|
|
68
|
+
checked={checked}
|
|
69
|
+
onChange={(e) => setChecked(e.target.checked)}
|
|
70
|
+
/>
|
|
71
|
+
<span className="text-sm text-fg-muted">
|
|
72
|
+
Embedded in a row that owns the touch target
|
|
73
|
+
</span>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Check, Minus } from "lucide-react";
|
|
2
|
+
import type { InputHTMLAttributes, ReactNode } from "react";
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
|
|
6
|
+
export interface CheckboxProps
|
|
7
|
+
extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "size"> {
|
|
8
|
+
/** Visible label; when set, the whole row is the ~44px touch target. */
|
|
9
|
+
label?: ReactNode;
|
|
10
|
+
/** Secondary line under the label. */
|
|
11
|
+
description?: ReactNode;
|
|
12
|
+
/** Tri-state tick: renders a dash. The input stays a real checkbox. */
|
|
13
|
+
indeterminate?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const box =
|
|
17
|
+
"peer relative size-5 shrink-0 cursor-pointer appearance-none rounded-md border border-line-strong bg-surface outline-none transition-colors checked:border-positive checked:bg-positive indeterminate:border-positive indeterminate:bg-positive focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-surface";
|
|
18
|
+
|
|
19
|
+
const mark =
|
|
20
|
+
"pointer-events-none absolute inset-0 m-auto size-3.5 text-accent-fg";
|
|
21
|
+
|
|
22
|
+
export function Checkbox({
|
|
23
|
+
label,
|
|
24
|
+
description,
|
|
25
|
+
indeterminate = false,
|
|
26
|
+
checked,
|
|
27
|
+
onChange,
|
|
28
|
+
className,
|
|
29
|
+
...props
|
|
30
|
+
}: CheckboxProps) {
|
|
31
|
+
const ref = useRef<HTMLInputElement>(null);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (ref.current) ref.current.indeterminate = indeterminate;
|
|
35
|
+
}, [indeterminate]);
|
|
36
|
+
|
|
37
|
+
const control = (
|
|
38
|
+
<span className="relative inline-flex">
|
|
39
|
+
<input
|
|
40
|
+
ref={ref}
|
|
41
|
+
type="checkbox"
|
|
42
|
+
checked={checked}
|
|
43
|
+
onChange={onChange}
|
|
44
|
+
className={box}
|
|
45
|
+
{...props}
|
|
46
|
+
/>
|
|
47
|
+
<Check className={cn(mark, "hidden peer-checked:block")} aria-hidden />
|
|
48
|
+
<Minus
|
|
49
|
+
className={cn(mark, "hidden peer-indeterminate:block")}
|
|
50
|
+
aria-hidden
|
|
51
|
+
/>
|
|
52
|
+
</span>
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (label === undefined && description === undefined) {
|
|
56
|
+
return <span className={className}>{control}</span>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
// biome-ignore lint/a11y/noLabelWithoutControl: label wraps a custom component that handles focus
|
|
61
|
+
<label
|
|
62
|
+
className={cn(
|
|
63
|
+
"flex min-h-11 cursor-pointer items-center gap-3 text-left",
|
|
64
|
+
className,
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
{control}
|
|
68
|
+
<span className="flex min-w-0 flex-col">
|
|
69
|
+
{label !== undefined && (
|
|
70
|
+
<span className="text-sm text-fg">{label}</span>
|
|
71
|
+
)}
|
|
72
|
+
{description !== undefined && (
|
|
73
|
+
<span className="text-xs text-fg-subtle">{description}</span>
|
|
74
|
+
)}
|
|
75
|
+
</span>
|
|
76
|
+
</label>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
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 { ComposeActionBar } from "./compose-action-bar.js";
|
|
6
|
+
|
|
7
|
+
const baseProps = {
|
|
8
|
+
onSend: () => undefined,
|
|
9
|
+
onDiscard: () => undefined,
|
|
10
|
+
sending: false,
|
|
11
|
+
canSend: true,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("ComposeActionBar", () => {
|
|
15
|
+
it("renders Send and Discard, never disabled", () => {
|
|
16
|
+
const html = renderToString(createElement(ComposeActionBar, baseProps));
|
|
17
|
+
assert.match(html, /Send/);
|
|
18
|
+
assert.match(html, /aria-label="Discard"/);
|
|
19
|
+
assert.doesNotMatch(html, /disabled=""/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("keeps Send pressable (no disabled) when it cannot send", () => {
|
|
23
|
+
const html = renderToString(
|
|
24
|
+
createElement(ComposeActionBar, {
|
|
25
|
+
...baseProps,
|
|
26
|
+
canSend: false,
|
|
27
|
+
unavailableReason: "SMTP not configured",
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
assert.match(html, /Send/);
|
|
31
|
+
assert.doesNotMatch(html, /disabled=""/);
|
|
32
|
+
assert.match(html, /SMTP not configured/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("uses aria-busy while sending", () => {
|
|
36
|
+
const html = renderToString(
|
|
37
|
+
createElement(ComposeActionBar, { ...baseProps, sending: true }),
|
|
38
|
+
);
|
|
39
|
+
assert.match(html, /aria-busy="true"/);
|
|
40
|
+
assert.doesNotMatch(html, /disabled=""/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("keeps the Send pill at a fixed min-height so it does not clip", () => {
|
|
44
|
+
const html = renderToString(createElement(ComposeActionBar, baseProps));
|
|
45
|
+
assert.match(html, /min-h-11/);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { ComposeActionBar } from "./compose-action-bar.js";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof ComposeActionBar> = {
|
|
5
|
+
title: "Mail/ComposeActionBar",
|
|
6
|
+
component: ComposeActionBar,
|
|
7
|
+
parameters: { layout: "padded" },
|
|
8
|
+
args: {
|
|
9
|
+
onSend: () => undefined,
|
|
10
|
+
onDiscard: () => undefined,
|
|
11
|
+
sending: false,
|
|
12
|
+
canSend: true,
|
|
13
|
+
saveStatus: "idle",
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
export default meta;
|
|
17
|
+
|
|
18
|
+
type Story = StoryObj<typeof ComposeActionBar>;
|
|
19
|
+
|
|
20
|
+
export const Ready: Story = {};
|
|
21
|
+
|
|
22
|
+
export const Saving: Story = { args: { saveStatus: "saving" } };
|
|
23
|
+
|
|
24
|
+
export const Saved: Story = { args: { saveStatus: "saved" } };
|
|
25
|
+
|
|
26
|
+
export const Sending: Story = { args: { sending: true } };
|
|
27
|
+
|
|
28
|
+
export const CannotSend: Story = {
|
|
29
|
+
name: "Cannot send — stays pressable",
|
|
30
|
+
args: {
|
|
31
|
+
canSend: false,
|
|
32
|
+
unavailableReason: "SMTP not configured",
|
|
33
|
+
onUnavailable: () => undefined,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Loader2, Send, Trash2 } from "lucide-react";
|
|
2
|
+
import { Button } from "./button.js";
|
|
3
|
+
|
|
4
|
+
export type ComposeSaveStatus = "idle" | "saving" | "saved" | "error";
|
|
5
|
+
|
|
6
|
+
export interface ComposeActionBarProps {
|
|
7
|
+
onSend: () => void;
|
|
8
|
+
onDiscard: () => void;
|
|
9
|
+
sending: boolean;
|
|
10
|
+
canSend: boolean;
|
|
11
|
+
saveStatus?: ComposeSaveStatus;
|
|
12
|
+
/**
|
|
13
|
+
* Why sending is unavailable (e.g. "SMTP not configured"). Shown on the
|
|
14
|
+
* Send button as a tooltip; surfaced to the user on press via
|
|
15
|
+
* `onUnavailable` rather than disabling the control.
|
|
16
|
+
*/
|
|
17
|
+
unavailableReason?: string;
|
|
18
|
+
/** Called when Send is pressed while it cannot act — explain, don't disable. */
|
|
19
|
+
onUnavailable?: (reason: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SaveStatusIndicator = ({ status }: { status: ComposeSaveStatus }) => {
|
|
23
|
+
if (status === "saving") {
|
|
24
|
+
return (
|
|
25
|
+
<span className="animate-pulse text-xs text-fg-muted">Saving...</span>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (status === "saved") {
|
|
29
|
+
return <span className="text-xs text-fg-muted">Draft saved</span>;
|
|
30
|
+
}
|
|
31
|
+
if (status === "error") {
|
|
32
|
+
return <span className="text-xs text-danger">Save failed</span>;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compose footer: Send + Discard. Send stays pressable even when it can't act
|
|
39
|
+
* yet — on press it explains why via `onUnavailable` instead of being
|
|
40
|
+
* disabled (never-disable). The pill keeps a fixed min-height so it never
|
|
41
|
+
* clips below the fold on mobile.
|
|
42
|
+
*/
|
|
43
|
+
export function ComposeActionBar({
|
|
44
|
+
onSend,
|
|
45
|
+
onDiscard,
|
|
46
|
+
sending,
|
|
47
|
+
canSend,
|
|
48
|
+
saveStatus = "idle",
|
|
49
|
+
unavailableReason,
|
|
50
|
+
onUnavailable,
|
|
51
|
+
}: ComposeActionBarProps) {
|
|
52
|
+
return (
|
|
53
|
+
<div className="flex items-center justify-between border-t border-line px-3 py-2">
|
|
54
|
+
<div className="flex items-center gap-3">
|
|
55
|
+
<Button
|
|
56
|
+
variant="primary"
|
|
57
|
+
size="md"
|
|
58
|
+
aria-busy={sending}
|
|
59
|
+
title={!canSend ? unavailableReason : undefined}
|
|
60
|
+
className="min-h-11 rounded-full px-4"
|
|
61
|
+
icon={
|
|
62
|
+
sending ? (
|
|
63
|
+
<Loader2 className="size-4 animate-spin" />
|
|
64
|
+
) : (
|
|
65
|
+
<Send className="size-4" />
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
onClick={() => {
|
|
69
|
+
if (sending) return;
|
|
70
|
+
if (!canSend) {
|
|
71
|
+
if (unavailableReason) onUnavailable?.(unavailableReason);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
onSend();
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
Send
|
|
78
|
+
</Button>
|
|
79
|
+
<SaveStatusIndicator status={saveStatus} />
|
|
80
|
+
</div>
|
|
81
|
+
<Button
|
|
82
|
+
variant="ghost"
|
|
83
|
+
size="md"
|
|
84
|
+
aria-busy={sending}
|
|
85
|
+
aria-label="Discard"
|
|
86
|
+
className="min-h-11 min-w-11 px-2 hover:text-danger"
|
|
87
|
+
icon={<Trash2 className="size-4" />}
|
|
88
|
+
onClick={() => {
|
|
89
|
+
if (sending) return;
|
|
90
|
+
onDiscard();
|
|
91
|
+
}}
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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 { ComposeFormShell, composeModeLabels } from "./compose-form-shell.js";
|
|
6
|
+
|
|
7
|
+
describe("ComposeFormShell", () => {
|
|
8
|
+
it("renders header, body and action bar slots in order", () => {
|
|
9
|
+
const html = renderToString(
|
|
10
|
+
createElement(ComposeFormShell, {
|
|
11
|
+
header: createElement("div", null, "HEADER"),
|
|
12
|
+
actionBar: createElement("div", null, "ACTIONBAR"),
|
|
13
|
+
// biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
|
|
14
|
+
children: createElement("div", null, "BODY"),
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
assert.ok(html.indexOf("HEADER") < html.indexOf("BODY"));
|
|
18
|
+
assert.ok(html.indexOf("BODY") < html.indexOf("ACTIONBAR"));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("renders the banner and quoted slots when supplied", () => {
|
|
22
|
+
const html = renderToString(
|
|
23
|
+
createElement(ComposeFormShell, {
|
|
24
|
+
banner: createElement("div", null, "BANNER"),
|
|
25
|
+
header: createElement("div", null, "HEADER"),
|
|
26
|
+
quoted: createElement("div", null, "QUOTED"),
|
|
27
|
+
actionBar: createElement("div", null, "BAR"),
|
|
28
|
+
// biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
|
|
29
|
+
children: createElement("div", null, "BODY"),
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
assert.match(html, /BANNER/);
|
|
33
|
+
assert.match(html, /QUOTED/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("omits the quoted region when not supplied", () => {
|
|
37
|
+
const html = renderToString(
|
|
38
|
+
createElement(ComposeFormShell, {
|
|
39
|
+
header: createElement("div", null, "H"),
|
|
40
|
+
actionBar: createElement("div", null, "B"),
|
|
41
|
+
// biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
|
|
42
|
+
children: createElement("div", null, "BODY"),
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
assert.doesNotMatch(html, /pb-2/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("exposes mode labels for every compose mode", () => {
|
|
49
|
+
assert.equal(composeModeLabels.new, "New Message");
|
|
50
|
+
assert.equal(composeModeLabels.reply, "Reply");
|
|
51
|
+
assert.equal(composeModeLabels.reply_all, "Reply All");
|
|
52
|
+
assert.equal(composeModeLabels.forward, "Forward");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { ComposeActionBar } from "./compose-action-bar.js";
|
|
3
|
+
import { ComposeFormShell, composeModeLabels } from "./compose-form-shell.js";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof ComposeFormShell> = {
|
|
6
|
+
title: "Mail/ComposeForm",
|
|
7
|
+
component: ComposeFormShell,
|
|
8
|
+
parameters: { layout: "fullscreen" },
|
|
9
|
+
};
|
|
10
|
+
export default meta;
|
|
11
|
+
|
|
12
|
+
type Story = StoryObj<typeof ComposeFormShell>;
|
|
13
|
+
|
|
14
|
+
const Header = ({ title }: { title: string }) => (
|
|
15
|
+
<div className="space-y-1 border-b border-line px-3 py-2">
|
|
16
|
+
<div className="text-xs font-semibold text-fg-muted">{title}</div>
|
|
17
|
+
<div className="flex items-center gap-2 text-sm">
|
|
18
|
+
<span className="w-12 text-fg-muted">To</span>
|
|
19
|
+
<span>alex@example.com</span>
|
|
20
|
+
</div>
|
|
21
|
+
<div className="flex items-center gap-2 text-sm">
|
|
22
|
+
<span className="w-12 text-fg-muted">Subject</span>
|
|
23
|
+
<span>Q3 planning notes</span>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const Body = () => (
|
|
29
|
+
<div className="min-h-[120px] px-3 py-2 text-sm">
|
|
30
|
+
Hi Alex, here are the notes from today…
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const bar = (
|
|
35
|
+
<ComposeActionBar
|
|
36
|
+
onSend={() => undefined}
|
|
37
|
+
onDiscard={() => undefined}
|
|
38
|
+
sending={false}
|
|
39
|
+
canSend={true}
|
|
40
|
+
saveStatus="saved"
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
export const DesktopFull: Story = {
|
|
45
|
+
name: "Desktop — full compose",
|
|
46
|
+
render: () => (
|
|
47
|
+
<div className="h-[560px] w-[560px] border border-line bg-canvas">
|
|
48
|
+
<ComposeFormShell
|
|
49
|
+
header={<Header title={composeModeLabels.new} />}
|
|
50
|
+
actionBar={bar}
|
|
51
|
+
>
|
|
52
|
+
<Body />
|
|
53
|
+
</ComposeFormShell>
|
|
54
|
+
</div>
|
|
55
|
+
),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const InlineReply: Story = {
|
|
59
|
+
name: "Inline reply",
|
|
60
|
+
render: () => (
|
|
61
|
+
<div className="flex h-[400px] w-[640px] max-h-[400px] flex-col border-t border-line bg-canvas">
|
|
62
|
+
<ComposeFormShell
|
|
63
|
+
header={<Header title={composeModeLabels.reply} />}
|
|
64
|
+
quoted={
|
|
65
|
+
<div className="border-l-2 border-line pl-3 text-xs text-fg-muted">
|
|
66
|
+
On Tuesday, Alex wrote: …
|
|
67
|
+
</div>
|
|
68
|
+
}
|
|
69
|
+
actionBar={bar}
|
|
70
|
+
>
|
|
71
|
+
<Body />
|
|
72
|
+
</ComposeFormShell>
|
|
73
|
+
</div>
|
|
74
|
+
),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const MobileSheet: Story = {
|
|
78
|
+
name: "Mobile sheet — Send within viewport",
|
|
79
|
+
parameters: { viewport: { defaultViewport: "mobile1" } },
|
|
80
|
+
render: () => (
|
|
81
|
+
<div className="flex h-[95dvh] w-[390px] flex-col rounded-t-lg border border-line bg-canvas">
|
|
82
|
+
<div className="mx-auto mt-2 mb-1 h-1.5 w-12 rounded-full bg-fg-muted/30" />
|
|
83
|
+
<div className="border-b border-line px-4 py-2 text-base font-semibold">
|
|
84
|
+
{composeModeLabels.new}
|
|
85
|
+
</div>
|
|
86
|
+
<div className="min-h-0 flex-1">
|
|
87
|
+
<ComposeFormShell
|
|
88
|
+
header={<Header title={composeModeLabels.new} />}
|
|
89
|
+
actionBar={bar}
|
|
90
|
+
>
|
|
91
|
+
<Body />
|
|
92
|
+
</ComposeFormShell>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
),
|
|
96
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type ComposeMode = "new" | "reply" | "reply_all" | "forward";
|
|
4
|
+
|
|
5
|
+
export const composeModeLabels: Record<ComposeMode, string> = {
|
|
6
|
+
new: "New Message",
|
|
7
|
+
reply: "Reply",
|
|
8
|
+
reply_all: "Reply All",
|
|
9
|
+
forward: "Forward",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export interface ComposeFormShellProps {
|
|
13
|
+
/** Optional banner above the header (e.g. SMTP-missing notice). */
|
|
14
|
+
banner?: ReactNode;
|
|
15
|
+
/** Recipient / subject header region. */
|
|
16
|
+
header: ReactNode;
|
|
17
|
+
/** The editor body. */
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
/** Quoted reply / forwarded content under the body. */
|
|
20
|
+
quoted?: ReactNode;
|
|
21
|
+
/** The ComposeActionBar. */
|
|
22
|
+
actionBar: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Presentational compose layout: banner / header / scrollable body+quote /
|
|
27
|
+
* pinned action bar. Owns the column structure so the action bar always sits
|
|
28
|
+
* at the bottom and never clips below the fold. The live form composes this
|
|
29
|
+
* with its provider-driven slots; stories render it with static slots.
|
|
30
|
+
*/
|
|
31
|
+
export function ComposeFormShell({
|
|
32
|
+
banner,
|
|
33
|
+
header,
|
|
34
|
+
children,
|
|
35
|
+
quoted,
|
|
36
|
+
actionBar,
|
|
37
|
+
}: ComposeFormShellProps) {
|
|
38
|
+
return (
|
|
39
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
40
|
+
{banner}
|
|
41
|
+
{header}
|
|
42
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
43
|
+
{children}
|
|
44
|
+
{quoted && <div className="px-3 pb-2">{quoted}</div>}
|
|
45
|
+
</div>
|
|
46
|
+
{actionBar}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { AlertTriangle } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
export interface DangerZoneSectionProps {
|
|
5
|
+
title: string;
|
|
6
|
+
description: ReactNode;
|
|
7
|
+
/** The destructive action control (a danger Button). */
|
|
8
|
+
action: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GitHub-style red offboarding block. Its own section, never bolted into a
|
|
13
|
+
* list — keep markup and labels here so the story and the live route can't
|
|
14
|
+
* drift (#791).
|
|
15
|
+
*/
|
|
16
|
+
export function DangerZoneSection({
|
|
17
|
+
title,
|
|
18
|
+
description,
|
|
19
|
+
action,
|
|
20
|
+
}: DangerZoneSectionProps) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="rounded-sm border border-danger/50">
|
|
23
|
+
<div className="flex items-center gap-2 border-b border-danger/30 bg-danger-soft px-row-inset py-2">
|
|
24
|
+
<AlertTriangle className="size-4 text-danger" />
|
|
25
|
+
<h2 className="text-sm font-semibold text-danger">Danger zone</h2>
|
|
26
|
+
</div>
|
|
27
|
+
<div className="flex flex-col gap-3 px-row-inset py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
|
28
|
+
<div className="min-w-0">
|
|
29
|
+
<div className="text-sm font-medium text-fg">{title}</div>
|
|
30
|
+
<p className="text-xs text-fg-muted">{description}</p>
|
|
31
|
+
</div>
|
|
32
|
+
<div className="shrink-0">{action}</div>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
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 { Dialog } from "./dialog.js";
|
|
6
|
+
|
|
7
|
+
const render = (props: Partial<Parameters<typeof Dialog>[0]>) =>
|
|
8
|
+
renderToString(
|
|
9
|
+
createElement(Dialog, {
|
|
10
|
+
open: true,
|
|
11
|
+
onClose: () => {},
|
|
12
|
+
title: "Folders",
|
|
13
|
+
// biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
|
|
14
|
+
children: createElement("span", null, "nav content"),
|
|
15
|
+
...props,
|
|
16
|
+
}),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
describe("Dialog backdrop", () => {
|
|
20
|
+
it("never frosts the content behind it (no blur, just a dim scrim)", () => {
|
|
21
|
+
const center = render({});
|
|
22
|
+
const left = render({ anchor: "left" });
|
|
23
|
+
assert.doesNotMatch(center, /backdrop-blur/, "center modal has no blur");
|
|
24
|
+
assert.doesNotMatch(left, /backdrop-blur/, "left drawer has no blur");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("dims with a plain scrim; left slide-over has no background wash", () => {
|
|
28
|
+
assert.match(render({}), /bg-canvas\/80/, "center modal keeps a dim scrim");
|
|
29
|
+
assert.doesNotMatch(
|
|
30
|
+
render({ anchor: "left" }),
|
|
31
|
+
/bg-canvas/,
|
|
32
|
+
"left drawer backdrop is transparent (no wash)",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("right slide-over mirrors left: transparent backdrop, pinned right edge", () => {
|
|
37
|
+
const right = render({ anchor: "right" });
|
|
38
|
+
assert.doesNotMatch(right, /backdrop-blur/, "right drawer has no blur");
|
|
39
|
+
assert.doesNotMatch(
|
|
40
|
+
right,
|
|
41
|
+
/bg-canvas\/80/,
|
|
42
|
+
"right drawer backdrop is transparent (no wash)",
|
|
43
|
+
);
|
|
44
|
+
assert.match(right, /justify-end/, "panel is pinned to the right edge");
|
|
45
|
+
assert.match(right, /border-l/, "right drawer has a left hairline");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("renders nothing when closed, so it never covers the content", () => {
|
|
49
|
+
assert.equal(
|
|
50
|
+
render({ open: false }),
|
|
51
|
+
"",
|
|
52
|
+
"closed dialog renders no markup",
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
});
|