@remit/ui 0.0.109 → 0.0.111

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.109",
3
+ "version": "0.0.111",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -5,10 +5,10 @@ import { renderToString } from "react-dom/server";
5
5
  import { ComposeActionBar } from "./compose-action-bar.js";
6
6
 
7
7
  const baseProps = {
8
+ send: { status: "ready" } as const,
8
9
  onSend: () => undefined,
10
+ onBlocked: () => undefined,
9
11
  onDiscard: () => undefined,
10
- sending: false,
11
- canSend: true,
12
12
  };
13
13
 
14
14
  describe("ComposeActionBar", () => {
@@ -23,8 +23,7 @@ describe("ComposeActionBar", () => {
23
23
  const html = renderToString(
24
24
  createElement(ComposeActionBar, {
25
25
  ...baseProps,
26
- canSend: false,
27
- unavailableReason: "SMTP not configured",
26
+ send: { status: "blocked", reason: "SMTP not configured" },
28
27
  }),
29
28
  );
30
29
  assert.match(html, /Send/);
@@ -34,7 +33,10 @@ describe("ComposeActionBar", () => {
34
33
 
35
34
  it("uses aria-busy while sending", () => {
36
35
  const html = renderToString(
37
- createElement(ComposeActionBar, { ...baseProps, sending: true }),
36
+ createElement(ComposeActionBar, {
37
+ ...baseProps,
38
+ send: { status: "sending" },
39
+ }),
38
40
  );
39
41
  assert.match(html, /aria-busy="true"/);
40
42
  assert.doesNotMatch(html, /disabled=""/);
@@ -1,17 +1,22 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
- import { expect, userEvent, within } from "storybook/test";
3
+ import { expect, fn, userEvent, within } from "storybook/test";
4
4
  import { ComposeActionBar } from "./compose-action-bar.js";
5
5
 
6
+ /**
7
+ * Send, Discard, and what the draft is doing. Send is never greyed out and
8
+ * never silent: a state that cannot send carries the sentence that says why,
9
+ * and the press reports it.
10
+ */
6
11
  const meta: Meta<typeof ComposeActionBar> = {
7
12
  title: "Mail/ComposeActionBar",
8
13
  component: ComposeActionBar,
9
14
  parameters: { layout: "padded" },
10
15
  args: {
11
- onSend: () => undefined,
12
- onDiscard: () => undefined,
13
- sending: false,
14
- canSend: true,
16
+ send: { status: "ready" },
17
+ onSend: fn(),
18
+ onBlocked: fn(),
19
+ onDiscard: fn(),
15
20
  saveStatus: "idle",
16
21
  },
17
22
  };
@@ -29,17 +34,20 @@ export const SaveFailed: Story = { args: { saveStatus: "error" } };
29
34
 
30
35
  export const Sending: Story = {
31
36
  name: "Sending — also while the pending draft is written",
32
- args: { sending: true },
37
+ args: { send: { status: "sending" } },
33
38
  };
34
39
 
35
40
  /**
36
- * Send is never greyed out. Pressing it with nothing to send on reports the
41
+ * Send is never greyed out. Pressing it with nobody to send to reports the
37
42
  * reason where the app would raise its banner — a control that swallowed the
38
43
  * press would be the dead button this bar exists to avoid.
39
44
  */
40
- export const CannotSend: Story = {
41
- name: "Cannot send stays pressable",
42
- render: () => {
45
+ export const NoRecipient: Story = {
46
+ name: "Blockednobody to send to",
47
+ args: {
48
+ send: { status: "blocked", reason: "Add at least one recipient." },
49
+ },
50
+ render: (args) => {
43
51
  const [reason, setReason] = useState<string>();
44
52
  return (
45
53
  <div className="space-y-2">
@@ -53,22 +61,31 @@ export const CannotSend: Story = {
53
61
  </div>
54
62
  )}
55
63
  <ComposeActionBar
56
- onSend={() => undefined}
57
- onDiscard={() => undefined}
58
- sending={false}
59
- canSend={false}
60
- saveStatus="idle"
61
- unavailableReason="SMTP not configured"
62
- onUnavailable={setReason}
64
+ {...args}
65
+ onBlocked={(next) => {
66
+ args.onBlocked(next);
67
+ setReason(next);
68
+ }}
63
69
  />
64
70
  </div>
65
71
  );
66
72
  },
67
- play: async ({ canvasElement }) => {
73
+ play: async ({ args, canvasElement }) => {
68
74
  const canvas = within(canvasElement);
69
75
  await userEvent.click(canvas.getByRole("button", { name: "Send" }));
70
76
  await expect(canvas.getByTestId("compose-unavailable")).toHaveTextContent(
71
- "SMTP not configured",
77
+ "Add at least one recipient.",
72
78
  );
79
+ await expect(args.onSend).not.toHaveBeenCalled();
80
+ },
81
+ };
82
+
83
+ export const SmtpMissing: Story = {
84
+ name: "Blocked — the account cannot send",
85
+ args: {
86
+ send: {
87
+ status: "blocked",
88
+ reason: "This account can't send mail until SMTP is configured.",
89
+ },
73
90
  },
74
91
  };
@@ -3,20 +3,26 @@ import { Button } from "./button.js";
3
3
 
4
4
  export type ComposeSaveStatus = "idle" | "saving" | "saved" | "error";
5
5
 
6
+ /**
7
+ * Whether Send can act, and when it cannot, the sentence that says why.
8
+ *
9
+ * A blocked state cannot be built without its reason, so no arrangement of
10
+ * props produces a Send press that reports nothing. The pair this replaces
11
+ * could: a message with no recipient set `canSend` false and left the reason
12
+ * undefined, and the press did nothing at all.
13
+ */
14
+ export type ComposeSendState =
15
+ | { status: "ready" }
16
+ | { status: "sending" }
17
+ | { status: "blocked"; reason: string };
18
+
6
19
  export interface ComposeActionBarProps {
20
+ send: ComposeSendState;
7
21
  onSend: () => void;
22
+ /** Called with the reason when Send is pressed while it cannot act. */
23
+ onBlocked: (reason: string) => void;
8
24
  onDiscard: () => void;
9
- sending: boolean;
10
- canSend: boolean;
11
25
  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
26
  }
21
27
 
22
28
  const SaveStatusIndicator = ({ status }: { status: ComposeSaveStatus }) => {
@@ -35,29 +41,31 @@ const SaveStatusIndicator = ({ status }: { status: ComposeSaveStatus }) => {
35
41
  };
36
42
 
37
43
  /**
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.
44
+ * Compose footer: Send + Discard. Send stays pressable when it cannot act and
45
+ * says why on press instead of greying out a dead control leaves the user
46
+ * guessing whether the app is broken or the message is. The pill keeps a fixed
47
+ * min-height so it never clips below the fold on mobile.
42
48
  */
43
49
  export function ComposeActionBar({
50
+ send,
44
51
  onSend,
52
+ onBlocked,
45
53
  onDiscard,
46
- sending,
47
- canSend,
48
54
  saveStatus = "idle",
49
- unavailableReason,
50
- onUnavailable,
51
55
  }: ComposeActionBarProps) {
56
+ const sending = send.status === "sending";
57
+ const blockedReason = send.status === "blocked" ? send.reason : undefined;
58
+
52
59
  return (
53
60
  <div className="flex items-center justify-between border-t border-line px-3 py-2">
54
- <div className="flex items-center gap-3">
61
+ <div className="flex min-w-0 items-center gap-3">
55
62
  <Button
56
63
  variant="primary"
57
64
  size="md"
58
65
  aria-busy={sending}
59
- title={!canSend ? unavailableReason : undefined}
60
- className="min-h-11 rounded-full px-4"
66
+ title={blockedReason}
67
+ className="min-h-11 shrink-0 rounded-full px-4"
68
+ data-testid="compose-send"
61
69
  icon={
62
70
  sending ? (
63
71
  <Loader2 className="size-4 animate-spin" />
@@ -67,8 +75,8 @@ export function ComposeActionBar({
67
75
  }
68
76
  onClick={() => {
69
77
  if (sending) return;
70
- if (!canSend) {
71
- if (unavailableReason) onUnavailable?.(unavailableReason);
78
+ if (blockedReason !== undefined) {
79
+ onBlocked(blockedReason);
72
80
  return;
73
81
  }
74
82
  onSend();
@@ -0,0 +1,10 @@
1
+ export const ComposeBodySkeleton = () => (
2
+ <div
3
+ aria-hidden="true"
4
+ data-testid="compose-body-skeleton"
5
+ className="min-h-[120px] px-3 py-2"
6
+ >
7
+ <div className="mb-2 h-8 animate-pulse rounded bg-surface-sunken" />
8
+ <div className="min-h-[80px] animate-pulse rounded bg-surface-sunken/50" />
9
+ </div>
10
+ );
@@ -33,20 +33,33 @@ const DUTCH_PROSE =
33
33
  /** The languages a Dutch-writing account has configured, most-used first. */
34
34
  const LANGUAGES = ["nl", "en", "de"];
35
35
 
36
+ const SIGNED_DOCUMENT = "<p></p><p>-- </p><p>Matthijs</p>";
37
+
38
+ const LONG_PLAIN_PROSE = [
39
+ "Beste Anna, hierbij de planning voor volgende week zoals besproken tijdens de vergadering van donderdag.",
40
+ "",
41
+ "| Region | Total |",
42
+ "| --- | --- |",
43
+ "| EMEA | 412 |",
44
+ ].join("\n");
45
+
36
46
  const noop = () => undefined;
37
47
 
38
48
  const Harness = ({
39
49
  initialHtml = "",
40
50
  initialText = "",
41
51
  startIn = "rich",
52
+ initialCaret,
42
53
  onConversionError,
43
54
  conversions,
44
55
  languages = LANGUAGES,
45
56
  quoted,
57
+ width = 680,
46
58
  }: {
47
59
  initialHtml?: string;
48
60
  initialText?: string;
49
61
  startIn?: "rich" | "plain";
62
+ initialCaret?: "start" | "end";
50
63
  onConversionError?: (failure: ConversionFailure) => void;
51
64
  conversions?: {
52
65
  toPlain: (value: RichTextValue) => string;
@@ -54,11 +67,15 @@ const Harness = ({
54
67
  };
55
68
  languages?: string[];
56
69
  quoted?: string;
70
+ width?: number;
57
71
  }) => {
58
72
  const [mode, setMode] = useState<"rich" | "plain">(startIn);
59
73
  const [failure, setFailure] = useState<ConversionFailure>();
60
74
  return (
61
- <div className="flex h-[460px] w-[680px] flex-col overflow-auto rounded-md border border-line bg-canvas">
75
+ <div
76
+ style={{ width }}
77
+ className="flex h-[460px] flex-col overflow-auto rounded-md border border-line bg-canvas"
78
+ >
62
79
  {failure && (
63
80
  <div
64
81
  role="alert"
@@ -74,6 +91,7 @@ const Harness = ({
74
91
  onModeChange={setMode}
75
92
  initialHtml={initialHtml}
76
93
  initialText={initialText}
94
+ initialCaret={initialCaret}
77
95
  onChange={noop}
78
96
  onConversionError={(reported) => {
79
97
  setFailure(reported);
@@ -136,6 +154,53 @@ export const RichDocument: Story = {
136
154
  args: { initialHtml: RICH_DOCUMENT },
137
155
  };
138
156
 
157
+ /**
158
+ * A new message opens above its signature. The caret used to land at the end of
159
+ * the document, which on any account that has one put the first keystroke under
160
+ * the sign-off.
161
+ */
162
+ export const OpensAboveTheSignature: Story = {
163
+ name: "A new message opens above the signature",
164
+ args: { initialHtml: SIGNED_DOCUMENT, initialCaret: "start" },
165
+ play: async ({ canvasElement }) => {
166
+ const editable = canvasElement.querySelector<HTMLElement>(
167
+ "[data-testid=compose-body]",
168
+ );
169
+ if (!editable) throw new Error("the rich surface is not mounted");
170
+
171
+ await waitFor(async () => {
172
+ await expect(editable).toHaveFocus();
173
+ });
174
+ await userEvent.keyboard("Hoi Anna");
175
+
176
+ await waitFor(async () => {
177
+ const text = editable.textContent ?? "";
178
+ await expect(text).toContain("Hoi Anna");
179
+ await expect(text.indexOf("Hoi Anna")).toBeLessThan(
180
+ text.indexOf("Matthijs"),
181
+ );
182
+ });
183
+ },
184
+ };
185
+
186
+ /**
187
+ * Plain prose wraps. A message whose every paragraph ran off the right edge
188
+ * could not be read back at all, which is the worse of the two failures at 390 —
189
+ * a pipe table wider than the surface is the one that gives.
190
+ */
191
+ export const PlainProseWraps: Story = {
192
+ name: "Plain, wrapping at a phone's width",
193
+ args: { startIn: "plain", initialText: LONG_PLAIN_PROSE, width: 390 },
194
+ play: async ({ canvasElement }) => {
195
+ const textarea = plainSurface(canvasElement);
196
+ if (!textarea) throw new Error("the plain surface is not mounted");
197
+ // A pixel of tolerance: sub-pixel layout rounding, not a scrollbar.
198
+ await expect(textarea.scrollWidth).toBeLessThanOrEqual(
199
+ textarea.clientWidth + 1,
200
+ );
201
+ },
202
+ };
203
+
139
204
  export const PlainDraft: Story = {
140
205
  name: "Plain, reopened from Markdown",
141
206
  args: { startIn: "plain", initialText: PLAIN_MARKDOWN },
@@ -9,7 +9,7 @@ import { ConfirmDialog } from "./confirm-dialog.js";
9
9
  import { PlainTextEditor } from "./plain-text-editor.js";
10
10
  import { markdownToHtml } from "./rich-text-document.js";
11
11
  import { RichTextEditor } from "./rich-text-editor.js";
12
- import type { RichTextValue } from "./rich-text-value.js";
12
+ import type { ComposeCaret, RichTextValue } from "./rich-text-value.js";
13
13
  import { useComposeLanguage } from "./use-compose-language.js";
14
14
 
15
15
  export interface ConversionFailure {
@@ -49,7 +49,11 @@ export interface ComposeBodyProps {
49
49
  initialText: string;
50
50
  onChange: (value: RichTextValue) => void;
51
51
  onSubmit?: () => void;
52
- autoFocus?: boolean;
52
+ /**
53
+ * Where the caret lands when the composer opens. Absent, the surface does
54
+ * not take focus.
55
+ */
56
+ initialCaret?: ComposeCaret;
53
57
  onConversionError: (failure: ConversionFailure) => void;
54
58
  conversions?: ComposeConversions;
55
59
  /** The account's writing languages, most-used first. */
@@ -73,7 +77,7 @@ export const ComposeBody = ({
73
77
  initialText,
74
78
  onChange,
75
79
  onSubmit,
76
- autoFocus = false,
80
+ initialCaret,
77
81
  onConversionError,
78
82
  conversions = DEFAULT_COMPOSE_CONVERSIONS,
79
83
  languages,
@@ -97,7 +101,7 @@ export const ComposeBody = ({
97
101
 
98
102
  // Detection reads the body the user typed. The quoted reply block is not part
99
103
  // of it — that lives outside the editor, in the form's own `quoted` slot.
100
- const { language, choose } = useComposeLanguage({
104
+ const { language, source, choose } = useComposeLanguage({
101
105
  languages,
102
106
  text: bodyText,
103
107
  initialLanguage,
@@ -166,6 +170,7 @@ export const ComposeBody = ({
166
170
  <ComposeLanguageChip
167
171
  language={language}
168
172
  languages={languages}
173
+ source={source}
169
174
  onSelect={choose}
170
175
  />
171
176
  <ComposeModeToggle mode={mode} onToggle={handleToggle} />
@@ -179,7 +184,7 @@ export const ComposeBody = ({
179
184
  value={plainText}
180
185
  onChange={handlePlainChange}
181
186
  onSubmit={onSubmit}
182
- autoFocus={focusSwitchedSurface}
187
+ initialCaret={focusSwitchedSurface ? "end" : initialCaret}
183
188
  lang={language}
184
189
  trailing={trailing}
185
190
  />
@@ -189,7 +194,7 @@ export const ComposeBody = ({
189
194
  initialHtml={richHtml}
190
195
  onChange={handleRichChange}
191
196
  onSubmit={onSubmit}
192
- autoFocus={autoFocus || focusSwitchedSurface}
197
+ initialCaret={focusSwitchedSurface ? "end" : initialCaret}
193
198
  lang={language}
194
199
  trailing={trailing}
195
200
  />
@@ -49,11 +49,13 @@ const Harness = ({
49
49
  const [showCc, setShowCc] = useState(initialCc !== undefined);
50
50
  const [showBcc, setShowBcc] = useState(initialBcc !== undefined);
51
51
  const [subject, setSubject] = useState(initialSubject);
52
+ const [isCollapsed, setIsCollapsed] = useState(collapsed);
52
53
 
53
54
  return (
54
55
  <div className="w-[560px] border border-line bg-surface">
55
56
  <ComposeHeader
56
- collapsed={collapsed}
57
+ collapsed={isCollapsed}
58
+ onExpand={() => setIsCollapsed(false)}
57
59
  summary={composeHeaderSummary({ to, cc, bcc, subject })}
58
60
  from={<FromRow email="alice@northwind.example" />}
59
61
  to={
@@ -133,6 +135,30 @@ export const CollapsedAndEmpty: Story = {
133
135
  render: () => <Harness collapsed />,
134
136
  };
135
137
 
138
+ /**
139
+ * The collapsed line is the way back. Pressing it puts the recipient rows
140
+ * back — with the keyboard up it is the only route to Cc, Bcc and the subject,
141
+ * and a bar that looks pressable and is not reads as a broken app.
142
+ */
143
+ export const CollapsedExpandsOnPress: Story = {
144
+ render: () => (
145
+ <Harness
146
+ collapsed
147
+ initialTo={[
148
+ { email: "ada@northwind.example", displayName: "Ada Lovelace" },
149
+ ]}
150
+ initialSubject="Re: Q3 planning"
151
+ />
152
+ ),
153
+ play: async ({ canvasElement }) => {
154
+ const canvas = within(canvasElement);
155
+ await userEvent.click(
156
+ canvas.getByRole("button", { name: "Show recipients and subject" }),
157
+ );
158
+ await expect(canvas.getByLabelText("To:")).toBeVisible();
159
+ },
160
+ };
161
+
136
162
  export const RevealingCcLeavesBccOnOffer: Story = {
137
163
  render: () => <Harness />,
138
164
  play: async ({ canvasElement }) => {
@@ -1,3 +1,4 @@
1
+ import { ChevronDown } from "lucide-react";
1
2
  import type { ReactNode } from "react";
2
3
  import type { AddressEntry } from "./compose-address-field.js";
3
4
 
@@ -10,6 +11,12 @@ export interface ComposeHeaderProps {
10
11
  collapsed?: boolean;
11
12
  /** One line of what the collapsed header stands in for. */
12
13
  summary?: string;
14
+ /**
15
+ * Puts the recipient rows back. Required, because the collapsed line is the
16
+ * only way back to Cc, Bcc and the subject while the keyboard is up, and a
17
+ * bar that looks pressable and is not is the failure this replaces.
18
+ */
19
+ onExpand: () => void;
13
20
  from: ReactNode;
14
21
  to: ReactNode;
15
22
  /** Present once the field has been revealed; absent leaves the reveal button. */
@@ -65,6 +72,7 @@ export const composeHeaderSummary = ({
65
72
  export const ComposeHeader = ({
66
73
  collapsed = false,
67
74
  summary = "",
75
+ onExpand,
68
76
  from,
69
77
  to,
70
78
  cc,
@@ -75,15 +83,19 @@ export const ComposeHeader = ({
75
83
  }: ComposeHeaderProps) => {
76
84
  if (collapsed) {
77
85
  return (
78
- <div
79
- className="flex items-center gap-2 px-3 py-1.5 border-b border-line overflow-hidden"
86
+ <button
87
+ type="button"
88
+ onClick={onExpand}
89
+ aria-label="Show recipients and subject"
90
+ className="flex min-h-11 w-full items-center gap-2 overflow-hidden border-b border-line px-3 py-1.5 text-left transition-colors hover:bg-surface-sunken"
80
91
  data-testid="compose-header-collapsed"
81
92
  >
82
93
  <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>
94
+ <ChevronDown
95
+ className="ml-auto size-4 shrink-0 text-fg-muted"
96
+ aria-hidden="true"
97
+ />
98
+ </button>
87
99
  );
88
100
  }
89
101
 
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
3
  import { expect, userEvent, within } from "storybook/test";
4
4
  import { ComposeLanguageChip } from "./compose-language-chip.js";
5
+ import type { ComposeLanguageSource } from "./use-compose-language.js";
5
6
 
6
7
  const LANGUAGES = ["nl", "en", "de"];
7
8
 
@@ -14,9 +15,11 @@ const FIREFOX_HELP =
14
15
 
15
16
  const Chip = ({
16
17
  initial = "nl",
18
+ source = "account",
17
19
  helpText,
18
20
  }: {
19
21
  initial?: string;
22
+ source?: ComposeLanguageSource;
20
23
  helpText?: string;
21
24
  }) => {
22
25
  const [language, setLanguage] = useState(initial);
@@ -25,6 +28,7 @@ const Chip = ({
25
28
  <ComposeLanguageChip
26
29
  language={language}
27
30
  languages={LANGUAGES}
31
+ source={language === initial ? source : "manual"}
28
32
  onSelect={setLanguage}
29
33
  helpText={helpText}
30
34
  />
@@ -50,10 +54,40 @@ export default meta;
50
54
  type Story = StoryObj<typeof Chip>;
51
55
 
52
56
  export const Closed: Story = {
53
- name: "The chip",
57
+ name: "The chip — the account's own language",
54
58
  args: {},
55
59
  };
56
60
 
61
+ /**
62
+ * The control changes under the user while they type, so the value alone does
63
+ * not say where it came from. The tint marks a message that is not going out in
64
+ * the account's own language, and the accessible name says whether the tag was
65
+ * guessed, chosen or inherited.
66
+ */
67
+ export const NotTheAccountLanguage: Story = {
68
+ name: "Detected — a message in another language",
69
+ args: { initial: "de", source: "detected" },
70
+ play: async ({ canvasElement }) => {
71
+ const chip = within(canvasElement).getByTestId("compose-language-chip");
72
+ await expect(chip).toHaveAttribute("data-language-source", "detected");
73
+ await expect(chip.getAttribute("aria-label")).toContain(
74
+ "detected from what you wrote",
75
+ );
76
+ },
77
+ };
78
+
79
+ export const ChosenByHand: Story = {
80
+ name: "Chosen — detection stops for this message",
81
+ args: { initial: "de", source: "manual" },
82
+ play: async ({ canvasElement }) => {
83
+ const chip = within(canvasElement).getByTestId("compose-language-chip");
84
+ await expect(chip).toHaveAttribute("data-language-source", "manual");
85
+ await expect(chip.getAttribute("aria-label")).toContain(
86
+ "chosen for this message",
87
+ );
88
+ },
89
+ };
90
+
57
91
  const openMenu = async (canvasElement: HTMLElement): Promise<void> => {
58
92
  await userEvent.click(
59
93
  within(canvasElement).getByRole("button", {
@@ -7,12 +7,25 @@ import {
7
7
  } from "../lib/compose-language.js";
8
8
  import { rovingNextIndex } from "../lib/roving-focus.js";
9
9
  import { Button } from "./button.js";
10
+ import type { ComposeLanguageSource } from "./use-compose-language.js";
11
+
12
+ const sourceNote: Record<ComposeLanguageSource, string> = {
13
+ account: "the account default",
14
+ detected: "detected from what you wrote",
15
+ manual: "chosen for this message",
16
+ };
10
17
 
11
18
  export interface ComposeLanguageChipProps {
12
19
  /** The BCP 47 tag the message is currently being written in. */
13
20
  language: string;
14
21
  /** The account's configured tags — the menu, and detection's candidate set. */
15
22
  languages: readonly string[];
23
+ /**
24
+ * Where the tag came from. The control changes under the user while they
25
+ * type, so its name says whether the value was guessed, chosen or inherited
26
+ * from the account rather than leaving two letters to stand for all three.
27
+ */
28
+ source: ComposeLanguageSource;
16
29
  onSelect: (tag: string) => void;
17
30
  /**
18
31
  * The sentence naming where the browser keeps its dictionary setting.
@@ -32,6 +45,7 @@ export interface ComposeLanguageChipProps {
32
45
  export const ComposeLanguageChip = ({
33
46
  language,
34
47
  languages,
48
+ source,
35
49
  onSelect,
36
50
  helpText,
37
51
  }: ComposeLanguageChipProps) => {
@@ -98,14 +112,18 @@ export const ComposeLanguageChip = ({
98
112
  ref={triggerRef}
99
113
  variant="ghost"
100
114
  size="md"
101
- aria-label={`Message language: ${languageLabel(language)}`}
115
+ aria-label={`Message language: ${languageLabel(language)}, ${sourceNote[source]}`}
102
116
  aria-haspopup="menu"
103
117
  aria-expanded={open}
104
118
  aria-controls={open ? menuId : undefined}
105
119
  onClick={() => setOpen((value) => !value)}
106
120
  data-testid="compose-language-chip"
107
121
  data-language={language}
108
- className="min-h-11 min-w-11 shrink-0 px-2 font-medium"
122
+ data-language-source={source}
123
+ className={cn(
124
+ "min-h-11 min-w-11 shrink-0 px-2 font-medium",
125
+ language !== languages[0] && "bg-accent-2-soft text-accent-2",
126
+ )}
109
127
  >
110
128
  {languageChipLabel(language)}
111
129
  </Button>
@@ -1,7 +1,18 @@
1
1
  import { AlertTriangle, ArrowRight } from "lucide-react";
2
+ import type { Ref } from "react";
3
+
4
+ /**
5
+ * The one sentence this failure gets. The banner states it, and Send refuses
6
+ * with the same words — a second wording of the same fact reads as a second
7
+ * problem.
8
+ */
9
+ export const SMTP_MISSING_MESSAGE =
10
+ "This account can't send mail until SMTP is configured.";
2
11
 
3
12
  export interface ComposeSmtpMissingBannerProps {
4
13
  onConfigure: () => void;
14
+ /** The fix, so a blocked Send can put the cursor on it. */
15
+ configureRef?: Ref<HTMLButtonElement>;
5
16
  }
6
17
 
7
18
  /**
@@ -12,6 +23,7 @@ export interface ComposeSmtpMissingBannerProps {
12
23
  */
13
24
  export const ComposeSmtpMissingBanner = ({
14
25
  onConfigure,
26
+ configureRef,
15
27
  }: ComposeSmtpMissingBannerProps) => (
16
28
  <div
17
29
  role="alert"
@@ -23,12 +35,12 @@ export const ComposeSmtpMissingBanner = ({
23
35
  aria-hidden="true"
24
36
  />
25
37
  <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>
38
+ <p className="text-sm font-medium text-warning">{SMTP_MISSING_MESSAGE}</p>
29
39
  <button
40
+ ref={configureRef}
30
41
  type="button"
31
42
  onClick={onConfigure}
43
+ data-testid="compose-smtp-configure"
32
44
  className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-warning hover:underline"
33
45
  >
34
46
  Configure SMTP