@remit/ui 0.0.32 → 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.32",
3
+ "version": "0.0.33",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -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
+ }>;
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,