@remit/ui 0.0.12 → 0.0.14

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.
@@ -64,4 +64,93 @@ describe("SelectionTopBar", () => {
64
64
  );
65
65
  assert.match(html, /Cross-account moves are not supported/);
66
66
  });
67
+
68
+ it("renders statusLabel in place of the count copy when provided", () => {
69
+ const html = renderToString(
70
+ createElement(SelectionTopBar, {
71
+ ...handlers,
72
+ count: 3412,
73
+ statusLabel: "Deleting 1,200 of 3,412…",
74
+ }),
75
+ );
76
+ assert.match(text(html), /Deleting 1,200 of 3,412…/);
77
+ assert.doesNotMatch(text(html), /3412 messages selected/);
78
+ });
79
+
80
+ it("falls back to the count copy when statusLabel is absent", () => {
81
+ const html = renderToString(
82
+ createElement(SelectionTopBar, { ...handlers, count: 2 }),
83
+ );
84
+ assert.match(text(html), /2 messages selected/);
85
+ });
86
+
87
+ it("renders failureHint when provided", () => {
88
+ const html = renderToString(
89
+ createElement(SelectionTopBar, {
90
+ ...handlers,
91
+ count: 2,
92
+ failureHint: "340 failed to delete — retry?",
93
+ }),
94
+ );
95
+ assert.match(html, /340 failed to delete — retry\?/);
96
+ });
97
+
98
+ it("omits the select-all control when selectAll is absent", () => {
99
+ const html = renderToString(
100
+ createElement(SelectionTopBar, { ...handlers, count: 2 }),
101
+ );
102
+ assert.doesNotMatch(html, /aria-label="Select all"/);
103
+ });
104
+
105
+ it("renders the select-all control, unchecked, in the some-selected state", () => {
106
+ const html = renderToString(
107
+ createElement(SelectionTopBar, {
108
+ ...handlers,
109
+ count: 2,
110
+ selectAll: {
111
+ checked: false,
112
+ indeterminate: true,
113
+ onChange: () => undefined,
114
+ },
115
+ }),
116
+ );
117
+ assert.match(html, /aria-label="Select all"/);
118
+ assert.doesNotMatch(
119
+ html,
120
+ /aria-label="Select all"[^>]*checked=""/,
121
+ "some-selected is not the checked state",
122
+ );
123
+ });
124
+
125
+ it("renders the select-all control checked in the all-selected state", () => {
126
+ const html = renderToString(
127
+ createElement(SelectionTopBar, {
128
+ ...handlers,
129
+ count: 12,
130
+ selectAll: {
131
+ checked: true,
132
+ indeterminate: false,
133
+ onChange: () => undefined,
134
+ },
135
+ }),
136
+ );
137
+ assert.match(
138
+ html,
139
+ /aria-label="Select all"[^>]*checked=""/,
140
+ "all-selected renders the checkbox checked",
141
+ );
142
+ });
143
+
144
+ it("omitting every new prop renders exactly the pre-existing bar", () => {
145
+ const html = renderToString(
146
+ createElement(SelectionTopBar, { ...handlers, count: 2 }),
147
+ );
148
+ assert.doesNotMatch(
149
+ html,
150
+ /aria-label="Select all"/,
151
+ "no select-all control",
152
+ );
153
+ assert.match(text(html), /2 messages selected/, "default count copy");
154
+ assert.doesNotMatch(html, /role="status"/, "no status line rendered");
155
+ });
67
156
  });
@@ -37,3 +37,69 @@ export const CrossAccountHint: Story = {
37
37
  "Move only works within one account — clear selection or pick messages from a single account",
38
38
  },
39
39
  };
40
+
41
+ /** Some but not all rows checked: the select-all control renders the
42
+ * `Checkbox` tri-state dash, not the box or the tick. */
43
+ export const SelectAll: Story = {
44
+ args: {
45
+ count: 3,
46
+ selectAll: {
47
+ checked: false,
48
+ indeterminate: true,
49
+ onChange: () => undefined,
50
+ },
51
+ },
52
+ };
53
+
54
+ /** Every row checked: the select-all control renders as a plain checked box. */
55
+ export const AllSelected: Story = {
56
+ args: {
57
+ count: 12,
58
+ selectAll: {
59
+ checked: true,
60
+ indeterminate: false,
61
+ onChange: () => undefined,
62
+ },
63
+ },
64
+ };
65
+
66
+ /** While a search result set is still paging, the exact count isn't known yet —
67
+ * `statusLabel` replaces the "{count} selected" text with a counting message. */
68
+ export const Counting: Story = {
69
+ args: {
70
+ count: 0,
71
+ statusLabel: "Counting matching messages…",
72
+ },
73
+ };
74
+
75
+ /** A bulk delete in progress reports a running total via `statusLabel`, and
76
+ * the delete button shows its busy spinner (never disables). */
77
+ export const DeletingWithProgress: Story = {
78
+ args: {
79
+ count: 3412,
80
+ statusLabel: "Deleting 1,200 of 3,412…",
81
+ isBusy: true,
82
+ },
83
+ };
84
+
85
+ /** After a bulk delete finishes with some batches failed, `failureHint`
86
+ * surfaces the shortfall in danger tone — independent of `moveDisabledHint`,
87
+ * which is muted and cross-account-specific. */
88
+ export const PartialFailure: Story = {
89
+ args: {
90
+ count: 3412,
91
+ failureHint: "3,072 deleted, 340 failed to delete — retry?",
92
+ },
93
+ };
94
+
95
+ /**
96
+ * count === 0 is unreachable in production: both the kit
97
+ * (`message-list-pane.tsx`'s `toggleCheck`) and the web-client
98
+ * (`MessageList.tsx`'s multi-select effect) auto-exit selection mode the
99
+ * instant the last checked row is unchecked. `SelectionTopBar` itself has no
100
+ * floor on `count` — this story pins the contract that no caller should ever
101
+ * leave it mounted here.
102
+ */
103
+ export const ZeroSelected: Story = {
104
+ args: { count: 0 },
105
+ };
@@ -1,6 +1,7 @@
1
1
  import { Loader2, MailOpen, Trash2, X } from "lucide-react";
2
2
  import type { ReactNode } from "react";
3
3
  import { Button } from "./button.js";
4
+ import { Checkbox } from "./checkbox.js";
4
5
 
5
6
  export interface SelectionTopBarProps {
6
7
  count: number;
@@ -23,6 +24,30 @@ export interface SelectionTopBarProps {
23
24
  * shows a spinner; other actions no-op. Never disables controls.
24
25
  */
25
26
  isBusy?: boolean;
27
+ /**
28
+ * Select-all control rendered between cancel and the count label. Presence
29
+ * of this prop is what renders the checkbox — omit it for a bar with no
30
+ * select-all affordance. `indeterminate` renders the some-selected tri-state
31
+ * (`Checkbox`'s dash), `checked` is the all-selected state.
32
+ */
33
+ selectAll?: {
34
+ checked: boolean;
35
+ indeterminate?: boolean;
36
+ onChange: () => void;
37
+ };
38
+ /**
39
+ * Overrides the "{count} messages selected" text. For states where the
40
+ * count itself isn't the useful thing to show yet — "Counting…" while a
41
+ * search result set is still paging, or "Deleting 1,200 of 3,412…" progress
42
+ * during a bulk delete.
43
+ */
44
+ statusLabel?: string;
45
+ /**
46
+ * Danger-toned status line below the action row, for a partial failure
47
+ * after a bulk operation (e.g. some batches failed to delete). Independent
48
+ * of `moveDisabledHint` — a caller shows one or the other, never both.
49
+ */
50
+ failureHint?: string;
26
51
  }
27
52
 
28
53
  /**
@@ -37,6 +62,9 @@ export function SelectionTopBar({
37
62
  moveSlot,
38
63
  moveDisabledHint,
39
64
  isBusy = false,
65
+ selectAll,
66
+ statusLabel,
67
+ failureHint,
40
68
  }: SelectionTopBarProps) {
41
69
  return (
42
70
  <header className="flex shrink-0 flex-col border-b border-line bg-surface-sunken">
@@ -49,8 +77,18 @@ export function SelectionTopBar({
49
77
  aria-label="Cancel selection"
50
78
  className="-ml-1 shrink-0"
51
79
  />
80
+ {selectAll && (
81
+ <Checkbox
82
+ aria-label="Select all"
83
+ checked={selectAll.checked}
84
+ indeterminate={selectAll.indeterminate}
85
+ onChange={selectAll.onChange}
86
+ className="shrink-0"
87
+ />
88
+ )}
52
89
  <span className="min-w-0 flex-1 truncate text-sm font-medium text-fg">
53
- {count} {count === 1 ? "message" : "messages"} selected
90
+ {statusLabel ??
91
+ `${count} ${count === 1 ? "message" : "messages"} selected`}
54
92
  </span>
55
93
  {onMarkRead && (
56
94
  <Button
@@ -90,6 +128,16 @@ export function SelectionTopBar({
90
128
  {moveDisabledHint}
91
129
  </p>
92
130
  )}
131
+ {failureHint && (
132
+ // biome-ignore lint/a11y/useSemanticElements: <p> with role="status" preserves block layout; <output> is inline
133
+ <p
134
+ className="px-row-inset pb-2 text-xs text-danger"
135
+ role="status"
136
+ aria-live="polite"
137
+ >
138
+ {failureHint}
139
+ </p>
140
+ )}
93
141
  </header>
94
142
  );
95
143
  }
@@ -0,0 +1,26 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import { SpamResultsOffer } from "./spam-results-offer.js";
6
+
7
+ const noop = () => {};
8
+
9
+ describe("SpamResultsOffer", () => {
10
+ it("states the count and offers a way into Spam", () => {
11
+ const html = renderToString(
12
+ createElement(SpamResultsOffer, { count: 3, onScopeToSpam: noop }),
13
+ );
14
+ assert.match(html, />3</);
15
+ assert.match(html, /results from Spam/);
16
+ assert.match(html, /View them/);
17
+ });
18
+
19
+ it("reads in the singular for one match", () => {
20
+ const html = renderToString(
21
+ createElement(SpamResultsOffer, { count: 1, onScopeToSpam: noop }),
22
+ );
23
+ assert.match(html, /result from Spam/);
24
+ assert.doesNotMatch(html, /results from Spam/);
25
+ });
26
+ });
@@ -0,0 +1,41 @@
1
+ import type { Decorator, Meta, StoryObj } from "@storybook/react";
2
+ import { SpamResultsOffer } from "./spam-results-offer.js";
3
+
4
+ /** Frames the offer at the width of the list pane it sits at the top of. */
5
+ const listPaneFrame: Decorator = (Story) => (
6
+ <div
7
+ className="overflow-hidden rounded-lg border border-line bg-canvas"
8
+ style={{ width: 360 }}
9
+ >
10
+ <Story />
11
+ </div>
12
+ );
13
+
14
+ const meta: Meta<typeof SpamResultsOffer> = {
15
+ title: "Components/SpamResultsOffer",
16
+ component: SpamResultsOffer,
17
+ parameters: { layout: "centered" },
18
+ decorators: [listPaneFrame],
19
+ };
20
+ export default meta;
21
+
22
+ type Story = StoryObj<typeof SpamResultsOffer>;
23
+
24
+ /**
25
+ * Spam matches a global search held out of its results. Deliberately quiet —
26
+ * an offer, not a warning — and the action scopes the search to Spam rather
27
+ * than opening a view of its own.
28
+ */
29
+ export const Default: Story = {
30
+ args: { count: 3, onScopeToSpam: () => {} },
31
+ };
32
+
33
+ /** One match reads in the singular. */
34
+ export const SingleResult: Story = {
35
+ args: { count: 1, onScopeToSpam: () => {} },
36
+ };
37
+
38
+ /** Large counts stay on one line; the figure is tabular so it does not jitter. */
39
+ export const ManyResults: Story = {
40
+ args: { count: 128, onScopeToSpam: () => {} },
41
+ };
@@ -0,0 +1,52 @@
1
+ import { Banner } from "./banner.js";
2
+ import { Button } from "./button.js";
3
+
4
+ export interface SpamResultsOfferProps {
5
+ /** How many matches the search found in Spam. */
6
+ count: number;
7
+ /**
8
+ * Scope the search to Spam. This is a shortcut to the state reached by
9
+ * navigating to Spam with the query carried over — the same scoped search,
10
+ * with the same `in:spam` chip — not a separate result mode.
11
+ */
12
+ onScopeToSpam: () => void;
13
+ }
14
+
15
+ const plural = (n: number): string => (n === 1 ? "result" : "results");
16
+
17
+ /**
18
+ * Spam matches held out of a global search, offered rather than mixed in. Spam
19
+ * is the one folder a search that reaches everywhere does not inline, because
20
+ * the whole point of the folder is that its contents are unwanted until asked
21
+ * for. Taking the offer scopes the search to Spam.
22
+ *
23
+ * Quiet on purpose: this is an offer, not a warning. Presentational — the
24
+ * caller owns what "scope to spam" does.
25
+ */
26
+ export function SpamResultsOffer({
27
+ count,
28
+ onScopeToSpam,
29
+ }: SpamResultsOfferProps) {
30
+ return (
31
+ <Banner
32
+ tone="info"
33
+ variant="soft"
34
+ className="items-center justify-between gap-3 rounded-none border-b border-line"
35
+ >
36
+ <div className="flex items-center justify-between gap-3">
37
+ <p className="min-w-0 text-xs text-fg-muted">
38
+ <span className="font-semibold text-fg tabular-nums">{count}</span>{" "}
39
+ {`${plural(count)} from Spam`}
40
+ </p>
41
+ <Button
42
+ variant="ghost"
43
+ size="sm"
44
+ onClick={onScopeToSpam}
45
+ className="shrink-0 text-accent"
46
+ >
47
+ View them
48
+ </Button>
49
+ </div>
50
+ </Banner>
51
+ );
52
+ }
@@ -55,6 +55,20 @@ export const PeekedLeading: Story = { args: { peek: "leading" } };
55
55
 
56
56
  export const PeekedTrailing: Story = { args: { peek: "trailing" } };
57
57
 
58
+ /**
59
+ * In selection mode the leading avatar is REPLACED by a checkbox affordance
60
+ * — unchecked below, checked in the next story. `baseArgs` never flips
61
+ * `selectionMode`/`checked`, so this row-level toggle had zero coverage.
62
+ */
63
+ export const SelectionUnchecked: Story = {
64
+ args: { peek: "none", selectionMode: true, checked: false },
65
+ };
66
+
67
+ /** Selection mode, row checked: the circle fills accent and shows a tick. */
68
+ export const SelectionChecked: Story = {
69
+ args: { peek: "none", selectionMode: true, checked: true },
70
+ };
71
+
58
72
  /**
59
73
  * The open affordance is rendered as a real `<a href>` via `linkComponent`,
60
74
  * so deep-link, middle-click and open-in-new-tab work. Consumers pass their
@@ -74,3 +74,22 @@ export const Refreshing: Story = { args: { refreshing: true } };
74
74
  export const SelectionMode: Story = {
75
75
  args: { selectionMode: true, checkedIds: new Set(["t1", "t3"]) },
76
76
  };
77
+
78
+ /** Every row checked — the ceiling a select-all control drives toward. */
79
+ export const SelectionModeAllChecked: Story = {
80
+ args: {
81
+ selectionMode: true,
82
+ checkedIds: new Set(
83
+ sections.flatMap((section) => section.threads.map((t) => t.id)),
84
+ ),
85
+ },
86
+ };
87
+
88
+ /**
89
+ * Selection mode with nothing checked. `TouchListBody` itself has no floor —
90
+ * the auto-exit-at-zero contract belongs to the caller (`MessageListPane`,
91
+ * production `MessageList.tsx`), which this component doesn't own.
92
+ */
93
+ export const SelectionModeNoneChecked: Story = {
94
+ args: { selectionMode: true, checkedIds: new Set<string>() },
95
+ };
package/src/index.ts CHANGED
@@ -121,7 +121,10 @@ export {
121
121
  export {
122
122
  canonicalRoleLabel,
123
123
  type FolderRole,
124
+ isVirtualFolderRole,
125
+ provenanceFolderLabel,
124
126
  providerLeaf,
127
+ type ResultFolder,
125
128
  roleIcon,
126
129
  } from "./components/folder-role.js";
127
130
  export {
@@ -304,9 +307,11 @@ export {
304
307
  type SearchResultTone,
305
308
  } from "./components/search-result-row.js";
306
309
  export {
310
+ partitionSpamResults,
307
311
  type SearchResultSection,
308
312
  SearchResults,
309
313
  type SearchResultsProps,
314
+ type SearchScope,
310
315
  } from "./components/search-results.js";
311
316
  export {
312
317
  SearchChipRow,
@@ -358,6 +363,10 @@ export {
358
363
  SlidePanel,
359
364
  type SlidePanelProps,
360
365
  } from "./components/slide-panel.js";
366
+ export {
367
+ SpamResultsOffer,
368
+ type SpamResultsOfferProps,
369
+ } from "./components/spam-results-offer.js";
361
370
  export {
362
371
  commitPeek,
363
372
  SwipeableRow,