@remit/ui 0.0.99 → 0.0.101
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/attachment-list.render.test.ts +120 -0
- package/src/components/attachment-list.stories.tsx +159 -0
- package/src/components/attachment-list.tsx +182 -0
- package/src/components/compose-mode-toggle.tsx +35 -0
- package/src/components/message-row.render.test.ts +31 -0
- package/src/components/message-row.tsx +10 -2
- package/src/components/plain-text-editor.mount.test.ts +238 -0
- package/src/components/plain-text-editor.stories.tsx +227 -0
- package/src/components/plain-text-editor.tsx +178 -0
- package/src/components/reading-pane.stories.tsx +58 -2
- package/src/components/rich-text-document.ts +111 -3
- package/src/components/rich-text-editor.stories.tsx +76 -0
- package/src/components/rich-text-editor.tsx +4 -1
- package/src/components/rich-text-formatting.test.ts +153 -0
- package/src/components/rich-text-markdown.test.ts +131 -0
- package/src/components/rich-text-markdown.ts +155 -0
- package/src/components/rich-text-toolbar.tsx +68 -46
- package/src/components/rich-text-value.ts +11 -1
- package/src/index.ts +11 -0
- package/src/lib/attachment-file.test.ts +162 -0
- package/src/lib/attachment-file.ts +94 -0
- package/src/rich-text.ts +17 -3
- package/src/tokens.css +4 -0
package/package.json
CHANGED
|
@@ -0,0 +1,120 @@
|
|
|
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 { AttachmentList, type AttachmentListProps } from "./attachment-list.js";
|
|
6
|
+
|
|
7
|
+
const render = (props: AttachmentListProps): string =>
|
|
8
|
+
renderToString(createElement(AttachmentList, props));
|
|
9
|
+
|
|
10
|
+
const item = (
|
|
11
|
+
overrides: Partial<AttachmentListProps["attachments"][number]>,
|
|
12
|
+
) => ({
|
|
13
|
+
attachmentId: "part-1",
|
|
14
|
+
filename: "Quarterly report.pdf",
|
|
15
|
+
typeLabel: "PDF",
|
|
16
|
+
sizeOctets: 1024 * 512,
|
|
17
|
+
download: { status: "idle" } as const,
|
|
18
|
+
...overrides,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("AttachmentList", () => {
|
|
22
|
+
it("renders nothing when the message has no attachments", () => {
|
|
23
|
+
assert.equal(render({ attachments: [], onDownload: () => undefined }), "");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("names the file, its type and its size", () => {
|
|
27
|
+
const html = render({
|
|
28
|
+
attachments: [item({})],
|
|
29
|
+
onDownload: () => undefined,
|
|
30
|
+
});
|
|
31
|
+
assert.match(html, /Quarterly report\.pdf/);
|
|
32
|
+
assert.match(html, /PDF/);
|
|
33
|
+
assert.match(html, /512 KB/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("gives every attachment a labelled download control", () => {
|
|
37
|
+
const html = render({
|
|
38
|
+
attachments: [
|
|
39
|
+
item({}),
|
|
40
|
+
item({ attachmentId: "part-2", filename: "photo.png" }),
|
|
41
|
+
],
|
|
42
|
+
onDownload: () => undefined,
|
|
43
|
+
});
|
|
44
|
+
assert.match(html, /aria-label="Download Quarterly report\.pdf"/);
|
|
45
|
+
assert.match(html, /aria-label="Download photo\.png"/);
|
|
46
|
+
assert.match(html, /2 attachments/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("counts a single attachment in the singular", () => {
|
|
50
|
+
const html = render({
|
|
51
|
+
attachments: [item({})],
|
|
52
|
+
onDownload: () => undefined,
|
|
53
|
+
});
|
|
54
|
+
assert.match(html, /1 attachment</);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("isolates the filename's text direction", () => {
|
|
58
|
+
const html = render({
|
|
59
|
+
attachments: [item({})],
|
|
60
|
+
onDownload: () => undefined,
|
|
61
|
+
});
|
|
62
|
+
assert.match(html, /<bdi/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("disables the control while a download is in flight", () => {
|
|
66
|
+
const html = render({
|
|
67
|
+
attachments: [item({ download: { status: "downloading" } })],
|
|
68
|
+
onDownload: () => undefined,
|
|
69
|
+
});
|
|
70
|
+
assert.match(html, /disabled=""/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("states what failed, the likely fix, and offers a retry", () => {
|
|
74
|
+
const html = render({
|
|
75
|
+
attachments: [
|
|
76
|
+
item({
|
|
77
|
+
download: {
|
|
78
|
+
status: "failed",
|
|
79
|
+
title: "Your session expired",
|
|
80
|
+
detail: "Sign in again, then download it once more.",
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
],
|
|
84
|
+
onDownload: () => undefined,
|
|
85
|
+
});
|
|
86
|
+
assert.match(html, /role="alert"/);
|
|
87
|
+
assert.match(html, /Your session expired/);
|
|
88
|
+
assert.match(html, /Sign in again, then download it once more\./);
|
|
89
|
+
assert.match(html, /Try again/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("offers a report link when the failure came with one", () => {
|
|
93
|
+
const html = render({
|
|
94
|
+
attachments: [
|
|
95
|
+
item({
|
|
96
|
+
download: {
|
|
97
|
+
status: "failed",
|
|
98
|
+
title: "Couldn't download this attachment",
|
|
99
|
+
detail: "The server refused the request.",
|
|
100
|
+
reportUrl:
|
|
101
|
+
"https://github.com/remit-mail/reader/issues/new?title=x",
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
],
|
|
105
|
+
onDownload: () => undefined,
|
|
106
|
+
});
|
|
107
|
+
assert.match(html, /Report this/);
|
|
108
|
+
assert.match(html, /issues\/new\?title=x/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("says so when the server promised an attachment no part describes", () => {
|
|
112
|
+
const html = render({
|
|
113
|
+
attachments: [],
|
|
114
|
+
onDownload: () => undefined,
|
|
115
|
+
hasUnlistedAttachment: true,
|
|
116
|
+
});
|
|
117
|
+
assert.match(html, /carries an attachment, but none of/);
|
|
118
|
+
assert.match(html, /Attachment</);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { type AttachmentItem, AttachmentList } from "./attachment-list.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The attachment list on an open message (#683). Every row saves a file, so
|
|
6
|
+
* every row is a button; the paperclip in the heading is the only glyph and it
|
|
7
|
+
* is decoration. Nothing here fetches — the app owns the download and hands
|
|
8
|
+
* back per-row state, which is what makes the failure story below the same
|
|
9
|
+
* component the app renders.
|
|
10
|
+
*/
|
|
11
|
+
const meta: Meta<typeof AttachmentList> = {
|
|
12
|
+
title: "Mail/AttachmentList",
|
|
13
|
+
component: AttachmentList,
|
|
14
|
+
parameters: { layout: "padded" },
|
|
15
|
+
};
|
|
16
|
+
export default meta;
|
|
17
|
+
|
|
18
|
+
type Story = StoryObj<typeof AttachmentList>;
|
|
19
|
+
|
|
20
|
+
const report: AttachmentItem = {
|
|
21
|
+
attachmentId: "part-2",
|
|
22
|
+
filename: "Q3 board pack.pdf",
|
|
23
|
+
typeLabel: "PDF",
|
|
24
|
+
sizeOctets: 2_411_724,
|
|
25
|
+
download: { status: "idle" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const OneAttachment: Story = {
|
|
29
|
+
args: {
|
|
30
|
+
attachments: [report],
|
|
31
|
+
onDownload: (id) => alert(`Download ${id}`),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const SeveralAttachments: Story = {
|
|
36
|
+
args: {
|
|
37
|
+
attachments: [
|
|
38
|
+
report,
|
|
39
|
+
{
|
|
40
|
+
attachmentId: "part-3",
|
|
41
|
+
filename: "site-plan.png",
|
|
42
|
+
typeLabel: "PNG",
|
|
43
|
+
sizeOctets: 486_120,
|
|
44
|
+
download: { status: "idle" },
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
attachmentId: "part-4",
|
|
48
|
+
filename: "notes.txt",
|
|
49
|
+
typeLabel: "PLAIN",
|
|
50
|
+
sizeOctets: 812,
|
|
51
|
+
download: { status: "idle" },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
attachmentId: "part-5",
|
|
55
|
+
filename: "archive",
|
|
56
|
+
typeLabel: "FILE",
|
|
57
|
+
sizeOctets: 1024 ** 3 + 1024 ** 2 * 200,
|
|
58
|
+
download: { status: "idle" },
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
onDownload: (id) => alert(`Download ${id}`),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const Downloading: Story = {
|
|
66
|
+
args: {
|
|
67
|
+
attachments: [
|
|
68
|
+
{ ...report, download: { status: "downloading" } },
|
|
69
|
+
{
|
|
70
|
+
attachmentId: "part-3",
|
|
71
|
+
filename: "site-plan.png",
|
|
72
|
+
typeLabel: "PNG",
|
|
73
|
+
sizeOctets: 486_120,
|
|
74
|
+
download: { status: "idle" },
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
onDownload: (id) => alert(`Download ${id}`),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A fetch that failed. The row keeps its control, and the alert underneath
|
|
83
|
+
* names what broke and what to do about it — a dead click that leaves the user
|
|
84
|
+
* guessing whether the app, the server or they themselves are at fault is the
|
|
85
|
+
* outcome this list exists to make impossible.
|
|
86
|
+
*/
|
|
87
|
+
export const DownloadFailed: Story = {
|
|
88
|
+
args: {
|
|
89
|
+
attachments: [
|
|
90
|
+
{
|
|
91
|
+
...report,
|
|
92
|
+
download: {
|
|
93
|
+
status: "failed",
|
|
94
|
+
title: "This attachment is missing from storage",
|
|
95
|
+
detail:
|
|
96
|
+
"Remit has the message but not the file. Re-sync the account from Settings, then try again.",
|
|
97
|
+
reportUrl: "https://github.com/remit-mail/reader/issues/new",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
attachmentId: "part-3",
|
|
102
|
+
filename: "site-plan.png",
|
|
103
|
+
typeLabel: "PNG",
|
|
104
|
+
sizeOctets: 486_120,
|
|
105
|
+
download: { status: "idle" },
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
onDownload: (id) => alert(`Download ${id}`),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Names written to deceive, as `sanitizeAttachmentFilename` leaves them. The
|
|
114
|
+
* senders wrote `../../../etc/passwd`, `invoice<RLO>gnp.exe` — which renders as
|
|
115
|
+
* `invoiceexe.png` with the override intact — and 400 characters of padding.
|
|
116
|
+
* The list shows exactly the name the file is saved under, so what is read is
|
|
117
|
+
* what lands.
|
|
118
|
+
*/
|
|
119
|
+
export const HostileFilename: Story = {
|
|
120
|
+
args: {
|
|
121
|
+
attachments: [
|
|
122
|
+
{
|
|
123
|
+
attachmentId: "part-6",
|
|
124
|
+
filename: "passwd",
|
|
125
|
+
typeLabel: "FILE",
|
|
126
|
+
sizeOctets: 3_120,
|
|
127
|
+
download: { status: "idle" },
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
attachmentId: "part-7",
|
|
131
|
+
filename: "invoicegnp.exe",
|
|
132
|
+
typeLabel: "FILE",
|
|
133
|
+
sizeOctets: 118_400,
|
|
134
|
+
download: { status: "idle" },
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
attachmentId: "part-8",
|
|
138
|
+
filename: `${"long-name-".repeat(11)}report.pdf`,
|
|
139
|
+
typeLabel: "PDF",
|
|
140
|
+
sizeOctets: 44_000,
|
|
141
|
+
download: { status: "idle" },
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
onDownload: (id) => alert(`Download ${id}`),
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The mail server flagged the message as carrying an attachment, but no body
|
|
150
|
+
* part describes one. Saying nothing here is what made the original paperclip
|
|
151
|
+
* read as broken.
|
|
152
|
+
*/
|
|
153
|
+
export const UnlistedAttachment: Story = {
|
|
154
|
+
args: {
|
|
155
|
+
attachments: [],
|
|
156
|
+
onDownload: () => undefined,
|
|
157
|
+
hasUnlistedAttachment: true,
|
|
158
|
+
},
|
|
159
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { AlertCircle, Download, Loader2, Paperclip } from "lucide-react";
|
|
2
|
+
import { formatByteSize } from "../lib/attachment-file.js";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
|
|
5
|
+
export type AttachmentDownloadState =
|
|
6
|
+
| { status: "idle" }
|
|
7
|
+
| { status: "downloading" }
|
|
8
|
+
| { status: "failed"; title: string; detail: string; reportUrl?: string };
|
|
9
|
+
|
|
10
|
+
export interface AttachmentItem {
|
|
11
|
+
attachmentId: string;
|
|
12
|
+
/**
|
|
13
|
+
* The name shown and the name saved — one string for both, already reduced
|
|
14
|
+
* by `sanitizeAttachmentFilename`. Anything that displays a different name
|
|
15
|
+
* than it writes to disk is the bug this field exists to prevent.
|
|
16
|
+
*/
|
|
17
|
+
filename: string;
|
|
18
|
+
/** Short type label derived from the MIME subtype, e.g. `PDF`. */
|
|
19
|
+
typeLabel: string;
|
|
20
|
+
sizeOctets: number;
|
|
21
|
+
download: AttachmentDownloadState;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AttachmentListProps {
|
|
25
|
+
attachments: readonly AttachmentItem[];
|
|
26
|
+
onDownload: (attachmentId: string) => void;
|
|
27
|
+
/**
|
|
28
|
+
* The message is flagged on the server as carrying an attachment that none
|
|
29
|
+
* of its body parts describe. Says so rather than rendering nothing, which
|
|
30
|
+
* is indistinguishable from a broken app to someone who saw the paperclip
|
|
31
|
+
* in the list.
|
|
32
|
+
*/
|
|
33
|
+
hasUnlistedAttachment?: boolean;
|
|
34
|
+
className?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function DownloadFailure({
|
|
38
|
+
state,
|
|
39
|
+
onRetry,
|
|
40
|
+
}: {
|
|
41
|
+
state: Extract<AttachmentDownloadState, { status: "failed" }>;
|
|
42
|
+
onRetry: () => void;
|
|
43
|
+
}) {
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
role="alert"
|
|
47
|
+
data-testid="attachment-error"
|
|
48
|
+
className="flex items-start gap-2 border-t border-danger/30 bg-danger-soft px-2 py-2 text-xs"
|
|
49
|
+
>
|
|
50
|
+
<AlertCircle
|
|
51
|
+
className="mt-0.5 size-3.5 shrink-0 text-danger"
|
|
52
|
+
aria-hidden
|
|
53
|
+
/>
|
|
54
|
+
<div className="min-w-0 flex-1">
|
|
55
|
+
<p className="font-medium text-danger">{state.title}</p>
|
|
56
|
+
<p className="mt-0.5 text-fg-muted">{state.detail}</p>
|
|
57
|
+
</div>
|
|
58
|
+
<div className="flex shrink-0 items-center gap-3">
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
onClick={onRetry}
|
|
62
|
+
className="font-medium text-accent hover:underline"
|
|
63
|
+
>
|
|
64
|
+
Try again
|
|
65
|
+
</button>
|
|
66
|
+
{state.reportUrl && (
|
|
67
|
+
<a
|
|
68
|
+
href={state.reportUrl}
|
|
69
|
+
target="_blank"
|
|
70
|
+
rel="noreferrer"
|
|
71
|
+
className="font-medium text-accent hover:underline"
|
|
72
|
+
>
|
|
73
|
+
Report this
|
|
74
|
+
</a>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The attachments carried by an open message: what each one is, how big it is,
|
|
83
|
+
* and a control that saves it.
|
|
84
|
+
*
|
|
85
|
+
* Every row is a real button. The paperclip in the section heading is the only
|
|
86
|
+
* glyph here and it is labelled as decoration — a paperclip that looks
|
|
87
|
+
* pressable and is not reads as a broken download, which is the failure this
|
|
88
|
+
* list replaces (#683).
|
|
89
|
+
*/
|
|
90
|
+
export function AttachmentList({
|
|
91
|
+
attachments,
|
|
92
|
+
onDownload,
|
|
93
|
+
hasUnlistedAttachment = false,
|
|
94
|
+
className,
|
|
95
|
+
}: AttachmentListProps) {
|
|
96
|
+
if (attachments.length === 0 && !hasUnlistedAttachment) return null;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<section
|
|
100
|
+
aria-label="Attachments"
|
|
101
|
+
data-testid="attachment-list"
|
|
102
|
+
className={cn("space-y-1.5", className)}
|
|
103
|
+
>
|
|
104
|
+
<h3 className="flex items-center gap-1.5 text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
|
|
105
|
+
<Paperclip className="size-3" aria-hidden />
|
|
106
|
+
{attachments.length === 0
|
|
107
|
+
? "Attachment"
|
|
108
|
+
: attachments.length === 1
|
|
109
|
+
? "1 attachment"
|
|
110
|
+
: `${attachments.length} attachments`}
|
|
111
|
+
</h3>
|
|
112
|
+
|
|
113
|
+
{attachments.length > 0 && (
|
|
114
|
+
<ul className="overflow-hidden rounded-sm border border-line bg-surface">
|
|
115
|
+
{attachments.map((attachment) => {
|
|
116
|
+
const isDownloading = attachment.download.status === "downloading";
|
|
117
|
+
return (
|
|
118
|
+
<li
|
|
119
|
+
key={attachment.attachmentId}
|
|
120
|
+
className="border-t border-line first:border-t-0"
|
|
121
|
+
>
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
onClick={() => onDownload(attachment.attachmentId)}
|
|
125
|
+
disabled={isDownloading}
|
|
126
|
+
data-testid="attachment-download"
|
|
127
|
+
aria-label={`Download ${attachment.filename}`}
|
|
128
|
+
className="flex w-full items-center gap-2 px-2 py-2 text-left transition-colors hover:bg-surface-sunken disabled:cursor-progress disabled:opacity-60"
|
|
129
|
+
>
|
|
130
|
+
<span className="min-w-0 flex-1">
|
|
131
|
+
{/* <bdi> isolates the name's own direction so a
|
|
132
|
+
right-to-left filename cannot reorder the label
|
|
133
|
+
around it. */}
|
|
134
|
+
<bdi
|
|
135
|
+
className="block truncate text-sm text-fg"
|
|
136
|
+
title={attachment.filename}
|
|
137
|
+
>
|
|
138
|
+
{attachment.filename}
|
|
139
|
+
</bdi>
|
|
140
|
+
<span className="block truncate text-2xs text-fg-subtle">
|
|
141
|
+
{attachment.typeLabel} ·{" "}
|
|
142
|
+
{formatByteSize(attachment.sizeOctets)}
|
|
143
|
+
</span>
|
|
144
|
+
</span>
|
|
145
|
+
{isDownloading ? (
|
|
146
|
+
<Loader2
|
|
147
|
+
className="size-4 shrink-0 animate-spin text-fg-subtle"
|
|
148
|
+
aria-hidden
|
|
149
|
+
/>
|
|
150
|
+
) : (
|
|
151
|
+
<Download
|
|
152
|
+
className="size-4 shrink-0 text-fg-subtle"
|
|
153
|
+
aria-hidden
|
|
154
|
+
/>
|
|
155
|
+
)}
|
|
156
|
+
</button>
|
|
157
|
+
{attachment.download.status === "failed" && (
|
|
158
|
+
<DownloadFailure
|
|
159
|
+
state={attachment.download}
|
|
160
|
+
onRetry={() => onDownload(attachment.attachmentId)}
|
|
161
|
+
/>
|
|
162
|
+
)}
|
|
163
|
+
</li>
|
|
164
|
+
);
|
|
165
|
+
})}
|
|
166
|
+
</ul>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{hasUnlistedAttachment && (
|
|
170
|
+
<p
|
|
171
|
+
role="alert"
|
|
172
|
+
data-testid="attachment-unlisted"
|
|
173
|
+
className="rounded-sm border border-line bg-surface px-2 py-2 text-xs text-fg-muted"
|
|
174
|
+
>
|
|
175
|
+
The mail server says this message carries an attachment, but none of
|
|
176
|
+
its parts describe one. Re-sync the account from Settings; if the
|
|
177
|
+
attachment still does not appear, the message is worth reporting.
|
|
178
|
+
</p>
|
|
179
|
+
)}
|
|
180
|
+
</section>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { Button } from "./button.js";
|
|
3
|
+
|
|
4
|
+
export type ComposeBodyMode = "rich" | "plain";
|
|
5
|
+
|
|
6
|
+
export interface ComposeModeToggleProps {
|
|
7
|
+
mode: ComposeBodyMode;
|
|
8
|
+
onToggle: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A text button, not an icon. The two glyphs a reader would reach for are
|
|
13
|
+
* already spoken for in an editor toolbar — the eraser clears formatting on a
|
|
14
|
+
* selection and the `A` opens formatting options — and the label reads the same
|
|
15
|
+
* in both states, so the control never changes under the finger.
|
|
16
|
+
*/
|
|
17
|
+
export const ComposeModeToggle = ({
|
|
18
|
+
mode,
|
|
19
|
+
onToggle,
|
|
20
|
+
}: ComposeModeToggleProps) => (
|
|
21
|
+
<Button
|
|
22
|
+
variant="ghost"
|
|
23
|
+
size="md"
|
|
24
|
+
aria-pressed={mode === "plain"}
|
|
25
|
+
title="Plain text"
|
|
26
|
+
onClick={onToggle}
|
|
27
|
+
data-testid="compose-mode-toggle"
|
|
28
|
+
className={cn(
|
|
29
|
+
"min-h-11 shrink-0",
|
|
30
|
+
mode === "plain" && "bg-accent-2-soft text-accent-2",
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
Plain text
|
|
34
|
+
</Button>
|
|
35
|
+
);
|
|
@@ -138,3 +138,34 @@ describe("CompactRow", () => {
|
|
|
138
138
|
assert.match(html, /Q3 planning notes/);
|
|
139
139
|
});
|
|
140
140
|
});
|
|
141
|
+
|
|
142
|
+
describe("attachment indicator", () => {
|
|
143
|
+
// A list row cannot download anything — the file lives on the message, which
|
|
144
|
+
// is one pane over. So the paperclip here is metadata and says so, rather
|
|
145
|
+
// than sitting inertly where a control belongs (#683).
|
|
146
|
+
it("marks a comfortable row as carrying an attachment, without a control", () => {
|
|
147
|
+
const html = renderToString(
|
|
148
|
+
createElement(ComfortableRow, {
|
|
149
|
+
thread: { ...base, isRead: true, hasAttachment: true },
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
assert.match(html, /role="img"/);
|
|
153
|
+
assert.match(html, /aria-label="Has an attachment"/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("marks a compact row as carrying an attachment", () => {
|
|
157
|
+
const html = renderToString(
|
|
158
|
+
createElement(CompactRow, {
|
|
159
|
+
thread: { ...base, isRead: true, hasAttachment: true },
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
assert.match(html, /aria-label="Has an attachment"/);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("renders no indicator when the message carries nothing", () => {
|
|
166
|
+
const html = renderToString(
|
|
167
|
+
createElement(ComfortableRow, { thread: { ...base, isRead: true } }),
|
|
168
|
+
);
|
|
169
|
+
assert.doesNotMatch(html, /Has an attachment/);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -98,7 +98,11 @@ export function CompactRowBody({ thread }: { thread: ThreadRowData }) {
|
|
|
98
98
|
<span className="text-fg-subtle"> — {thread.snippet}</span>
|
|
99
99
|
</span>
|
|
100
100
|
{thread.hasAttachment && (
|
|
101
|
-
<Paperclip
|
|
101
|
+
<Paperclip
|
|
102
|
+
className="size-3 shrink-0 text-fg-subtle"
|
|
103
|
+
role="img"
|
|
104
|
+
aria-label="Has an attachment"
|
|
105
|
+
/>
|
|
102
106
|
)}
|
|
103
107
|
<span className="w-11 shrink-0 text-right text-2xs text-fg-subtle tabular-nums">
|
|
104
108
|
{thread.timeLabel}
|
|
@@ -192,7 +196,11 @@ export function ComfortableRowTextContent({
|
|
|
192
196
|
<Star className="size-3 shrink-0 fill-warning text-warning" />
|
|
193
197
|
)}
|
|
194
198
|
{thread.hasAttachment && (
|
|
195
|
-
<Paperclip
|
|
199
|
+
<Paperclip
|
|
200
|
+
className="size-3 shrink-0 text-fg-subtle"
|
|
201
|
+
role="img"
|
|
202
|
+
aria-label="Has an attachment"
|
|
203
|
+
/>
|
|
196
204
|
)}
|
|
197
205
|
</span>
|
|
198
206
|
<span className="flex items-center gap-1.5">
|