@remit/ui 0.0.75 → 0.0.76

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.75",
3
+ "version": "0.0.76",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -6,6 +6,7 @@ import {
6
6
  useRef,
7
7
  useState,
8
8
  } from "react";
9
+ import type { SelectionModifiers } from "../lib/use-selection.js";
9
10
  import type {
10
11
  IntelligenceData,
11
12
  SenderTrustLevel,
@@ -58,9 +59,30 @@ export function resolvePaneLayout(
58
59
 
59
60
  export type NarrowView = "list" | "message";
60
61
 
61
- /** Seeds the narrow touch list's interaction state for stories / SSR, so it can
62
- * render selection mode or a swipe-peeked row without a live gesture. */
63
- export type TouchSeed = "selection" | "peek-trailing" | "peek-leading";
62
+ /** Seeds the narrow touch list with a swipe-peeked row for stories / SSR, so it
63
+ * can render that state without a live gesture. */
64
+ export type TouchSeed = "peek-trailing" | "peek-leading";
65
+
66
+ /**
67
+ * The list's selection, owned by whoever renders the list. The kit holds none
68
+ * of it: a surface that offers multi-select already has the selected set, the
69
+ * range anchor and the verbs the bar runs, and one set of rows can only ever
70
+ * answer to one of them.
71
+ *
72
+ * `useSelection` is the model both the app and the kit's own stories drive this
73
+ * from.
74
+ */
75
+ export interface MessageListSelection {
76
+ /** The ticked rows. */
77
+ selectedIds: ReadonlySet<string>;
78
+ /** Ticks or unticks one row — its checkbox, and a long press on touch. */
79
+ onToggle: (id: string) => void;
80
+ /**
81
+ * A row click and the modifiers it carried. True when selection took the
82
+ * click, in which case the row does not open.
83
+ */
84
+ onRowSelect?: (id: string, modifiers: SelectionModifiers) => boolean;
85
+ }
64
86
 
65
87
  /**
66
88
  * Measures an element's OWN width via ResizeObserver — a container query, not a
@@ -317,19 +339,21 @@ export interface AppShellProps {
317
339
  */
318
340
  initialNarrowView?: NarrowView;
319
341
  /**
320
- * Seed the narrow touch list directly in selection mode or with a
321
- * swipe-peeked row, so a story / SSR can show those triage states statically.
322
- * Only meaningful below the reading boundary; ignored once the reading pane
323
- * fits or when the message view is showing.
342
+ * Seed the narrow touch list with a swipe-peeked row, so a story / SSR can
343
+ * show that triage state statically. Only meaningful below the reading
344
+ * boundary; ignored once the reading pane fits or when the message view is
345
+ * showing.
324
346
  */
325
347
  initialTouchState?: TouchSeed;
326
348
  /**
327
- * Replaces the narrow-width list pane header when a selection is active
328
- * forwarded to `MessageListPane`'s `selectionBar` slot. The caller owns
329
- * selection state and toolbar actions; when omitted, `initialTouchState`
330
- * still drives the pane's own built-in touch-triage bar.
349
+ * The list header, forwarded to `MessageListPane`'s `selectionBar` slot. The
350
+ * caller mounts it for every state of the list: it names the view while
351
+ * nothing is ticked and carries the count and the verbs from the first
352
+ * ticked row.
331
353
  */
332
354
  selectionBar?: ReactNode;
355
+ /** The list's selection, forwarded to `MessageListPane`. */
356
+ selection?: MessageListSelection;
333
357
  intelligence?: IntelligenceData;
334
358
  /** Pane 4 visible. Defaults to true when intelligence is present. */
335
359
  intelligenceOpen?: boolean;
@@ -294,14 +294,19 @@ describe("AppShell touch-state seeds (story/SSR affordance)", () => {
294
294
  );
295
295
  });
296
296
 
297
- it("selection seed shows the selection bar replacing the header", () => {
298
- const html = render({ ...touchBase, initialTouchState: "selection" });
299
- assert.match(
300
- html,
301
- /aria-label="Cancel selection"/,
302
- "the selection bar is shown",
297
+ it("draws the consumer's selection on the touch rows", () => {
298
+ const html = render({
299
+ ...touchBase,
300
+ selection: {
301
+ selectedIds: new Set(["r1", "r2"]),
302
+ onToggle: () => undefined,
303
+ },
304
+ });
305
+ assert.equal(
306
+ count(html, /aria-checked="true"/g),
307
+ 2,
308
+ "both ticked rows show a checked box",
303
309
  );
304
- assert.match(html, /2 messages selected/, "selection wording shown");
305
310
  // Selection mode is not a swipe — no action zones revealed.
306
311
  assert.equal(
307
312
  count(html, trailingAction),
@@ -344,33 +349,38 @@ describe("AppShell touch-state seeds (story/SSR affordance)", () => {
344
349
  );
345
350
  });
346
351
 
347
- it("selectionBar overrides the built-in bar and forwards to the list pane", () => {
352
+ it("the selection bar is the list pane's header", () => {
348
353
  const html = render({
349
354
  ...touchBase,
355
+ listTitle: "Inbox",
350
356
  selectionBar: createElement(
351
357
  "div",
352
358
  { "data-testid": "custom-bar" },
353
359
  "Deleting 1,200 of 3,412…",
354
360
  ),
355
361
  });
356
- assert.match(html, /Deleting 1,200 of 3,412…/, "the override renders");
362
+ assert.match(html, /Deleting 1,200 of 3,412…/, "the bar renders");
357
363
  assert.doesNotMatch(
358
364
  html,
359
- /aria-label="Cancel selection"/,
360
- "the built-in bar is not also rendered",
365
+ /<h1[^>]*>Inbox<\/h1>/,
366
+ "the pane's own title header stands down",
361
367
  );
362
368
  });
363
369
 
364
- it("ignores the touch seed at/above 1024 (desktop list)", () => {
370
+ it("leaves the always-visible checkbox to the touch tier", () => {
365
371
  const html = render({
366
372
  ...touchBase,
367
373
  initialWidth: 1100,
368
- initialTouchState: "selection",
374
+ selection: {
375
+ selectedIds: new Set(["r1"]),
376
+ onToggle: () => undefined,
377
+ },
369
378
  });
379
+ assert.match(html, /aria-label="Deselect message"/, "the row is ticked");
370
380
  assert.doesNotMatch(
371
381
  html,
372
- /aria-label="Cancel selection"/,
373
- "no selection bar on the desktop list",
382
+ /translateX/,
383
+ "desktop rows carry no swipe-triage layer",
374
384
  );
375
385
  });
376
386
  });
@@ -70,6 +70,7 @@ export function AppShell({
70
70
  thread,
71
71
  initialNarrowView = "list",
72
72
  initialTouchState,
73
+ selection,
73
74
  selectionBar,
74
75
  intelligence,
75
76
  intelligenceOpen = true,
@@ -127,6 +128,7 @@ export function AppShell({
127
128
  }}
128
129
  onSelectBriefCategory={selectCategory}
129
130
  initialTouchState={initialTouchState}
131
+ selection={selection}
130
132
  selectionBar={selectionBar}
131
133
  />
132
134
  );
@@ -187,6 +189,7 @@ function AppShellList({
187
189
  onSelectThread,
188
190
  onSelectBriefCategory,
189
191
  initialTouchState,
192
+ selection,
190
193
  selectionBar,
191
194
  }: Pick<
192
195
  AppShellProps,
@@ -205,6 +208,7 @@ function AppShellList({
205
208
  | "density"
206
209
  | "onSelectThread"
207
210
  | "initialTouchState"
211
+ | "selection"
208
212
  | "selectionBar"
209
213
  > & {
210
214
  narrowView: NarrowView;
@@ -248,6 +252,7 @@ function AppShellList({
248
252
  onOpenNav={showNavPane ? undefined : layout?.openNav}
249
253
  isDesktop={showReadingPane}
250
254
  initialTouchState={initialTouchState}
255
+ selection={selection}
251
256
  selectionBar={selectionBar}
252
257
  />
253
258
  );
@@ -85,7 +85,7 @@ describe("FilterSheet under a FilterPanelProvider", () => {
85
85
  renderToString(
86
86
  createElement(
87
87
  FilterPanelProvider,
88
- null,
88
+ { hasSheet: true },
89
89
  createElement(FilterToggle),
90
90
  createElement(FilterSheet, {
91
91
  categories,
@@ -120,4 +120,15 @@ describe("FilterSheet under a FilterPanelProvider", () => {
120
120
  it("renders nothing for a toggle with no panel above it", () => {
121
121
  assert.equal(renderToString(createElement(FilterToggle)), "");
122
122
  });
123
+
124
+ it("renders nothing while the provider reports no sheet", () => {
125
+ const html = renderToString(
126
+ createElement(
127
+ FilterPanelProvider,
128
+ { hasSheet: false },
129
+ createElement(FilterToggle),
130
+ ),
131
+ );
132
+ assert.equal(html, "");
133
+ });
123
134
  });
@@ -133,16 +133,18 @@ const FilterPanelCtx = createContext<FilterPanelState | null>(null);
133
133
  * `FilterToggle`, the sheet drops its own trigger row, and the sheet keeps its
134
134
  * filter state exactly where it already lives.
135
135
  *
136
- * A view whose body is sometimes something other than the filtered list — a
137
- * skeleton, an empty state, an error says so with `hasSheet`, and the caret
138
- * stands down for as long as there is no panel behind it.
136
+ * `hasSheet` states whether a sheet is mounted right now, and the caret stands
137
+ * down for as long as there is no panel behind it. It is required: a view whose
138
+ * body is sometimes something else a skeleton, an empty state, an error —
139
+ * would otherwise inherit a caret over nothing by saying nothing. Compute it
140
+ * from what the body renders, in the same pass that renders it.
139
141
  */
140
142
  export function FilterPanelProvider({
141
143
  children,
142
- hasSheet = true,
144
+ hasSheet,
143
145
  }: {
144
- children: ReactNode;
145
- hasSheet?: boolean;
146
+ children?: ReactNode;
147
+ hasSheet: boolean;
146
148
  }) {
147
149
  const [open, setOpen] = useState(false);
148
150
  const [active, setActive] = useState(false);
@@ -152,6 +152,28 @@ describe("MessageListPane", () => {
152
152
  assert.doesNotMatch(html, /Every message in this folder was checked\./);
153
153
  });
154
154
 
155
+ it("ticks the rows the consumer says are selected, and nothing else", () => {
156
+ const html = renderToString(
157
+ createElement(MessageListPane, {
158
+ ...baseProps,
159
+ isDesktop: true,
160
+ selection: {
161
+ selectedIds: new Set(["t1"]),
162
+ onToggle: () => undefined,
163
+ },
164
+ }),
165
+ );
166
+ assert.match(html, /aria-label="Deselect message"/);
167
+ assert.match(html, /aria-label="Select message"/);
168
+ });
169
+
170
+ it("offers no checkbox at all when the consumer holds no selection", () => {
171
+ const html = renderToString(
172
+ createElement(MessageListPane, { ...baseProps, isDesktop: true }),
173
+ );
174
+ assert.doesNotMatch(html, /aria-label="Select message"/);
175
+ });
176
+
155
177
  it("renders the selectionBar slot instead of the pane header when provided", () => {
156
178
  const html = renderToString(
157
179
  createElement(MessageListPane, {
@@ -1,6 +1,11 @@
1
1
  import type { Decorator, Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
3
  import { inboxFilterConfig } from "../filter-presets.js";
4
+ import {
5
+ rowSelectIntent,
6
+ type SelectionModifiers,
7
+ useSelection,
8
+ } from "../lib/use-selection.js";
4
9
  import type { ThreadSection } from "./app-shell-types.js";
5
10
  import { FilterSheet } from "./filter-sheet.js";
6
11
  import { MailHeader } from "./mail-header.js";
@@ -212,60 +217,109 @@ export const CustomListBody: Story = {
212
217
  decorators: [desktopFrame],
213
218
  };
214
219
 
220
+ function SelectableList({ isDesktop }: { isDesktop: boolean }) {
221
+ const selection = useSelection();
222
+ const [trashedIds, setTrashedIds] = useState<ReadonlySet<string>>(new Set());
223
+ const [readIds, setReadIds] = useState<ReadonlySet<string>>(new Set());
224
+
225
+ const visible = sections.map((section) => ({
226
+ ...section,
227
+ threads: section.threads
228
+ .filter((thread) => !trashedIds.has(thread.id))
229
+ .map((thread) =>
230
+ readIds.has(thread.id) ? { ...thread, isRead: true } : thread,
231
+ ),
232
+ }));
233
+ const orderedIds = visible.flatMap((s) => s.threads).map((t) => t.id);
234
+ const allSelected =
235
+ orderedIds.length > 0 &&
236
+ orderedIds.every((id) => selection.selectedIds.has(id));
237
+
238
+ const runVerb = (record: (ids: ReadonlySet<string>) => void) => {
239
+ record(selection.selectedIds);
240
+ selection.clearSelection();
241
+ };
242
+
243
+ // Shift and cmd/ctrl come off a mouse, which the touch list has no path for.
244
+ const onRowSelect = isDesktop
245
+ ? (id: string, modifiers: SelectionModifiers) => {
246
+ const intent = rowSelectIntent(modifiers);
247
+ if (intent === "range") {
248
+ selection.selectRange(orderedIds, id);
249
+ return true;
250
+ }
251
+ if (intent === "toggle") {
252
+ selection.toggle(id);
253
+ return true;
254
+ }
255
+ selection.clearSelection();
256
+ selection.setAnchor(id);
257
+ return false;
258
+ }
259
+ : undefined;
260
+
261
+ return (
262
+ <MessageListPane
263
+ listTitle="Inbox"
264
+ listMeta="3 conversations"
265
+ sections={visible}
266
+ flatList
267
+ isDesktop={isDesktop}
268
+ onSelectThread={() => undefined}
269
+ selection={{
270
+ selectedIds: selection.selectedIds,
271
+ onToggle: selection.toggle,
272
+ onRowSelect,
273
+ }}
274
+ selectionBar={
275
+ <SelectionTopBar
276
+ title="Inbox"
277
+ count={selection.selectedCount}
278
+ onCancel={selection.clearSelection}
279
+ onDelete={() =>
280
+ runVerb((ids) =>
281
+ setTrashedIds((prev) => new Set([...prev, ...ids])),
282
+ )
283
+ }
284
+ onMarkRead={() =>
285
+ runVerb((ids) => setReadIds((prev) => new Set([...prev, ...ids])))
286
+ }
287
+ selectAll={{
288
+ checked: allSelected,
289
+ indeterminate: selection.hasSelection && !allSelected,
290
+ onChange: () => selection.toggleAll(orderedIds),
291
+ }}
292
+ />
293
+ }
294
+ />
295
+ );
296
+ }
297
+
215
298
  /**
216
- * The `selectionBar` slot at desktop width, holding the same
217
- * `SelectionTopBar` the narrow story below holds. One surface at every
218
- * width: from 768px up the labelled select-all sits inline in the bar.
299
+ * The pane under a selection its consumer owns — it draws the checkboxes and
300
+ * holds none of the state. Click a checkbox to tick one row, cmd/ctrl-click a
301
+ * row to tick it without opening it, shift-click to range from the last row
302
+ * touched, and select-all covers the rows on screen. From 768px up the labelled
303
+ * select-all sits inline in the bar.
304
+ *
305
+ * The bar is the header for every state of the list: it names the mailbox with
306
+ * nothing ticked and carries the count and the verbs from the first ticked row.
307
+ * Delete and Mark read act on the rows — trashed rows leave the list, marked
308
+ * ones lose their unread dot.
219
309
  */
220
- export const ExternalSelectionBar: Story = {
221
- args: {
222
- isDesktop: true,
223
- flatList: true,
224
- selectionBar: (
225
- <SelectionTopBar
226
- title="Inbox"
227
- count={2}
228
- onCancel={() => undefined}
229
- onMarkRead={() => undefined}
230
- onJunk={() => undefined}
231
- onOrganize={() => undefined}
232
- onDelete={() => undefined}
233
- selectAll={{
234
- checked: false,
235
- indeterminate: true,
236
- onChange: () => undefined,
237
- }}
238
- />
239
- ),
240
- },
310
+ export const ConsumerSelection: Story = {
311
+ render: () => <SelectableList isDesktop />,
241
312
  decorators: [desktopFrame],
242
313
  };
243
314
 
244
315
  /**
245
- * The same bar at phone width: select-all moves to a second row so row one
246
- * stays a count and the verbs, with a back arrow out of selection.
316
+ * The same list at phone width, where selection is a long press and a tap on
317
+ * the checkbox: no modifier comes off a touch, so there is no range and no
318
+ * cmd-toggle here. Checkboxes stay put once a row is ticked, and select-all
319
+ * moves to a second row so row one stays a count and the verbs.
247
320
  */
248
- export const NarrowExternalSelectionBar: Story = {
249
- args: {
250
- isDesktop: false,
251
- flatList: true,
252
- selectionBar: (
253
- <SelectionTopBar
254
- title="Inbox"
255
- count={2}
256
- onCancel={() => undefined}
257
- onMarkRead={() => undefined}
258
- onJunk={() => undefined}
259
- onOrganize={() => undefined}
260
- onDelete={() => undefined}
261
- selectAll={{
262
- checked: false,
263
- indeterminate: true,
264
- onChange: () => undefined,
265
- }}
266
- />
267
- ),
268
- },
321
+ export const NarrowConsumerSelection: Story = {
322
+ render: () => <SelectableList isDesktop={false} />,
269
323
  decorators: [narrowFrame],
270
324
  };
271
325