@remit/ui 0.0.60 → 0.0.61

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/src/index.ts CHANGED
@@ -469,16 +469,6 @@ export {
469
469
  type SegmentedOption,
470
470
  } from "./components/segmented-control.js";
471
471
  export { Select, type SelectProps } from "./components/select.js";
472
- export {
473
- resolveSheetSnap,
474
- SELECTION_SHEET_TEASER_HEIGHT,
475
- SelectionSheet,
476
- type SelectionSheetMode,
477
- type SelectionSheetNotice,
478
- type SelectionSheetNoticeAction,
479
- type SelectionSheetProps,
480
- type SheetSnapInput,
481
- } from "./components/selection-sheet.js";
482
472
  export {
483
473
  SelectionTopBar,
484
474
  type SelectionTopBarNotice,
@@ -672,6 +662,8 @@ export {
672
662
  backExits,
673
663
  clauseSentence,
674
664
  clauseWords,
665
+ crossAccountDestinationReason,
666
+ crossAccountRuleReason,
675
667
  type MatchCount,
676
668
  type MatchDescription,
677
669
  type MatchMode,
@@ -80,24 +80,25 @@ describe("stepsFor — the step ids for every verb × match mode × scope", () =
80
80
  ]);
81
81
  });
82
82
 
83
- it("gives Organize a scope step, and a naming step only where the scope persists", () => {
83
+ it("gives Organize a destination and a scope step, and a naming step only where the scope persists", () => {
84
84
  assert.deepEqual(stepsFor({ verb: "organize", mode: "selected" }), [
85
85
  "match",
86
+ "folder",
86
87
  "rule",
87
88
  "review",
88
89
  "run",
89
90
  ]);
90
91
  assert.deepEqual(
91
92
  stepsFor({ verb: "organize", mode: "selected", scope: "once" }),
92
- ["match", "rule", "review", "run"],
93
+ ["match", "folder", "rule", "review", "run"],
93
94
  );
94
95
  assert.deepEqual(
95
96
  stepsFor({ verb: "organize", mode: "similar", scope: "standing" }),
96
- ["match", "rule", "name", "review", "run"],
97
+ ["match", "folder", "rule", "name", "review", "run"],
97
98
  );
98
99
  assert.deepEqual(
99
100
  stepsFor({ verb: "organize", mode: "properties", scope: "until" }),
100
- ["match", "properties", "rule", "name", "review", "run"],
101
+ ["match", "properties", "folder", "rule", "name", "review", "run"],
101
102
  );
102
103
  });
103
104
 
@@ -109,7 +110,7 @@ describe("stepsFor — the step ids for every verb × match mode × scope", () =
109
110
  scope: "standing",
110
111
  fromSearch: true,
111
112
  }),
112
- ["properties", "rule", "name", "review", "run"],
113
+ ["properties", "folder", "rule", "name", "review", "run"],
113
114
  );
114
115
  assert.deepEqual(
115
116
  stepsFor({ verb: "move", mode: "properties", fromSearch: true }),
@@ -171,14 +172,21 @@ describe("stepsFor — a branching answer only adds or drops a step after itself
171
172
  mode: "selected",
172
173
  scope: "standing",
173
174
  });
174
- assert.deepEqual(standing, ["match", "rule", "name", "review", "run"]);
175
+ assert.deepEqual(standing, [
176
+ "match",
177
+ "folder",
178
+ "rule",
179
+ "name",
180
+ "review",
181
+ "run",
182
+ ]);
175
183
 
176
184
  const once = stepsFor({
177
185
  verb: "organize",
178
186
  mode: "selected",
179
187
  scope: "once",
180
188
  });
181
- assert.deepEqual(once, ["match", "rule", "review", "run"]);
189
+ assert.deepEqual(once, ["match", "folder", "rule", "review", "run"]);
182
190
 
183
191
  // The user is on Review when they go back and change the scope to once.
184
192
  // Held by number, index 3 lands them on Run — the action taken without the
@@ -222,7 +230,7 @@ describe("stepsFor — a branching answer only adds or drops a step after itself
222
230
  const shortened = stepsFor({ verb: "organize", mode: "selected" });
223
231
  assert.equal(shortened.includes("properties"), false);
224
232
  assert.equal(stepIndex(shortened, "properties"), 0);
225
- assert.equal(stepIndex(shortened, "rule"), 1);
233
+ assert.equal(stepIndex(shortened, "rule"), 2);
226
234
  });
227
235
  });
228
236
 
@@ -370,6 +378,27 @@ describe("stepBlockedReason", () => {
370
378
  );
371
379
  });
372
380
 
381
+ it("refuses to commit a match that reaches nothing", () => {
382
+ // A settled zero is a whole answer: committing it would report a bulk
383
+ // action over no mail. The reload path lands here too — the step comes
384
+ // back in the URL and the ticked rows do not come back with it.
385
+ const reason = stepBlockedReason("review", draft(), {
386
+ status: "ready",
387
+ count: 0,
388
+ });
389
+ assert.ok(reason);
390
+ assert.match(reason, /nothing to do/);
391
+ // A zero that is still being recounted is not that answer yet.
392
+ assert.equal(
393
+ stepBlockedReason("review", draft(), {
394
+ status: "ready",
395
+ count: 0,
396
+ stale: true,
397
+ }),
398
+ ruleBlockedCopy.recounting,
399
+ );
400
+ });
401
+
373
402
  it("waits on no count where a widened door never had one", () => {
374
403
  assert.equal(stepBlockedReason("review", draft(), UNCOUNTED), undefined);
375
404
  });
@@ -112,8 +112,9 @@ export interface WizardAnswers {
112
112
 
113
113
  /**
114
114
  * The steps this flow walks, in order. The property door earns an editor step,
115
- * Move earns a destination step, Organize earns a scope step, and a scope that
116
- * persists earns a naming step.
115
+ * Move and Organize both earn a destination step a rule that moves nothing is
116
+ * a rule with nothing to commit — Organize earns a scope step after it, and a
117
+ * scope that persists earns a naming step.
117
118
  */
118
119
  export const stepsFor = ({
119
120
  verb,
@@ -129,9 +130,9 @@ export const stepsFor = ({
129
130
  if (verb === "move") return [...opening, "folder", "review", "run"];
130
131
  if (verb === "organize") {
131
132
  if (scope === "standing" || scope === "until") {
132
- return [...opening, "rule", "name", "review", "run"];
133
+ return [...opening, "folder", "rule", "name", "review", "run"];
133
134
  }
134
- return [...opening, "rule", "review", "run"];
135
+ return [...opening, "folder", "rule", "review", "run"];
135
136
  }
136
137
  return [...opening, "review", "run"];
137
138
  };
@@ -200,6 +201,27 @@ export const unreadableDraftClauses = (draft: WizardDraft): RuleClause[] =>
200
201
  */
201
202
  export type MatchCount = PreviewCount | { status: "uncounted" };
202
203
 
204
+ /**
205
+ * Why a selection spanning accounts cannot become a rule (#477 5.5). A filter
206
+ * belongs to the account it was created for, so the two persisting scopes have
207
+ * nothing to attach to; the one-off scope acts on the messages themselves and is
208
+ * unaffected.
209
+ */
210
+ export const crossAccountRuleReason =
211
+ "A rule only works within one account — clear the selection, or pick messages from a single account.";
212
+
213
+ /** The same restriction on the step that asks for a folder. */
214
+ export const crossAccountDestinationReason =
215
+ "A destination only works within one account — clear the selection, or pick messages from a single account.";
216
+
217
+ /**
218
+ * The match reached nothing, so committing it would report a bulk action that
219
+ * touched no mail. Reachable both from a predicate that matches nothing and from
220
+ * a wizard restored by a reload, whose ticked rows did not come back with it.
221
+ */
222
+ const EMPTY_MATCH =
223
+ "This matches no messages, so there is nothing to do. Go back and change what it applies to.";
224
+
203
225
  /** A clause chip that was added but never filled in. The rule editor has no equivalent — it holds its draft until the value is typed. */
204
226
  const INCOMPLETE_CLAUSE = "Fill in every property, or take the empty one off.";
205
227
  const NO_DESTINATION = "Pick a destination first.";
@@ -244,9 +266,11 @@ export const stepBlockedReason = (
244
266
  }
245
267
  if (step === "name" && !draft.name?.trim()) return ruleBlockedCopy.unnamed;
246
268
  if (step === "review") {
247
- return count.status === "uncounted"
248
- ? undefined
249
- : previewSettledReason(count);
269
+ if (count.status === "uncounted") return undefined;
270
+ if (count.status === "ready" && count.count === 0 && !count.stale) {
271
+ return EMPTY_MATCH;
272
+ }
273
+ return previewSettledReason(count);
250
274
  }
251
275
  return undefined;
252
276
  };
@@ -364,7 +388,7 @@ export const runCopy = ({
364
388
  title: standing ? "Rule saved and applied" : `${past} ${applied}`,
365
389
  detail: standing
366
390
  ? `${applied} of ${matched} already in your mailbox ${done}. New mail follows the rule as it arrives.`
367
- : `Every message the match reached was ${done}.`,
391
+ : `Every message the match reached was ${done}. Your mail server is still catching up, so the list can lag behind.`,
368
392
  tone: "success",
369
393
  dismissLabel: "Done",
370
394
  };
@@ -1,169 +0,0 @@
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 { SelectionSheet, type SelectionSheetProps } from "./selection-sheet.js";
6
-
7
- const noop = () => {};
8
-
9
- const base: SelectionSheetProps = {
10
- count: 3,
11
- onCancel: noop,
12
- onDelete: noop,
13
- };
14
-
15
- describe("SelectionSheet", () => {
16
- it("teases collapsed with the count and the swipe hint", () => {
17
- const html = renderToString(createElement(SelectionSheet, base));
18
- assert.match(html, /3 messages selected/);
19
- assert.match(html, /Swipe up for actions/);
20
- assert.match(html, /role="slider"/);
21
- // Collapsed: no cancel/mark-read in the header yet.
22
- assert.doesNotMatch(html, /aria-label="Cancel selection"/);
23
- // The clipped body is inert while collapsed, so its offscreen verbs stay
24
- // out of the tab order and the a11y tree.
25
- assert.match(html, /inert=""/);
26
- });
27
-
28
- it("expanded shows the quick actions, cancel, and the smart-flow rows", () => {
29
- const html = renderToString(
30
- createElement(SelectionSheet, {
31
- ...base,
32
- startExpanded: true,
33
- onMarkRead: noop,
34
- onJunk: noop,
35
- onSelectSimilar: noop,
36
- onSomethingElse: noop,
37
- moveSlot: createElement("span", null, "move-here"),
38
- }),
39
- );
40
- assert.match(html, /aria-label="Move selected messages to Trash"/);
41
- assert.match(html, /aria-label="Move selected messages to Junk"/);
42
- assert.match(html, /aria-label="Mark as read"/);
43
- assert.match(html, /aria-label="Cancel selection"/);
44
- assert.match(html, /move-here/);
45
- assert.match(html, /Select similar messages/);
46
- assert.match(html, /Something else/);
47
- // Expanded: the body is live, not inert.
48
- assert.doesNotMatch(html, /inert=""/);
49
- });
50
-
51
- it("names the loaded scope and renders the select-all control", () => {
52
- const html = renderToString(
53
- createElement(SelectionSheet, {
54
- ...base,
55
- count: 47,
56
- startExpanded: true,
57
- selectAll: { checked: true, indeterminate: false, onChange: noop },
58
- }),
59
- );
60
- assert.match(html, /All 47 loaded selected/);
61
- assert.match(html, /aria-label="Select all"/);
62
- });
63
-
64
- it("counting replaces the quick actions with the status and a Stop", () => {
65
- const html = renderToString(
66
- createElement(SelectionSheet, {
67
- ...base,
68
- count: 0,
69
- mode: "counting",
70
- statusLabel: "Counting… 1,900 so far",
71
- notice: {
72
- tone: "info",
73
- text: "",
74
- action: { label: "Stop", onClick: noop },
75
- },
76
- }),
77
- );
78
- assert.match(html, /Counting… 1,900 so far/);
79
- assert.match(html, /Stop/);
80
- // No quick actions while counting.
81
- assert.doesNotMatch(html, /aria-label="Move selected messages to Trash"/);
82
- });
83
-
84
- it("running shows a progress bar and no quick actions", () => {
85
- const html = renderToString(
86
- createElement(SelectionSheet, {
87
- ...base,
88
- count: 3412,
89
- mode: "running",
90
- isBusy: true,
91
- statusLabel: "Deleting 1,200 of 3,412…",
92
- progress: { value: 1200, max: 3412 },
93
- }),
94
- );
95
- assert.match(html, /Deleting 1,200 of 3,412…/);
96
- assert.match(html, /role="progressbar"/);
97
- assert.doesNotMatch(html, /Select similar messages/);
98
- });
99
-
100
- it("escalated keeps the verbs and a clear-selection notice", () => {
101
- const html = renderToString(
102
- createElement(SelectionSheet, {
103
- ...base,
104
- count: 3412,
105
- mode: "escalated",
106
- startExpanded: true,
107
- statusLabel: 'All 3,412 matching "npm" selected',
108
- moveSlot: createElement("span", null, "move-here"),
109
- notice: {
110
- tone: "info",
111
- text: "",
112
- action: { label: "Clear selection", onClick: noop },
113
- },
114
- }),
115
- );
116
- assert.match(html, /All 3,412 matching/);
117
- assert.match(html, /aria-label="Move selected messages to Trash"/);
118
- assert.match(html, /Clear selection/);
119
- // Not a bounded selection, so no select-similar entry.
120
- assert.doesNotMatch(html, /Select similar messages/);
121
- });
122
-
123
- /**
124
- * jsdom lays nothing out, so these assert the constraint itself rather than a
125
- * measured height: the sheet must not carry a fixed pixel ceiling (which sat
126
- * below its own content at 411×759 and clipped the last action), and its body
127
- * must scroll rather than clip whatever the ceiling does cut off (#405).
128
- */
129
- it("caps the expanded height against the viewport, not a fixed pixel ceiling", () => {
130
- const html = renderToString(
131
- createElement(SelectionSheet, { ...base, startExpanded: true }),
132
- );
133
- assert.match(html, /max-height:70dvh/);
134
- assert.doesNotMatch(html, /max-height:min\(/);
135
- });
136
-
137
- it("scrolls the expanded actions instead of clipping them", () => {
138
- const html = renderToString(
139
- createElement(SelectionSheet, {
140
- ...base,
141
- startExpanded: true,
142
- onSelectSimilar: noop,
143
- onSomethingElse: noop,
144
- }),
145
- );
146
- assert.match(html, /class="flex min-h-0 flex-1 flex-col overflow-y-auto /);
147
- assert.doesNotMatch(
148
- html,
149
- /class="flex min-h-0 flex-1 flex-col overflow-hidden /,
150
- );
151
- });
152
-
153
- it("renders a partial-failure notice with a Retry action", () => {
154
- const html = renderToString(
155
- createElement(SelectionSheet, {
156
- ...base,
157
- count: 340,
158
- startExpanded: true,
159
- notice: {
160
- tone: "danger",
161
- text: "3,072 moved to Trash. 340 couldn't be deleted.",
162
- action: { label: "Retry 340", onClick: noop },
163
- },
164
- }),
165
- );
166
- assert.match(html, /3,072 moved to Trash/);
167
- assert.match(html, /Retry 340/);
168
- });
169
- });
@@ -1,179 +0,0 @@
1
- import type { Meta, StoryObj } from "@storybook/react-vite";
2
- import { FolderInput } from "lucide-react";
3
- import { SelectionSheet } from "./selection-sheet.js";
4
-
5
- const meta: Meta<typeof SelectionSheet> = {
6
- title: "Screens/Kit/SelectionSheet",
7
- component: SelectionSheet,
8
- parameters: { layout: "fullscreen" },
9
- args: {
10
- count: 3,
11
- onCancel: () => undefined,
12
- onDelete: () => undefined,
13
- onMarkRead: () => undefined,
14
- onJunk: () => undefined,
15
- onSelectSimilar: () => undefined,
16
- onSomethingElse: () => undefined,
17
- },
18
- decorators: [
19
- (Story) => (
20
- <div className="relative mx-auto h-dvh w-full shrink-0 overflow-hidden bg-surface sm:my-6 sm:h-[720px] sm:w-[390px] sm:rounded-[2rem] sm:border sm:border-line sm:shadow-sm">
21
- {/* Inbox backdrop, so the peeking sheet reads against a list. */}
22
- <div className="divide-y divide-line opacity-50">
23
- {Array.from({ length: 11 }).map((_, i) => (
24
- <div
25
- // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton rows
26
- key={i}
27
- className="flex items-start gap-3 px-row-inset py-2.5"
28
- >
29
- <div className="mt-0.5 size-7 shrink-0 rounded-full bg-surface-sunken" />
30
- <div className="min-w-0 flex-1 space-y-1">
31
- <div className="h-2.5 w-1/3 rounded bg-surface-sunken" />
32
- <div className="h-2 w-2/3 rounded bg-surface-sunken" />
33
- </div>
34
- </div>
35
- ))}
36
- </div>
37
- <Story />
38
- </div>
39
- ),
40
- ],
41
- };
42
- export default meta;
43
-
44
- type Story = StoryObj<typeof SelectionSheet>;
45
-
46
- /** Stand-in for the caller's move-to-folder trigger (an icon button that opens
47
- * a folder picker). The sheet only reserves the slot. */
48
- const MoveSlot = () => (
49
- <button
50
- type="button"
51
- aria-label="Move selected messages"
52
- className="inline-flex size-11 shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-raised"
53
- >
54
- <FolderInput className="size-4" />
55
- </button>
56
- );
57
-
58
- /** Collapsed — the slim ~56px teaser that rises at 2+ selected. */
59
- export const Teaser: Story = {
60
- args: { moveSlot: <MoveSlot /> },
61
- };
62
-
63
- /** Expanded — quick actions (Delete / Move / Junk) plus the select-similar and
64
- * "Something else" entries. */
65
- export const Expanded: Story = {
66
- args: { startExpanded: true, moveSlot: <MoveSlot /> },
67
- };
68
-
69
- /**
70
- * The same expanded sheet on the shortest phone viewport a real device reports
71
- * (411×759 — Android Chrome with its address bar and system nav showing). This
72
- * is the tallest the content ever gets: select-all, all three quick actions and
73
- * both smart-flow rows. A fixed height ceiling cut "Something else" off here
74
- * with no way to reach it (#405); the sheet now takes the height its content
75
- * needs, and scrolls only when the viewport genuinely can't hold it.
76
- */
77
- export const ExpandedShortViewport: Story = {
78
- globals: { viewport: { value: "mobileShort" } },
79
- args: {
80
- startExpanded: true,
81
- moveSlot: <MoveSlot />,
82
- selectAll: {
83
- checked: false,
84
- indeterminate: true,
85
- onChange: () => undefined,
86
- },
87
- },
88
- };
89
-
90
- /** While a search result set pages to its total: the count isn't known, the
91
- * quick actions are replaced by the running total and an explicit Stop. */
92
- export const Counting: Story = {
93
- args: {
94
- count: 0,
95
- mode: "counting",
96
- startExpanded: true,
97
- statusLabel: "Counting… 1,900 so far",
98
- selectAll: {
99
- checked: true,
100
- indeterminate: false,
101
- onChange: () => undefined,
102
- },
103
- notice: {
104
- tone: "info",
105
- text: "",
106
- action: { label: "Stop", onClick: () => undefined },
107
- },
108
- },
109
- };
110
-
111
- /** A bulk delete in progress — a running total and a determinate progress bar,
112
- * the delete busy, no quick actions to act mid-run. */
113
- export const RunningProgress: Story = {
114
- args: {
115
- count: 3412,
116
- mode: "running",
117
- startExpanded: true,
118
- isBusy: true,
119
- statusLabel: "Deleting 1,200 of 3,412…",
120
- progress: { value: 1200, max: 3412 },
121
- },
122
- };
123
-
124
- /**
125
- * Every loaded row checked and the search has more matches: the sheet offers to
126
- * escalate the selection to the whole result set.
127
- */
128
- export const EscalationAvailable: Story = {
129
- args: {
130
- count: 47,
131
- startExpanded: true,
132
- moveSlot: <MoveSlot />,
133
- selectAll: {
134
- checked: true,
135
- indeterminate: false,
136
- onChange: () => undefined,
137
- },
138
- notice: {
139
- tone: "info",
140
- text: "",
141
- action: {
142
- label: 'Select all matching "npm"',
143
- onClick: () => undefined,
144
- },
145
- },
146
- },
147
- };
148
-
149
- /** Selection escalated to the search predicate: the count names the query's
150
- * total, every verb still acts, and the notice offers a way back. */
151
- export const Escalated: Story = {
152
- args: {
153
- count: 3412,
154
- mode: "escalated",
155
- startExpanded: true,
156
- moveSlot: <MoveSlot />,
157
- statusLabel: 'All 3,412 matching "npm" selected',
158
- notice: {
159
- tone: "info",
160
- text: "",
161
- action: { label: "Clear selection", onClick: () => undefined },
162
- },
163
- },
164
- };
165
-
166
- /** After a bulk delete with some batches failed: the count reflects only what
167
- * is still selected — the failures — and Retry names how many. */
168
- export const PartialFailure: Story = {
169
- args: {
170
- count: 340,
171
- startExpanded: true,
172
- moveSlot: <MoveSlot />,
173
- notice: {
174
- tone: "danger",
175
- text: "3,072 moved to Trash. 340 couldn't be deleted.",
176
- action: { label: "Retry 340", onClick: () => undefined },
177
- },
178
- },
179
- };
@@ -1,88 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { resolveSheetSnap } from "./selection-sheet.js";
4
-
5
- const base = {
6
- expandedHeight: 320,
7
- teaserHeight: 56,
8
- flickVelocity: 0.5,
9
- };
10
-
11
- describe("resolveSheetSnap", () => {
12
- it("flicks up expand regardless of how far the drag travelled", () => {
13
- assert.equal(
14
- resolveSheetSnap({ ...base, expanded: false, delta: -4, velocity: -1.2 }),
15
- true,
16
- );
17
- });
18
-
19
- it("flicks down collapse regardless of how far the drag travelled", () => {
20
- assert.equal(
21
- resolveSheetSnap({ ...base, expanded: true, delta: 4, velocity: 1.2 }),
22
- false,
23
- );
24
- });
25
-
26
- it("a slow upward drag past the midpoint expands from the teaser", () => {
27
- // midpoint = (320 - 56) / 2 = 132; -140 crosses it going up.
28
- assert.equal(
29
- resolveSheetSnap({
30
- ...base,
31
- expanded: false,
32
- delta: -140,
33
- velocity: 0.1,
34
- }),
35
- true,
36
- );
37
- });
38
-
39
- it("a slow upward drag short of the midpoint settles back to the teaser", () => {
40
- assert.equal(
41
- resolveSheetSnap({
42
- ...base,
43
- expanded: false,
44
- delta: -100,
45
- velocity: 0.1,
46
- }),
47
- false,
48
- );
49
- });
50
-
51
- it("a slow downward drag past the midpoint collapses from expanded", () => {
52
- assert.equal(
53
- resolveSheetSnap({ ...base, expanded: true, delta: 140, velocity: 0.1 }),
54
- false,
55
- );
56
- });
57
-
58
- it("a slow downward drag short of the midpoint stays expanded", () => {
59
- assert.equal(
60
- resolveSheetSnap({ ...base, expanded: true, delta: 100, velocity: 0.1 }),
61
- true,
62
- );
63
- });
64
-
65
- it("a barely-moved release keeps the current snap state", () => {
66
- assert.equal(
67
- resolveSheetSnap({ ...base, expanded: true, delta: 0, velocity: 0 }),
68
- true,
69
- );
70
- assert.equal(
71
- resolveSheetSnap({ ...base, expanded: false, delta: 0, velocity: 0 }),
72
- false,
73
- );
74
- });
75
-
76
- it("defaults the flick threshold when omitted", () => {
77
- assert.equal(
78
- resolveSheetSnap({
79
- expanded: false,
80
- delta: -4,
81
- velocity: -1,
82
- expandedHeight: 320,
83
- teaserHeight: 56,
84
- }),
85
- true,
86
- );
87
- });
88
- });