@remit/ui 0.0.102 → 0.0.103
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 +1 -1
- package/src/components/compose-address-field.stories.tsx +138 -0
- package/src/components/compose-address-field.tsx +206 -0
- package/src/components/compose-body.stories.tsx +463 -0
- package/src/components/compose-body.tsx +210 -0
- package/src/components/compose-header.stories.tsx +147 -0
- package/src/components/compose-header.tsx +109 -0
- package/src/components/compose-mode-toggle.stories.tsx +53 -0
- package/src/components/compose-smtp-missing-banner.stories.tsx +30 -0
- package/src/components/compose-smtp-missing-banner.tsx +39 -0
- package/src/components/compose-subject-field.tsx +22 -0
- package/src/components/confirm-dialog.stories.tsx +54 -0
- package/src/components/confirm-dialog.tsx +140 -0
- package/src/index.ts +23 -0
- package/src/lib/compose-mode.test.ts +76 -0
- package/src/lib/compose-mode.ts +50 -0
- package/src/rich-text.ts +7 -0
- package/src/components/compose-form-shell.stories.tsx +0 -96
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
4
|
+
import {
|
|
5
|
+
type AddressEntry,
|
|
6
|
+
ComposeAddressField,
|
|
7
|
+
} from "./compose-address-field.js";
|
|
8
|
+
import { ComposeHeader, composeHeaderSummary } from "./compose-header.js";
|
|
9
|
+
import { ComposeSubjectField } from "./compose-subject-field.js";
|
|
10
|
+
|
|
11
|
+
const FromRow = ({ email }: { email: string }) => (
|
|
12
|
+
<div className="flex items-start gap-2">
|
|
13
|
+
{/* biome-ignore lint/a11y/noLabelWithoutControl: decorative label for a read-only value, not a form control */}
|
|
14
|
+
<label className="text-sm text-fg-muted shrink-0 w-12 pt-1.5">From:</label>
|
|
15
|
+
<div className="text-sm py-1.5">{email}</div>
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Who the message is going to and what it is about. Cc and Bcc are not there
|
|
21
|
+
* until they are asked for — the common message has one recipient line, and
|
|
22
|
+
* three empty ones push the writing surface off a phone.
|
|
23
|
+
*/
|
|
24
|
+
const meta: Meta<typeof ComposeHeader> = {
|
|
25
|
+
title: "Mail/ComposeHeader",
|
|
26
|
+
component: ComposeHeader,
|
|
27
|
+
parameters: { layout: "padded" },
|
|
28
|
+
};
|
|
29
|
+
export default meta;
|
|
30
|
+
|
|
31
|
+
type Story = StoryObj<typeof ComposeHeader>;
|
|
32
|
+
|
|
33
|
+
const Harness = ({
|
|
34
|
+
initialTo = [],
|
|
35
|
+
initialCc,
|
|
36
|
+
initialBcc,
|
|
37
|
+
initialSubject = "",
|
|
38
|
+
collapsed = false,
|
|
39
|
+
}: {
|
|
40
|
+
initialTo?: AddressEntry[];
|
|
41
|
+
initialCc?: AddressEntry[];
|
|
42
|
+
initialBcc?: AddressEntry[];
|
|
43
|
+
initialSubject?: string;
|
|
44
|
+
collapsed?: boolean;
|
|
45
|
+
}) => {
|
|
46
|
+
const [to, setTo] = useState(initialTo);
|
|
47
|
+
const [cc, setCc] = useState(initialCc ?? []);
|
|
48
|
+
const [bcc, setBcc] = useState(initialBcc ?? []);
|
|
49
|
+
const [showCc, setShowCc] = useState(initialCc !== undefined);
|
|
50
|
+
const [showBcc, setShowBcc] = useState(initialBcc !== undefined);
|
|
51
|
+
const [subject, setSubject] = useState(initialSubject);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="w-[560px] border border-line bg-surface">
|
|
55
|
+
<ComposeHeader
|
|
56
|
+
collapsed={collapsed}
|
|
57
|
+
summary={composeHeaderSummary({ to, cc, bcc, subject })}
|
|
58
|
+
from={<FromRow email="alice@northwind.example" />}
|
|
59
|
+
to={
|
|
60
|
+
<ComposeAddressField
|
|
61
|
+
label="To"
|
|
62
|
+
addresses={to}
|
|
63
|
+
onChange={setTo}
|
|
64
|
+
placeholder="Recipients"
|
|
65
|
+
/>
|
|
66
|
+
}
|
|
67
|
+
cc={
|
|
68
|
+
showCc ? (
|
|
69
|
+
<ComposeAddressField label="Cc" addresses={cc} onChange={setCc} />
|
|
70
|
+
) : undefined
|
|
71
|
+
}
|
|
72
|
+
bcc={
|
|
73
|
+
showBcc ? (
|
|
74
|
+
<ComposeAddressField
|
|
75
|
+
label="Bcc"
|
|
76
|
+
addresses={bcc}
|
|
77
|
+
onChange={setBcc}
|
|
78
|
+
/>
|
|
79
|
+
) : undefined
|
|
80
|
+
}
|
|
81
|
+
subject={<ComposeSubjectField value={subject} onChange={setSubject} />}
|
|
82
|
+
onShowCc={() => setShowCc(true)}
|
|
83
|
+
onShowBcc={() => setShowBcc(true)}
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const NewMessage: Story = {
|
|
90
|
+
name: "New message — nothing filled in",
|
|
91
|
+
render: () => <Harness />,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const Reply: Story = {
|
|
95
|
+
render: () => (
|
|
96
|
+
<Harness
|
|
97
|
+
initialTo={[{ email: "ada@northwind.example", displayName: "Ada" }]}
|
|
98
|
+
initialSubject="Re: Q3 planning"
|
|
99
|
+
/>
|
|
100
|
+
),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const CcAndBccRevealed: Story = {
|
|
104
|
+
render: () => (
|
|
105
|
+
<Harness
|
|
106
|
+
initialTo={[{ email: "ada@northwind.example", displayName: "Ada" }]}
|
|
107
|
+
initialCc={[{ email: "grace@northwind.example" }]}
|
|
108
|
+
initialBcc={[]}
|
|
109
|
+
initialSubject="Re: Q3 planning"
|
|
110
|
+
/>
|
|
111
|
+
),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The software keyboard is up on a phone. The header gives its space to the
|
|
116
|
+
* writing surface and keeps one line of what is already filled in.
|
|
117
|
+
*/
|
|
118
|
+
export const Collapsed: Story = {
|
|
119
|
+
render: () => (
|
|
120
|
+
<Harness
|
|
121
|
+
collapsed
|
|
122
|
+
initialTo={[
|
|
123
|
+
{ email: "ada@northwind.example", displayName: "Ada Lovelace" },
|
|
124
|
+
]}
|
|
125
|
+
initialCc={[{ email: "grace@northwind.example" }]}
|
|
126
|
+
initialSubject="Re: Q3 planning"
|
|
127
|
+
/>
|
|
128
|
+
),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Collapsed before anything is typed: an ellipsis, not an empty bar. */
|
|
132
|
+
export const CollapsedAndEmpty: Story = {
|
|
133
|
+
render: () => <Harness collapsed />,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const RevealingCcLeavesBccOnOffer: Story = {
|
|
137
|
+
render: () => <Harness />,
|
|
138
|
+
play: async ({ canvasElement }) => {
|
|
139
|
+
const canvas = within(canvasElement);
|
|
140
|
+
await expect(canvas.queryByLabelText("Cc:")).not.toBeInTheDocument();
|
|
141
|
+
await userEvent.click(canvas.getByRole("button", { name: "Cc" }));
|
|
142
|
+
await expect(canvas.getByLabelText("Cc:")).toBeVisible();
|
|
143
|
+
await expect(canvas.queryByLabelText("Bcc:")).not.toBeInTheDocument();
|
|
144
|
+
await userEvent.click(canvas.getByRole("button", { name: "Bcc" }));
|
|
145
|
+
await expect(canvas.getByLabelText("Bcc:")).toBeVisible();
|
|
146
|
+
},
|
|
147
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { AddressEntry } from "./compose-address-field.js";
|
|
3
|
+
|
|
4
|
+
export interface ComposeHeaderProps {
|
|
5
|
+
/**
|
|
6
|
+
* The software keyboard is up on a phone and the recipient rows would eat
|
|
7
|
+
* the writing surface. The header collapses to one line of what is already
|
|
8
|
+
* filled in.
|
|
9
|
+
*/
|
|
10
|
+
collapsed?: boolean;
|
|
11
|
+
/** One line of what the collapsed header stands in for. */
|
|
12
|
+
summary?: string;
|
|
13
|
+
from: ReactNode;
|
|
14
|
+
to: ReactNode;
|
|
15
|
+
/** Present once the field has been revealed; absent leaves the reveal button. */
|
|
16
|
+
cc?: ReactNode;
|
|
17
|
+
bcc?: ReactNode;
|
|
18
|
+
subject: ReactNode;
|
|
19
|
+
onShowCc: () => void;
|
|
20
|
+
onShowBcc: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const RevealButton = ({
|
|
24
|
+
label,
|
|
25
|
+
onClick,
|
|
26
|
+
}: {
|
|
27
|
+
label: string;
|
|
28
|
+
onClick: () => void;
|
|
29
|
+
}) => (
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
onClick={onClick}
|
|
33
|
+
className="text-xs text-fg-muted hover:text-fg transition-colors"
|
|
34
|
+
>
|
|
35
|
+
{label}
|
|
36
|
+
</button>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
export const composeHeaderSummary = ({
|
|
40
|
+
to,
|
|
41
|
+
cc,
|
|
42
|
+
bcc,
|
|
43
|
+
subject,
|
|
44
|
+
}: {
|
|
45
|
+
to: readonly AddressEntry[];
|
|
46
|
+
cc: readonly AddressEntry[];
|
|
47
|
+
bcc: readonly AddressEntry[];
|
|
48
|
+
subject: string;
|
|
49
|
+
}): string => {
|
|
50
|
+
const chips: string[] = [];
|
|
51
|
+
if (to.length > 0)
|
|
52
|
+
chips.push(`To: ${to.map((a) => a.displayName ?? a.email).join(", ")}`);
|
|
53
|
+
if (cc.length > 0) chips.push(`Cc: ${cc.length}`);
|
|
54
|
+
if (bcc.length > 0) chips.push(`Bcc: ${bcc.length}`);
|
|
55
|
+
if (subject) chips.push(subject);
|
|
56
|
+
return chips.join(" · ");
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Who the message is going to and what it is about. The fields are slots: the
|
|
61
|
+
* app fills them with the wired address and account controls, a story with
|
|
62
|
+
* static ones, and both get the same layout, the same Cc/Bcc reveal and the
|
|
63
|
+
* same collapsed line.
|
|
64
|
+
*/
|
|
65
|
+
export const ComposeHeader = ({
|
|
66
|
+
collapsed = false,
|
|
67
|
+
summary = "",
|
|
68
|
+
from,
|
|
69
|
+
to,
|
|
70
|
+
cc,
|
|
71
|
+
bcc,
|
|
72
|
+
subject,
|
|
73
|
+
onShowCc,
|
|
74
|
+
onShowBcc,
|
|
75
|
+
}: ComposeHeaderProps) => {
|
|
76
|
+
if (collapsed) {
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
className="flex items-center gap-2 px-3 py-1.5 border-b border-line overflow-hidden"
|
|
80
|
+
data-testid="compose-header-collapsed"
|
|
81
|
+
>
|
|
82
|
+
<span className="truncate text-xs text-fg-muted">{summary || "…"}</span>
|
|
83
|
+
<span className="shrink-0 inline-flex items-center justify-center rounded bg-surface-sunken px-1.5 py-0.5 text-2xs text-fg-muted">
|
|
84
|
+
…
|
|
85
|
+
</span>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div className="space-y-1 px-3 py-2 border-b border-line">
|
|
92
|
+
{from}
|
|
93
|
+
{to}
|
|
94
|
+
{cc ?? (
|
|
95
|
+
<div className="flex gap-2 pl-14">
|
|
96
|
+
<RevealButton label="Cc" onClick={onShowCc} />
|
|
97
|
+
<RevealButton label="Bcc" onClick={onShowBcc} />
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
{cc && !bcc && (
|
|
101
|
+
<div className="pl-14">
|
|
102
|
+
<RevealButton label="Bcc" onClick={onShowBcc} />
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
{bcc}
|
|
106
|
+
{subject}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
4
|
+
import {
|
|
5
|
+
type ComposeBodyMode,
|
|
6
|
+
ComposeModeToggle,
|
|
7
|
+
} from "./compose-mode-toggle.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The control that swaps the writing surface. It reads "Plain text" in both
|
|
11
|
+
* states — the label names the mode it offers, and `aria-pressed` carries
|
|
12
|
+
* which one is up, so the control never changes under the finger.
|
|
13
|
+
*/
|
|
14
|
+
const meta: Meta<typeof ComposeModeToggle> = {
|
|
15
|
+
title: "Mail/ComposeModeToggle",
|
|
16
|
+
component: ComposeModeToggle,
|
|
17
|
+
parameters: { layout: "centered" },
|
|
18
|
+
args: { onToggle: () => undefined },
|
|
19
|
+
};
|
|
20
|
+
export default meta;
|
|
21
|
+
|
|
22
|
+
type Story = StoryObj<typeof ComposeModeToggle>;
|
|
23
|
+
|
|
24
|
+
export const RichText: Story = {
|
|
25
|
+
name: "Rich text — plain text on offer",
|
|
26
|
+
args: { mode: "rich" },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const PlainText: Story = {
|
|
30
|
+
name: "Plain text — the mode is on",
|
|
31
|
+
args: { mode: "plain" },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const Harness = ({ start }: { start: ComposeBodyMode }) => {
|
|
35
|
+
const [mode, setMode] = useState<ComposeBodyMode>(start);
|
|
36
|
+
return (
|
|
37
|
+
<ComposeModeToggle
|
|
38
|
+
mode={mode}
|
|
39
|
+
onToggle={() => setMode(mode === "plain" ? "rich" : "plain")}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const PressedStateFollowsTheMode: Story = {
|
|
45
|
+
render: () => <Harness start="rich" />,
|
|
46
|
+
play: async ({ canvasElement }) => {
|
|
47
|
+
const toggle = within(canvasElement).getByTestId("compose-mode-toggle");
|
|
48
|
+
await expect(toggle).toHaveAttribute("aria-pressed", "false");
|
|
49
|
+
await userEvent.click(toggle);
|
|
50
|
+
await expect(toggle).toHaveAttribute("aria-pressed", "true");
|
|
51
|
+
await expect(toggle).toHaveTextContent("Plain text");
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { expect, fn, userEvent, within } from "storybook/test";
|
|
3
|
+
import { ComposeSmtpMissingBanner } from "./compose-smtp-missing-banner.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The selected account has no SMTP host. Send stays pressable and explains
|
|
7
|
+
* itself; this says the same thing before the user reaches for it, and carries
|
|
8
|
+
* the way out rather than leaving them to find Settings.
|
|
9
|
+
*/
|
|
10
|
+
const meta: Meta<typeof ComposeSmtpMissingBanner> = {
|
|
11
|
+
title: "Mail/ComposeSmtpMissingBanner",
|
|
12
|
+
component: ComposeSmtpMissingBanner,
|
|
13
|
+
parameters: { layout: "padded" },
|
|
14
|
+
args: { onConfigure: () => undefined },
|
|
15
|
+
};
|
|
16
|
+
export default meta;
|
|
17
|
+
|
|
18
|
+
type Story = StoryObj<typeof ComposeSmtpMissingBanner>;
|
|
19
|
+
|
|
20
|
+
export const Default: Story = {};
|
|
21
|
+
|
|
22
|
+
export const TheWayOutIsReachable: Story = {
|
|
23
|
+
args: { onConfigure: fn() },
|
|
24
|
+
play: async ({ args, canvasElement }) => {
|
|
25
|
+
await userEvent.click(
|
|
26
|
+
within(canvasElement).getByRole("button", { name: /Configure SMTP/ }),
|
|
27
|
+
);
|
|
28
|
+
await expect(args.onConfigure).toHaveBeenCalled();
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { AlertTriangle, ArrowRight } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
export interface ComposeSmtpMissingBannerProps {
|
|
4
|
+
onConfigure: () => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Non-dismissible banner shown above the compose form when the selected
|
|
9
|
+
* account has no SMTP host configured. Pairs with a Send button that explains
|
|
10
|
+
* rather than disables, so the user has a single, factual account of why
|
|
11
|
+
* nothing will leave. See issue #196.
|
|
12
|
+
*/
|
|
13
|
+
export const ComposeSmtpMissingBanner = ({
|
|
14
|
+
onConfigure,
|
|
15
|
+
}: ComposeSmtpMissingBannerProps) => (
|
|
16
|
+
<div
|
|
17
|
+
role="alert"
|
|
18
|
+
data-testid="compose-smtp-missing-banner"
|
|
19
|
+
className="flex items-start gap-3 border-b border-warning/50 bg-warning/10 px-3 py-2"
|
|
20
|
+
>
|
|
21
|
+
<AlertTriangle
|
|
22
|
+
className="size-5 shrink-0 mt-0.5 text-warning"
|
|
23
|
+
aria-hidden="true"
|
|
24
|
+
/>
|
|
25
|
+
<div className="flex-1 min-w-0">
|
|
26
|
+
<p className="text-sm font-medium text-warning">
|
|
27
|
+
This account can't send mail until SMTP is configured.
|
|
28
|
+
</p>
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
onClick={onConfigure}
|
|
32
|
+
className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-warning hover:underline"
|
|
33
|
+
>
|
|
34
|
+
Configure SMTP
|
|
35
|
+
<ArrowRight className="size-3" aria-hidden="true" />
|
|
36
|
+
</button>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface ComposeSubjectFieldProps {
|
|
2
|
+
value: string;
|
|
3
|
+
onChange: (value: string) => void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const ComposeSubjectField = ({
|
|
7
|
+
value,
|
|
8
|
+
onChange,
|
|
9
|
+
}: ComposeSubjectFieldProps) => (
|
|
10
|
+
<div className="flex items-start gap-2">
|
|
11
|
+
{/* biome-ignore lint/a11y/noLabelWithoutControl: label is visually adjacent to the sibling input; static id risks duplicates */}
|
|
12
|
+
<label className="text-sm text-fg-muted shrink-0 w-12 pt-1.5">Subj:</label>
|
|
13
|
+
<input
|
|
14
|
+
type="text"
|
|
15
|
+
value={value}
|
|
16
|
+
onChange={(e) => onChange(e.target.value)}
|
|
17
|
+
className="flex-1 px-2 py-1.5 border rounded-md bg-canvas text-sm"
|
|
18
|
+
placeholder="Subject"
|
|
19
|
+
data-subject-field
|
|
20
|
+
/>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { ConfirmDialog } from "./confirm-dialog.js";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof ConfirmDialog> = {
|
|
5
|
+
title: "Primitives/ConfirmDialog",
|
|
6
|
+
component: ConfirmDialog,
|
|
7
|
+
parameters: { layout: "centered" },
|
|
8
|
+
args: {
|
|
9
|
+
isOpen: true,
|
|
10
|
+
title: "Move 3,412 messages to Trash?",
|
|
11
|
+
description: "You can restore them from Trash later.",
|
|
12
|
+
confirmLabel: "Move to Trash",
|
|
13
|
+
destructive: true,
|
|
14
|
+
onConfirm: () => undefined,
|
|
15
|
+
onCancel: () => undefined,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
export default meta;
|
|
19
|
+
|
|
20
|
+
type Story = StoryObj<typeof ConfirmDialog>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A single corner tap on the bar's delete icon used to fall straight through
|
|
24
|
+
* to a delete with nothing in between — this is what now sits in the way.
|
|
25
|
+
* Wording says "Move … to Trash", not "Delete": the operation is reversible
|
|
26
|
+
* (IMAP delete moves to Trash), and the confirmation copy says so rather than
|
|
27
|
+
* reading as final.
|
|
28
|
+
*/
|
|
29
|
+
export const Default: Story = {};
|
|
30
|
+
|
|
31
|
+
export const OneMessage: Story = {
|
|
32
|
+
args: {
|
|
33
|
+
title: "Move 1 message to Trash?",
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The mutation is in flight: the confirm button disables rather than
|
|
38
|
+
* allowing a second concurrent delete request. */
|
|
39
|
+
export const Busy: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
isBusy: true,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** A non-destructive confirmation (no `destructive`) uses the accent
|
|
46
|
+
* affirmative styling instead of danger. */
|
|
47
|
+
export const NonDestructive: Story = {
|
|
48
|
+
args: {
|
|
49
|
+
title: "Archive 12 messages?",
|
|
50
|
+
description: undefined,
|
|
51
|
+
confirmLabel: "Archive",
|
|
52
|
+
destructive: false,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
|
|
4
|
+
export interface ConfirmDialogProps {
|
|
5
|
+
isOpen: boolean;
|
|
6
|
+
title: string;
|
|
7
|
+
/** Optional supporting line under the title. */
|
|
8
|
+
description?: string;
|
|
9
|
+
confirmLabel: string;
|
|
10
|
+
cancelLabel?: string;
|
|
11
|
+
/** Style the confirm button as a destructive action. */
|
|
12
|
+
destructive?: boolean;
|
|
13
|
+
/** Disable the confirm button (e.g. while a mutation is in flight). */
|
|
14
|
+
isBusy?: boolean;
|
|
15
|
+
onConfirm: () => void;
|
|
16
|
+
onCancel: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Minimal accessible confirmation dialog. No existing Dialog/ConfirmDialog
|
|
21
|
+
* primitive ships in the web client (only the bespoke KeyboardShortcutsModal
|
|
22
|
+
* and SlidePanel), so this is a small reusable one matching their Tailwind +
|
|
23
|
+
* overlay conventions. Esc cancels, the backdrop cancels, Cancel is focused on
|
|
24
|
+
* open, and the confirm/cancel pair is the only focusable content so the focus
|
|
25
|
+
* stays within the dialog.
|
|
26
|
+
*/
|
|
27
|
+
export const ConfirmDialog = ({
|
|
28
|
+
isOpen,
|
|
29
|
+
title,
|
|
30
|
+
description,
|
|
31
|
+
confirmLabel,
|
|
32
|
+
cancelLabel = "Cancel",
|
|
33
|
+
destructive = false,
|
|
34
|
+
isBusy = false,
|
|
35
|
+
onConfirm,
|
|
36
|
+
onCancel,
|
|
37
|
+
}: ConfirmDialogProps) => {
|
|
38
|
+
const cancelRef = useRef<HTMLButtonElement>(null);
|
|
39
|
+
|
|
40
|
+
const handleKeyDown = useCallback(
|
|
41
|
+
(event: KeyboardEvent) => {
|
|
42
|
+
if (event.key === "Escape") {
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
event.stopPropagation();
|
|
45
|
+
event.stopImmediatePropagation();
|
|
46
|
+
onCancel();
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
[onCancel],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!isOpen) return;
|
|
54
|
+
// Capture phase so Esc closes the dialog before any list-level Esc
|
|
55
|
+
// handler (e.g. clearSelection) also fires on the same keystroke.
|
|
56
|
+
window.addEventListener("keydown", handleKeyDown, true);
|
|
57
|
+
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
|
58
|
+
}, [isOpen, handleKeyDown]);
|
|
59
|
+
|
|
60
|
+
// Whoever opened the dialog gets the focus back when it closes. Without this
|
|
61
|
+
// a cancelled confirmation drops focus to the body, and the control the user
|
|
62
|
+
// was on — the compose mode toggle, a row's delete button — is gone from
|
|
63
|
+
// under the keyboard.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!isOpen) return;
|
|
66
|
+
const opener =
|
|
67
|
+
document.activeElement instanceof HTMLElement
|
|
68
|
+
? document.activeElement
|
|
69
|
+
: null;
|
|
70
|
+
cancelRef.current?.focus();
|
|
71
|
+
return () => {
|
|
72
|
+
if (opener?.isConnected) opener.focus();
|
|
73
|
+
};
|
|
74
|
+
}, [isOpen]);
|
|
75
|
+
|
|
76
|
+
if (!isOpen) return null;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
// Above every other overlay, the mobile compose sheet included: a
|
|
80
|
+
// confirmation is the decision blocking whatever is under it, and a drawer
|
|
81
|
+
// portalled to the body at the same level would cover it.
|
|
82
|
+
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
|
83
|
+
{/* Backdrop. It carries the click-to-dismiss and the aria-hidden: the
|
|
84
|
+
dialog itself must stay in the accessibility tree, and an
|
|
85
|
+
aria-hidden ancestor would take it out. */}
|
|
86
|
+
<div
|
|
87
|
+
className="absolute inset-0 bg-canvas/80 backdrop-blur-sm"
|
|
88
|
+
aria-hidden="true"
|
|
89
|
+
onClick={onCancel}
|
|
90
|
+
/>
|
|
91
|
+
|
|
92
|
+
{/* Dialog */}
|
|
93
|
+
<div
|
|
94
|
+
role="dialog"
|
|
95
|
+
aria-modal="true"
|
|
96
|
+
aria-label={title}
|
|
97
|
+
className={cn(
|
|
98
|
+
"relative z-10 w-full max-w-sm",
|
|
99
|
+
"bg-surface border border-line rounded-sm shadow-lg",
|
|
100
|
+
"p-6",
|
|
101
|
+
)}
|
|
102
|
+
onClick={(e) => e.stopPropagation()}
|
|
103
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
104
|
+
>
|
|
105
|
+
<h2 className="text-lg font-semibold">{title}</h2>
|
|
106
|
+
{description && (
|
|
107
|
+
<p className="mt-2 text-sm text-fg-muted">{description}</p>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
<div className="mt-6 flex items-center justify-end gap-2">
|
|
111
|
+
<button
|
|
112
|
+
ref={cancelRef}
|
|
113
|
+
type="button"
|
|
114
|
+
onClick={onCancel}
|
|
115
|
+
className={cn(
|
|
116
|
+
"min-h-11 inline-flex items-center justify-center px-4 rounded text-sm font-medium transition-colors",
|
|
117
|
+
"border border-line hover:bg-surface-raised",
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
{cancelLabel}
|
|
121
|
+
</button>
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
onClick={onConfirm}
|
|
125
|
+
disabled={isBusy}
|
|
126
|
+
className={cn(
|
|
127
|
+
"min-h-11 inline-flex items-center justify-center px-4 rounded text-sm font-medium transition-colors",
|
|
128
|
+
destructive
|
|
129
|
+
? "bg-danger text-canvas hover:bg-danger/90"
|
|
130
|
+
: "bg-accent text-accent-fg hover:bg-accent-hover",
|
|
131
|
+
"disabled:opacity-50 disabled:cursor-not-allowed",
|
|
132
|
+
)}
|
|
133
|
+
>
|
|
134
|
+
{confirmLabel}
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -130,16 +130,38 @@ export {
|
|
|
130
130
|
type ComposeActionBarProps,
|
|
131
131
|
type ComposeSaveStatus,
|
|
132
132
|
} from "./components/compose-action-bar.js";
|
|
133
|
+
export {
|
|
134
|
+
type AddressEntry,
|
|
135
|
+
ComposeAddressField,
|
|
136
|
+
type ComposeAddressFieldProps,
|
|
137
|
+
} from "./components/compose-address-field.js";
|
|
133
138
|
export {
|
|
134
139
|
ComposeFormShell,
|
|
135
140
|
type ComposeFormShellProps,
|
|
136
141
|
type ComposeMode,
|
|
137
142
|
composeModeLabels,
|
|
138
143
|
} from "./components/compose-form-shell.js";
|
|
144
|
+
export {
|
|
145
|
+
ComposeHeader,
|
|
146
|
+
type ComposeHeaderProps,
|
|
147
|
+
composeHeaderSummary,
|
|
148
|
+
} from "./components/compose-header.js";
|
|
139
149
|
export {
|
|
140
150
|
ComposeLanguageSetting,
|
|
141
151
|
type ComposeLanguageSettingProps,
|
|
142
152
|
} from "./components/compose-language-setting.js";
|
|
153
|
+
export {
|
|
154
|
+
ComposeSmtpMissingBanner,
|
|
155
|
+
type ComposeSmtpMissingBannerProps,
|
|
156
|
+
} from "./components/compose-smtp-missing-banner.js";
|
|
157
|
+
export {
|
|
158
|
+
ComposeSubjectField,
|
|
159
|
+
type ComposeSubjectFieldProps,
|
|
160
|
+
} from "./components/compose-subject-field.js";
|
|
161
|
+
export {
|
|
162
|
+
ConfirmDialog,
|
|
163
|
+
type ConfirmDialogProps,
|
|
164
|
+
} from "./components/confirm-dialog.js";
|
|
143
165
|
export {
|
|
144
166
|
DangerZoneSection,
|
|
145
167
|
type DangerZoneSectionProps,
|
|
@@ -674,6 +696,7 @@ export {
|
|
|
674
696
|
unwrapLanguage,
|
|
675
697
|
wrapWithLanguage,
|
|
676
698
|
} from "./lib/compose-language.js";
|
|
699
|
+
export { modeOfDraft } from "./lib/compose-mode.js";
|
|
677
700
|
export { generateLayoutClampCSS } from "./lib/email-layout-clamp.js";
|
|
678
701
|
export {
|
|
679
702
|
classifyEmailRenderTreatment,
|