@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.31",
3
+ "version": "0.0.33",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -109,8 +109,9 @@ describe("AppShell render honors the pane layout (#784)", () => {
109
109
  thread,
110
110
  intelligence,
111
111
  };
112
- // The reading pane's toolbar is the only place "Search mail" renders.
113
- const readingPaneMarker = /Search mail/;
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 = /Search mail/;
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
- * Full inner body of a comfortable row (unread dot + avatar + text content).
177
- * Place inside any wrapper element that uses `comfortableRowClass()`.
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 function ComfortableRowBody({ thread }: { thread: ThreadRowData }) {
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
- <Avatar name={thread.fromName} email={thread.fromEmail} size="sm" />
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 = (props: {
313
+ export type BriefRowComponent = ComponentType<{
237
314
  thread: ThreadRowData;
238
315
  active?: boolean;
239
316
  onClick?: () => void;
240
- }) => React.ReactNode;
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 toolbar with the search input", () => {
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, /Search mail/);
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
- * verbs (reply/reply-all/forward, delete/move/flag) plus search and
257
- * compose, Apple Mail-style above the message area. Built on the shared
258
- * `MailActionToolbar` so the mail verbs stay pressable with no
259
- * thread open and explain inline rather than greying out (the never-disable
260
- * tenet). The intelligence toggle is the exception: it holds its slot and greys
261
- * out when there is no rail to open (#52).
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,