@remit/ui 0.0.31 → 0.0.33
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/app-shell.render.test.ts +4 -3
- package/src/components/message-row.render.test.ts +26 -0
- package/src/components/message-row.stories.tsx +32 -2
- package/src/components/message-row.tsx +89 -12
- package/src/components/reading-pane.render.test.ts +3 -2
- package/src/components/reading-pane.tsx +9 -31
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -109,8 +109,9 @@ describe("AppShell render honors the pane layout (#784)", () => {
|
|
|
109
109
|
thread,
|
|
110
110
|
intelligence,
|
|
111
111
|
};
|
|
112
|
-
// The reading
|
|
113
|
-
|
|
112
|
+
// The desktop reading-pane toolbar keeps the kit's default shortcut hints;
|
|
113
|
+
// the mobile pane passes its own labels without them.
|
|
114
|
+
const readingPaneMarker = /title="Reply \(r\)"/;
|
|
114
115
|
// "Known sender" is the wellknown trust label, rendered only by the rail.
|
|
115
116
|
const intelligenceMarker = /Known sender/;
|
|
116
117
|
|
|
@@ -175,7 +176,7 @@ describe("AppShell narrow message view: width-gated in-place swap", () => {
|
|
|
175
176
|
// narrow single pane is showing the message view.
|
|
176
177
|
const messageViewMarker = /aria-label="Back to messages"/;
|
|
177
178
|
// The desktop reading-pane toolbar — present only at/above 1024.
|
|
178
|
-
const readingPaneMarker = /
|
|
179
|
+
const readingPaneMarker = /title="Reply \(r\)"/;
|
|
179
180
|
|
|
180
181
|
it("below 1024 defaults to the list, not the message view", () => {
|
|
181
182
|
const html = render({ ...withThread, initialWidth: 800 });
|
|
@@ -37,6 +37,32 @@ describe("ComfortableRow", () => {
|
|
|
37
37
|
});
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
describe("ComfortableRow selection slot", () => {
|
|
41
|
+
it("renders no checkbox without a selection (non-selectable mode)", () => {
|
|
42
|
+
const html = renderToString(
|
|
43
|
+
createElement(ComfortableRow, { thread: { ...base, isRead: true } }),
|
|
44
|
+
);
|
|
45
|
+
assert.doesNotMatch(html, /Select message/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("renders a checkbox reflecting the checked state", () => {
|
|
49
|
+
const unchecked = renderToString(
|
|
50
|
+
createElement(ComfortableRow, {
|
|
51
|
+
thread: { ...base, isRead: true },
|
|
52
|
+
selection: { checked: false, onToggle: () => undefined },
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
const checked = renderToString(
|
|
56
|
+
createElement(ComfortableRow, {
|
|
57
|
+
thread: { ...base, isRead: true },
|
|
58
|
+
selection: { checked: true, onToggle: () => undefined },
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
assert.match(unchecked, /Select message/);
|
|
62
|
+
assert.match(checked, /Deselect message/);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
40
66
|
describe("CompactRow", () => {
|
|
41
67
|
it("renders fromName and subject", () => {
|
|
42
68
|
const html = renderToString(
|
|
@@ -112,10 +112,40 @@ export const Compact: Story = {
|
|
|
112
112
|
export const States: Story = {
|
|
113
113
|
render: () => (
|
|
114
114
|
<List>
|
|
115
|
-
<ComfortableRow thread={unread} />
|
|
116
|
-
<ComfortableRow thread={read} />
|
|
115
|
+
<ComfortableRow thread={unread} active />
|
|
116
|
+
<ComfortableRow thread={read} focused />
|
|
117
117
|
<ComfortableRow thread={starred} />
|
|
118
118
|
<ComfortableRow thread={suspicious} />
|
|
119
119
|
</List>
|
|
120
120
|
),
|
|
121
121
|
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Selectable rows. The checkbox layers over the avatar: hidden until hover
|
|
125
|
+
* while unchecked, pinned visible once checked or while the list is in
|
|
126
|
+
* multi-select mode. A row rendered without `selection` — the brief and
|
|
127
|
+
* Flagged before they gained selection — shows the avatar alone.
|
|
128
|
+
*/
|
|
129
|
+
export const Selectable: Story = {
|
|
130
|
+
render: () => (
|
|
131
|
+
<List>
|
|
132
|
+
<ComfortableRow
|
|
133
|
+
thread={unread}
|
|
134
|
+
selection={{ checked: true, onToggle: () => undefined }}
|
|
135
|
+
/>
|
|
136
|
+
<ComfortableRow
|
|
137
|
+
thread={read}
|
|
138
|
+
selection={{ checked: false, onToggle: () => undefined }}
|
|
139
|
+
/>
|
|
140
|
+
<ComfortableRow
|
|
141
|
+
thread={starred}
|
|
142
|
+
selection={{
|
|
143
|
+
checked: false,
|
|
144
|
+
alwaysVisible: true,
|
|
145
|
+
onToggle: () => undefined,
|
|
146
|
+
}}
|
|
147
|
+
/>
|
|
148
|
+
<ComfortableRow thread={withCategory} />
|
|
149
|
+
</List>
|
|
150
|
+
),
|
|
151
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Paperclip, ShieldAlert, Star } from "lucide-react";
|
|
2
|
-
import type { ReactNode } from "react";
|
|
1
|
+
import { Check, Paperclip, ShieldAlert, Star } from "lucide-react";
|
|
2
|
+
import type { ComponentType, ReactNode } from "react";
|
|
3
3
|
import { cn } from "../lib/cn.js";
|
|
4
4
|
import { LIST_ROW_ATTRIBUTE } from "../lib/roving-focus.js";
|
|
5
5
|
import { categoryTone, type ThreadRowData } from "./app-shell-types.js";
|
|
@@ -173,18 +173,89 @@ export function ComfortableRowTextContent({
|
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
176
|
+
* Checkbox state for a selectable row. Absent on a row that cannot be
|
|
177
|
+
* multi-selected (the brief and Flagged before they gained selection), which
|
|
178
|
+
* renders the avatar alone.
|
|
178
179
|
*/
|
|
179
|
-
export
|
|
180
|
+
export interface RowSelection {
|
|
181
|
+
checked: boolean;
|
|
182
|
+
/** Keep the checkbox visible instead of revealing it on hover (mobile). */
|
|
183
|
+
alwaysVisible?: boolean;
|
|
184
|
+
onToggle: (event: React.MouseEvent) => void;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Leading slot of a comfortable row: the avatar, with a checkbox layered over
|
|
189
|
+
* it when the row is selectable. Fixed 28px so the row never reflows as the
|
|
190
|
+
* checkbox appears.
|
|
191
|
+
*/
|
|
192
|
+
export function ComfortableRowLeading({
|
|
193
|
+
thread,
|
|
194
|
+
selection,
|
|
195
|
+
}: {
|
|
196
|
+
thread: ThreadRowData;
|
|
197
|
+
selection?: RowSelection;
|
|
198
|
+
}) {
|
|
199
|
+
if (!selection) {
|
|
200
|
+
return <Avatar name={thread.fromName} email={thread.fromEmail} size="sm" />;
|
|
201
|
+
}
|
|
202
|
+
const { checked, alwaysVisible } = selection;
|
|
203
|
+
return (
|
|
204
|
+
<span className="relative size-7 shrink-0">
|
|
205
|
+
<Avatar
|
|
206
|
+
name={thread.fromName}
|
|
207
|
+
email={thread.fromEmail}
|
|
208
|
+
size="sm"
|
|
209
|
+
className={cn(
|
|
210
|
+
"absolute inset-0 transition-opacity sm:group-hover:opacity-0",
|
|
211
|
+
(checked || alwaysVisible) && "opacity-0",
|
|
212
|
+
)}
|
|
213
|
+
/>
|
|
214
|
+
<button
|
|
215
|
+
type="button"
|
|
216
|
+
// Out of the tab order: the row is the list's single tab stop, and this
|
|
217
|
+
// control is `opacity-0` until hover, so a tabbable one would put focus
|
|
218
|
+
// on something invisible on every row.
|
|
219
|
+
tabIndex={-1}
|
|
220
|
+
onClick={selection.onToggle}
|
|
221
|
+
className={cn(
|
|
222
|
+
"absolute inset-0 size-7 items-center justify-center rounded-full border transition-opacity",
|
|
223
|
+
alwaysVisible ? "flex" : "hidden sm:flex",
|
|
224
|
+
checked
|
|
225
|
+
? "bg-accent border-accent text-accent-fg opacity-100"
|
|
226
|
+
: alwaysVisible
|
|
227
|
+
? "border-fg-subtle/40 bg-canvas opacity-100"
|
|
228
|
+
: "border-fg-subtle/40 bg-canvas opacity-0 group-hover:opacity-100",
|
|
229
|
+
)}
|
|
230
|
+
aria-label={checked ? "Deselect message" : "Select message"}
|
|
231
|
+
>
|
|
232
|
+
{checked && <Check className="size-3" />}
|
|
233
|
+
</button>
|
|
234
|
+
</span>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Full inner body of a comfortable row (unread dot + leading slot + text
|
|
240
|
+
* content). Place inside any wrapper element that uses `comfortableRowClass()`.
|
|
241
|
+
*/
|
|
242
|
+
export function ComfortableRowBody({
|
|
243
|
+
thread,
|
|
244
|
+
selection,
|
|
245
|
+
badge,
|
|
246
|
+
}: {
|
|
247
|
+
thread: ThreadRowData;
|
|
248
|
+
selection?: RowSelection;
|
|
249
|
+
badge?: ReactNode;
|
|
250
|
+
}) {
|
|
180
251
|
const unread = !thread.isRead;
|
|
181
252
|
return (
|
|
182
253
|
<>
|
|
183
254
|
{unread && (
|
|
184
255
|
<span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
|
|
185
256
|
)}
|
|
186
|
-
<
|
|
187
|
-
<ComfortableRowTextContent thread={thread} />
|
|
257
|
+
<ComfortableRowLeading thread={thread} selection={selection} />
|
|
258
|
+
<ComfortableRowTextContent thread={thread} badge={badge} />
|
|
188
259
|
</>
|
|
189
260
|
);
|
|
190
261
|
}
|
|
@@ -192,10 +263,12 @@ export function ComfortableRowBody({ thread }: { thread: ThreadRowData }) {
|
|
|
192
263
|
export function CompactRow({
|
|
193
264
|
thread,
|
|
194
265
|
active,
|
|
266
|
+
focused,
|
|
195
267
|
onClick,
|
|
196
268
|
}: {
|
|
197
269
|
thread: ThreadRowData;
|
|
198
270
|
active?: boolean;
|
|
271
|
+
focused?: boolean;
|
|
199
272
|
onClick?: () => void;
|
|
200
273
|
}) {
|
|
201
274
|
return (
|
|
@@ -203,7 +276,7 @@ export function CompactRow({
|
|
|
203
276
|
type="button"
|
|
204
277
|
{...LIST_ROW_ATTRIBUTE}
|
|
205
278
|
onClick={onClick}
|
|
206
|
-
className={compactRowClass({ active })}
|
|
279
|
+
className={compactRowClass({ active, focused })}
|
|
207
280
|
>
|
|
208
281
|
<CompactRowBody thread={thread} />
|
|
209
282
|
</button>
|
|
@@ -213,10 +286,14 @@ export function CompactRow({
|
|
|
213
286
|
export function ComfortableRow({
|
|
214
287
|
thread,
|
|
215
288
|
active,
|
|
289
|
+
focused,
|
|
290
|
+
selection,
|
|
216
291
|
onClick,
|
|
217
292
|
}: {
|
|
218
293
|
thread: ThreadRowData;
|
|
219
294
|
active?: boolean;
|
|
295
|
+
focused?: boolean;
|
|
296
|
+
selection?: RowSelection;
|
|
220
297
|
onClick?: () => void;
|
|
221
298
|
}) {
|
|
222
299
|
return (
|
|
@@ -224,17 +301,17 @@ export function ComfortableRow({
|
|
|
224
301
|
type="button"
|
|
225
302
|
{...LIST_ROW_ATTRIBUTE}
|
|
226
303
|
onClick={onClick}
|
|
227
|
-
className={comfortableRowClass({ active })}
|
|
304
|
+
className={cn("group", comfortableRowClass({ active, focused }))}
|
|
228
305
|
>
|
|
229
|
-
<ComfortableRowBody thread={thread} />
|
|
306
|
+
<ComfortableRowBody thread={thread} selection={selection} />
|
|
230
307
|
</button>
|
|
231
308
|
);
|
|
232
309
|
}
|
|
233
310
|
|
|
234
311
|
/** A row renderer the brief drives — Comfortable/Compact rows or a consumer's
|
|
235
312
|
* own (e.g. the web client's navigation-aware row) all satisfy this shape. */
|
|
236
|
-
export type BriefRowComponent =
|
|
313
|
+
export type BriefRowComponent = ComponentType<{
|
|
237
314
|
thread: ThreadRowData;
|
|
238
315
|
active?: boolean;
|
|
239
316
|
onClick?: () => void;
|
|
240
|
-
}
|
|
317
|
+
}>;
|
|
@@ -53,9 +53,10 @@ describe("ReadingPane", () => {
|
|
|
53
53
|
assert.match(html, /Select a thread to read/);
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
it("renders the
|
|
56
|
+
it("renders the message verbs and no search field — search is the app top bar's", () => {
|
|
57
57
|
const html = renderToString(createElement(ReadingPane, { thread }));
|
|
58
|
-
assert.match(html, /
|
|
58
|
+
assert.match(html, /title="Reply \(r\)"/);
|
|
59
|
+
assert.doesNotMatch(html, /Search mail/);
|
|
59
60
|
});
|
|
60
61
|
});
|
|
61
62
|
|
|
@@ -1,17 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ChevronDown,
|
|
3
|
-
ChevronRight,
|
|
4
|
-
Search,
|
|
5
|
-
ShieldAlert,
|
|
6
|
-
SquarePen,
|
|
7
|
-
} from "lucide-react";
|
|
1
|
+
import { ChevronDown, ChevronRight, ShieldAlert } from "lucide-react";
|
|
8
2
|
import { type ReactNode, useState } from "react";
|
|
9
3
|
import { cn } from "../lib/cn.js";
|
|
10
4
|
import { isSelfRowActivation } from "../lib/row-keyboard.js";
|
|
11
5
|
import type { ThreadData, ThreadMessageData } from "./app-shell-types.js";
|
|
12
6
|
import { Avatar } from "./avatar.js";
|
|
13
|
-
import { Button } from "./button.js";
|
|
14
|
-
import { Input } from "./input.js";
|
|
15
7
|
import { IntelligenceToggle } from "./intelligence-toggle.js";
|
|
16
8
|
import { MailActionToolbar } from "./mail-action-toolbar.js";
|
|
17
9
|
import { MessageBodyView } from "./message-body-view.js";
|
|
@@ -252,13 +244,14 @@ export function ExpandedMessage({
|
|
|
252
244
|
}
|
|
253
245
|
|
|
254
246
|
/**
|
|
255
|
-
* Message action toolbar on the pane-header datum: the reading pane's
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
247
|
+
* Message action toolbar on the pane-header datum: the reading pane's verbs
|
|
248
|
+
* (reply/reply-all/forward, delete/move/flag) and the intelligence toggle.
|
|
249
|
+
* Everything here acts on the open message; search, compose and the account
|
|
250
|
+
* menu belong to the app and live in the `AppTopBar` above every pane. Built on
|
|
251
|
+
* the shared `MailActionToolbar` so the mail verbs stay pressable with no thread
|
|
252
|
+
* open and explain inline rather than greying out (the never-disable tenet). The
|
|
253
|
+
* intelligence toggle is the exception: it holds its slot and greys out when
|
|
254
|
+
* there is no rail to open (#52).
|
|
262
255
|
*/
|
|
263
256
|
function MessageToolbar({
|
|
264
257
|
hasThread,
|
|
@@ -278,21 +271,6 @@ function MessageToolbar({
|
|
|
278
271
|
onUnavailable={() => setHint("Open a message first")}
|
|
279
272
|
unavailableHint={hint}
|
|
280
273
|
>
|
|
281
|
-
{/* Apple Mail geometry: search sits top-right over the message
|
|
282
|
-
area but still filters the current list / brief */}
|
|
283
|
-
<Input
|
|
284
|
-
icon={<Search className="size-4" />}
|
|
285
|
-
placeholder="Search mail"
|
|
286
|
-
className="h-8 w-64 min-w-40 shrink"
|
|
287
|
-
/>
|
|
288
|
-
<span className="mx-1 h-4 w-px bg-line" aria-hidden />
|
|
289
|
-
<Button
|
|
290
|
-
variant="ghost"
|
|
291
|
-
size="sm"
|
|
292
|
-
icon={<SquarePen className="size-4" />}
|
|
293
|
-
title="Compose (⌘N)"
|
|
294
|
-
aria-label="Compose"
|
|
295
|
-
/>
|
|
296
274
|
<IntelligenceToggle
|
|
297
275
|
open={intelligenceOpen}
|
|
298
276
|
enabled={canToggleIntelligence}
|
package/src/index.ts
CHANGED
|
@@ -203,11 +203,13 @@ export {
|
|
|
203
203
|
type BriefRowComponent,
|
|
204
204
|
ComfortableRow,
|
|
205
205
|
ComfortableRowBody,
|
|
206
|
+
ComfortableRowLeading,
|
|
206
207
|
ComfortableRowTextContent,
|
|
207
208
|
CompactRow,
|
|
208
209
|
CompactRowBody,
|
|
209
210
|
comfortableRowClass,
|
|
210
211
|
compactRowClass,
|
|
212
|
+
type RowSelection,
|
|
211
213
|
} from "./components/message-row.js";
|
|
212
214
|
export {
|
|
213
215
|
type MobileMessageAction,
|