@remit/ui 0.0.32 → 0.0.34

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.34",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -176,3 +176,43 @@ export const AccountSources: Story = {
176
176
  );
177
177
  },
178
178
  };
179
+
180
+ /**
181
+ * (d) Multi-select and the keyboard cursor in the brief. The rows are the same
182
+ * `Row` the mailbox list renders, so a checked row carries the checkbox and the
183
+ * selected tint, and the keyboard cursor shows its left accent rail on the row
184
+ * it sits on — one row implementation across the brief, Flagged and the inbox.
185
+ */
186
+ export const Selection: Story = {
187
+ render: (args) => {
188
+ const [checked, setChecked] = useState<ReadonlySet<string>>(
189
+ new Set(["p1", "f1"]),
190
+ );
191
+ const toggle = (id: string) =>
192
+ setChecked((prev) => {
193
+ const next = new Set(prev);
194
+ if (next.has(id)) next.delete(id);
195
+ else next.add(id);
196
+ return next;
197
+ });
198
+ return (
199
+ <div className="flex h-screen w-96 flex-col border-r border-line">
200
+ <BriefSections
201
+ {...args}
202
+ Row={({ thread, active, onClick }) => (
203
+ <ComfortableRow
204
+ thread={thread}
205
+ active={active}
206
+ focused={thread.id === "p1"}
207
+ selection={{
208
+ checked: checked.has(thread.id),
209
+ onToggle: () => toggle(thread.id),
210
+ }}
211
+ onClick={onClick}
212
+ />
213
+ )}
214
+ />
215
+ </div>
216
+ );
217
+ },
218
+ };
@@ -75,6 +75,17 @@ export interface BriefSectionsProps {
75
75
  onSelectSource?: (id: string) => void;
76
76
  /** Seeds the filter panel open on first render (stories / deep links). */
77
77
  defaultExpanded?: boolean;
78
+ /**
79
+ * Whether this component owns arrow-key traversal and the roving tabindex
80
+ * over its rows. On by default so a consumer that only passes rows (the
81
+ * Storybook prototype) still has a keyboard.
82
+ *
83
+ * The web client turns it off: its triage layer routes ↑/↓/Home/End through
84
+ * one window-level dispatcher, and a container handler here would swallow
85
+ * them first — taking Shift+↑/↓ with them, which the dispatcher needs for
86
+ * range selection and `rovingNextIndex` ignores.
87
+ */
88
+ manageFocus?: boolean;
78
89
  }
79
90
 
80
91
  /**
@@ -96,11 +107,16 @@ export function BriefSections({
96
107
  sourcesNote,
97
108
  onSelectSource,
98
109
  defaultExpanded = false,
110
+ manageFocus = true,
99
111
  }: BriefSectionsProps) {
100
112
  const [active, setActive] = useState<ReadonlySet<BriefFilterId>>(new Set());
101
113
  const [sheetExpanded, setSheetExpanded] = useState(defaultExpanded);
102
114
  const listRef = useRef<HTMLDivElement>(null);
103
- useRovingFocus({ containerRef: listRef, itemSelector: LIST_ROW_SELECTOR });
115
+ useRovingFocus({
116
+ containerRef: listRef,
117
+ itemSelector: LIST_ROW_SELECTOR,
118
+ enabled: manageFocus,
119
+ });
104
120
 
105
121
  const toggleFilter = (id: BriefFilterId) => {
106
122
  setActive((prev) => {
@@ -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, SyntheticEvent } 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,106 @@ 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
+ /** What the toggle handler needs off the click or keypress that triggered it. */
181
+ export type RowToggleEvent = Pick<
182
+ SyntheticEvent,
183
+ "preventDefault" | "stopPropagation"
184
+ >;
185
+
186
+ export interface RowSelection {
187
+ checked: boolean;
188
+ /** Keep the checkbox visible instead of revealing it on hover (mobile). */
189
+ alwaysVisible?: boolean;
190
+ onToggle: (event: RowToggleEvent) => void;
191
+ }
192
+
193
+ /**
194
+ * Leading slot of a comfortable row: the avatar, with a checkbox layered over
195
+ * it when the row is selectable. Fixed 28px so the row never reflows as the
196
+ * checkbox appears.
197
+ */
198
+ export function ComfortableRowLeading({
199
+ thread,
200
+ selection,
201
+ }: {
202
+ thread: ThreadRowData;
203
+ selection?: RowSelection;
204
+ }) {
205
+ if (!selection) {
206
+ return <Avatar name={thread.fromName} email={thread.fromEmail} size="sm" />;
207
+ }
208
+ const { checked, alwaysVisible } = selection;
209
+ return (
210
+ <span className="relative size-7 shrink-0">
211
+ <Avatar
212
+ name={thread.fromName}
213
+ email={thread.fromEmail}
214
+ size="sm"
215
+ className={cn(
216
+ "absolute inset-0 transition-opacity sm:group-hover:opacity-0",
217
+ (checked || alwaysVisible) && "opacity-0",
218
+ )}
219
+ />
220
+ {/* A span, not a <button>: the row itself is a button (the brief,
221
+ Flagged) or a link (the mailbox list), and neither may nest an
222
+ interactive element. The button role and label are what the user and
223
+ the test suite address, so both stay. */}
224
+ {/* biome-ignore lint/a11y/useSemanticElements: a nested <button> inside the row's own button/link is invalid HTML */}
225
+ <span
226
+ role="button"
227
+ // Out of the tab order: the row is the list's single tab stop, and this
228
+ // control is `opacity-0` until hover, so a tabbable one would put focus
229
+ // on something invisible on every row.
230
+ tabIndex={-1}
231
+ onClick={selection.onToggle}
232
+ onKeyDown={(event) => {
233
+ if (event.key !== "Enter" && event.key !== " ") return;
234
+ event.preventDefault();
235
+ event.stopPropagation();
236
+ selection.onToggle(event);
237
+ }}
238
+ className={cn(
239
+ "absolute inset-0 size-7 items-center justify-center rounded-full border transition-opacity",
240
+ alwaysVisible ? "flex" : "hidden sm:flex",
241
+ checked
242
+ ? "bg-accent border-accent text-accent-fg opacity-100"
243
+ : alwaysVisible
244
+ ? "border-fg-subtle/40 bg-canvas opacity-100"
245
+ : "border-fg-subtle/40 bg-canvas opacity-0 group-hover:opacity-100",
246
+ )}
247
+ aria-label={checked ? "Deselect message" : "Select message"}
248
+ >
249
+ {checked && <Check className="size-3" />}
250
+ </span>
251
+ </span>
252
+ );
253
+ }
254
+
255
+ /**
256
+ * Full inner body of a comfortable row (unread dot + leading slot + text
257
+ * content). Place inside any wrapper element that uses `comfortableRowClass()`.
258
+ */
259
+ export function ComfortableRowBody({
260
+ thread,
261
+ selection,
262
+ badge,
263
+ }: {
264
+ thread: ThreadRowData;
265
+ selection?: RowSelection;
266
+ badge?: ReactNode;
267
+ }) {
180
268
  const unread = !thread.isRead;
181
269
  return (
182
270
  <>
183
271
  {unread && (
184
272
  <span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
185
273
  )}
186
- <Avatar name={thread.fromName} email={thread.fromEmail} size="sm" />
187
- <ComfortableRowTextContent thread={thread} />
274
+ <ComfortableRowLeading thread={thread} selection={selection} />
275
+ <ComfortableRowTextContent thread={thread} badge={badge} />
188
276
  </>
189
277
  );
190
278
  }
@@ -192,10 +280,12 @@ export function ComfortableRowBody({ thread }: { thread: ThreadRowData }) {
192
280
  export function CompactRow({
193
281
  thread,
194
282
  active,
283
+ focused,
195
284
  onClick,
196
285
  }: {
197
286
  thread: ThreadRowData;
198
287
  active?: boolean;
288
+ focused?: boolean;
199
289
  onClick?: () => void;
200
290
  }) {
201
291
  return (
@@ -203,7 +293,7 @@ export function CompactRow({
203
293
  type="button"
204
294
  {...LIST_ROW_ATTRIBUTE}
205
295
  onClick={onClick}
206
- className={compactRowClass({ active })}
296
+ className={compactRowClass({ active, focused })}
207
297
  >
208
298
  <CompactRowBody thread={thread} />
209
299
  </button>
@@ -213,10 +303,14 @@ export function CompactRow({
213
303
  export function ComfortableRow({
214
304
  thread,
215
305
  active,
306
+ focused,
307
+ selection,
216
308
  onClick,
217
309
  }: {
218
310
  thread: ThreadRowData;
219
311
  active?: boolean;
312
+ focused?: boolean;
313
+ selection?: RowSelection;
220
314
  onClick?: () => void;
221
315
  }) {
222
316
  return (
@@ -224,17 +318,17 @@ export function ComfortableRow({
224
318
  type="button"
225
319
  {...LIST_ROW_ATTRIBUTE}
226
320
  onClick={onClick}
227
- className={comfortableRowClass({ active })}
321
+ className={cn("group", comfortableRowClass({ active, focused }))}
228
322
  >
229
- <ComfortableRowBody thread={thread} />
323
+ <ComfortableRowBody thread={thread} selection={selection} />
230
324
  </button>
231
325
  );
232
326
  }
233
327
 
234
328
  /** A row renderer the brief drives — Comfortable/Compact rows or a consumer's
235
329
  * own (e.g. the web client's navigation-aware row) all satisfy this shape. */
236
- export type BriefRowComponent = (props: {
330
+ export type BriefRowComponent = ComponentType<{
237
331
  thread: ThreadRowData;
238
332
  active?: boolean;
239
333
  onClick?: () => void;
240
- }) => React.ReactNode;
334
+ }>;
package/src/index.ts CHANGED
@@ -203,11 +203,14 @@ 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,
213
+ type RowToggleEvent,
211
214
  } from "./components/message-row.js";
212
215
  export {
213
216
  type MobileMessageAction,
@@ -43,6 +43,11 @@ export interface UseRovingFocusOptions {
43
43
  /** CSS selector, scoped to the container, matching every roving item. */
44
44
  itemSelector: string;
45
45
  orientation?: RovingOrientation;
46
+ /**
47
+ * Off when a keyboard layer above the group owns the same keys — the hook
48
+ * binds nothing and leaves the tabindex to whoever does. Defaults on.
49
+ */
50
+ enabled?: boolean;
46
51
  }
47
52
 
48
53
  function rovingItems(
@@ -70,10 +75,12 @@ export function useRovingFocus({
70
75
  containerRef,
71
76
  itemSelector,
72
77
  orientation = "vertical",
78
+ enabled = true,
73
79
  }: UseRovingFocusOptions): void {
74
80
  // No dependency array: items appear and disappear as sections expand, rows
75
81
  // load, or filters change, none of which this hook's own inputs describe.
76
82
  useEffect(() => {
83
+ if (!enabled) return;
77
84
  const container = containerRef.current;
78
85
  if (!container) return;
79
86