@remit/ui 0.0.74 → 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.74",
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
  );
@@ -16,7 +16,7 @@ import {
16
16
  import type { BriefRowComponent } from "./message-row.js";
17
17
 
18
18
  /* Composable brief filters — each is an additive predicate over a thread row. */
19
- type BriefFilterId = "unread" | "attachment" | "contacts" | "today";
19
+ export type BriefFilterId = "unread" | "attachment" | "contacts" | "today";
20
20
 
21
21
  /* "Today" prefers the real `sentDate` timestamp; it falls back to the fixture
22
22
  convention that same-day rows render a HH:MM timeLabel (fixtures carry no
@@ -56,7 +56,40 @@ export const briefFilterChips: FilterSheetFilter[] = briefFilterDefs.map(
56
56
  ({ id, label }) => ({ id, label }),
57
57
  );
58
58
 
59
- export interface BriefSectionsProps {
59
+ /**
60
+ * Whether a thread survives a set of attribute chips, as the brief's own list
61
+ * applies them. Exported so a consumer narrowing the same rows on another
62
+ * surface — the phone search takeover — reads one definition of what "Unread" or
63
+ * "Today" means.
64
+ */
65
+ export function matchesBriefFilters(
66
+ thread: ThreadRowData,
67
+ activeFilters: ReadonlySet<BriefFilterId>,
68
+ ): boolean {
69
+ return briefFilterDefs.every(
70
+ (f) => !activeFilters.has(f.id) || f.match(thread),
71
+ );
72
+ }
73
+
74
+ /**
75
+ * The attribute chips are either this component's own or entirely the
76
+ * consumer's. A consumer narrowing the same rows on a second surface (the phone
77
+ * search takeover) holds the set so both surfaces answer to one selection, and
78
+ * takes every control over it with the set.
79
+ */
80
+ type BriefFilterControl =
81
+ | {
82
+ activeFilters: ReadonlySet<BriefFilterId>;
83
+ onToggleFilter: (id: BriefFilterId) => void;
84
+ onClearFilters: () => void;
85
+ }
86
+ | {
87
+ activeFilters?: never;
88
+ onToggleFilter?: never;
89
+ onClearFilters?: never;
90
+ };
91
+
92
+ interface BriefSectionsBaseProps {
60
93
  sections: ThreadSection[];
61
94
  briefCategory?: BriefCategoryFilter;
62
95
  selectedThreadId?: string;
@@ -73,10 +106,17 @@ export interface BriefSectionsProps {
73
106
  sourcesNote?: string;
74
107
  /** Called when the user selects a source/account pill. */
75
108
  onSelectSource?: (id: string) => void;
109
+ /**
110
+ * Drop the filter row and its panel, keeping the rows where they are. See
111
+ * `FilterSheetProps`.
112
+ */
113
+ hideChrome?: boolean;
76
114
  /** Seeds the filter panel open on first render (stories / deep links). */
77
115
  defaultExpanded?: boolean;
78
116
  }
79
117
 
118
+ export type BriefSectionsProps = BriefSectionsBaseProps & BriefFilterControl;
119
+
80
120
  /**
81
121
  * The daily-brief list body: category pills (single-select) + attribute chips
82
122
  * (additive) + one capped section per category (see {@link BriefSection}). Owns
@@ -95,9 +135,15 @@ export function BriefSections({
95
135
  sources,
96
136
  sourcesNote,
97
137
  onSelectSource,
138
+ activeFilters,
139
+ onToggleFilter,
140
+ onClearFilters,
141
+ hideChrome,
98
142
  defaultExpanded = false,
99
143
  }: BriefSectionsProps) {
100
- const [active, setActive] = useState<ReadonlySet<BriefFilterId>>(new Set());
144
+ const [ownFilters, setOwnFilters] = useState<ReadonlySet<BriefFilterId>>(
145
+ new Set(),
146
+ );
101
147
  const [sheetExpanded, setSheetExpanded] = useState(defaultExpanded);
102
148
  const listRef = useRef<HTMLDivElement>(null);
103
149
  useRovingFocus({
@@ -105,8 +151,14 @@ export function BriefSections({
105
151
  itemSelector: LIST_ROW_SELECTOR,
106
152
  });
107
153
 
154
+ const active = activeFilters ?? ownFilters;
155
+
108
156
  const toggleFilter = (id: BriefFilterId) => {
109
- setActive((prev) => {
157
+ if (onToggleFilter) {
158
+ onToggleFilter(id);
159
+ return;
160
+ }
161
+ setOwnFilters((prev) => {
110
162
  const next = new Set(prev);
111
163
  if (next.has(id)) next.delete(id);
112
164
  else next.add(id);
@@ -114,10 +166,9 @@ export function BriefSections({
114
166
  });
115
167
  };
116
168
 
117
- const predicates = briefFilterDefs.filter((f) => active.has(f.id));
118
169
  const matches = (t: ThreadRowData) =>
119
170
  (briefCategory === "all" || t.category === briefCategory) &&
120
- predicates.every((f) => f.match(t));
171
+ matchesBriefFilters(t, active);
121
172
 
122
173
  // One section per category only earns its keep at the "all" scope. Narrow to
123
174
  // a single category and the headers are redundant: render a plain flat list.
@@ -142,7 +193,11 @@ export function BriefSections({
142
193
 
143
194
  const clearFilters = () => {
144
195
  onSelectBriefCategory?.("all");
145
- setActive(new Set());
196
+ if (onClearFilters) {
197
+ onClearFilters();
198
+ return;
199
+ }
200
+ setOwnFilters(new Set());
146
201
  };
147
202
 
148
203
  const empty = showSections ? filtered.length === 0 : flatRows.length === 0;
@@ -200,6 +255,7 @@ export function BriefSections({
200
255
  onSelectSource={onSelectSource}
201
256
  onToggleFilter={(id) => toggleFilter(id as BriefFilterId)}
202
257
  onClear={clearFilters}
258
+ hideChrome={hideChrome}
203
259
  >
204
260
  {listBody}
205
261
  </FilterSheet>
@@ -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
  });
@@ -121,6 +121,8 @@ interface FilterPanelState {
121
121
  /** Whether the sheet under this provider is narrowing the list right now. */
122
122
  active: boolean;
123
123
  setActive: (active: boolean) => void;
124
+ /** Whether a sheet is mounted for the caret to open. */
125
+ hasSheet: boolean;
124
126
  }
125
127
 
126
128
  const FilterPanelCtx = createContext<FilterPanelState | null>(null);
@@ -130,29 +132,54 @@ const FilterPanelCtx = createContext<FilterPanelState | null>(null);
130
132
  * under it, which are siblings in the tree. Wrap both: the header renders a
131
133
  * `FilterToggle`, the sheet drops its own trigger row, and the sheet keeps its
132
134
  * filter state exactly where it already lives.
135
+ *
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.
133
141
  */
134
- export function FilterPanelProvider({ children }: { children: ReactNode }) {
142
+ export function FilterPanelProvider({
143
+ children,
144
+ hasSheet,
145
+ }: {
146
+ children?: ReactNode;
147
+ hasSheet: boolean;
148
+ }) {
135
149
  const [open, setOpen] = useState(false);
136
150
  const [active, setActive] = useState(false);
137
151
  const value = useMemo(
138
- () => ({ open, setOpen, active, setActive }),
139
- [open, active],
152
+ () => ({ open, setOpen, active, setActive, hasSheet }),
153
+ [open, active, hasSheet],
140
154
  );
141
155
  return (
142
156
  <FilterPanelCtx.Provider value={value}>{children}</FilterPanelCtx.Provider>
143
157
  );
144
158
  }
145
159
 
160
+ /**
161
+ * Detaches whatever it wraps from an enclosing `FilterPanelProvider`. A
162
+ * full-screen surface that covers the list — the phone search takeover — carries
163
+ * its own filter chrome, and the caret it would otherwise borrow belongs to a
164
+ * header that is not on screen.
165
+ */
166
+ export function FilterPanelBoundary({ children }: { children: ReactNode }) {
167
+ return (
168
+ <FilterPanelCtx.Provider value={null}>{children}</FilterPanelCtx.Provider>
169
+ );
170
+ }
171
+
146
172
  /**
147
173
  * The filter caret, for a list header to render beside its unread count. It is
148
174
  * the view's whole filter affordance; the panel it opens still belongs to the
149
175
  * sheet, inline above the rows.
150
176
  *
151
- * Renders nothing outside a `FilterPanelProvider` there is no panel to open.
177
+ * Renders nothing outside a `FilterPanelProvider`, and nothing while that
178
+ * provider reports no sheet — there is no panel to open.
152
179
  */
153
180
  export function FilterToggle() {
154
181
  const panel = useContext(FilterPanelCtx);
155
- if (!panel) return null;
182
+ if (!panel?.hasSheet) return null;
156
183
  const { open, active, setOpen } = panel;
157
184
  return (
158
185
  <button
@@ -225,7 +252,9 @@ export function FilterSheet({
225
252
 
226
253
  const setPanelActive = panel?.setActive;
227
254
  useEffect(() => {
228
- setPanelActive?.(hasActive);
255
+ if (!setPanelActive) return;
256
+ setPanelActive(hasActive);
257
+ return () => setPanelActive(false);
229
258
  }, [hasActive, setPanelActive]);
230
259
 
231
260
  const clearSource = useCallback(() => {
@@ -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