@remit/ui 0.0.60 → 0.0.62
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 +1 -1
- package/src/components/blocked-reason.render.test.ts +44 -0
- package/src/components/blocked-reason.tsx +41 -0
- package/src/components/filter-rule.ts +10 -0
- package/src/components/mobile-search-view.stories.tsx +7 -7
- package/src/components/search-results.render.test.ts +4 -3
- package/src/components/search-results.stories.tsx +6 -3
- package/src/components/search-results.tsx +35 -25
- package/src/components/selection-top-bar.render.test.ts +6 -8
- package/src/components/selection-top-bar.stories.tsx +9 -24
- package/src/components/selection-top-bar.tsx +27 -24
- package/src/components/selection-wizard.render.test.ts +49 -9
- package/src/components/selection-wizard.tsx +138 -52
- package/src/index.ts +8 -11
- package/src/lib/search-rule.test.ts +22 -21
- package/src/lib/search-rule.ts +14 -32
- package/src/lib/wizard-steps.test.ts +37 -8
- package/src/lib/wizard-steps.ts +32 -8
- package/src/components/selection-sheet.render.test.ts +0 -169
- package/src/components/selection-sheet.stories.tsx +0 -179
- package/src/components/selection-sheet.test.ts +0 -88
- package/src/components/selection-sheet.tsx +0 -496
package/package.json
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
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 { BlockedReason } from "./blocked-reason.js";
|
|
6
|
+
|
|
7
|
+
const REASON = "Pick a destination first.";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Nothing disables (#477 1.7), so a dimmed control has to say what it is missing
|
|
11
|
+
* two ways: a description that is there for as long as the block is, and an
|
|
12
|
+
* announcement that happens when the control is pressed.
|
|
13
|
+
*
|
|
14
|
+
* They are separate elements because a live region announces what is written
|
|
15
|
+
* into it. One element that already holds the reason and gains `role="status"`
|
|
16
|
+
* on the press has nothing written into it, so nothing is announced — which is
|
|
17
|
+
* how a wizard comes to have a visual-only answer to "why can't I continue".
|
|
18
|
+
*/
|
|
19
|
+
describe("BlockedReason", () => {
|
|
20
|
+
it("describes the control before anything is pressed, silently", () => {
|
|
21
|
+
const html = renderToString(
|
|
22
|
+
createElement(BlockedReason, { id: "reason", reason: REASON }),
|
|
23
|
+
);
|
|
24
|
+
assert.match(html, /<p id="reason"[^>]*sr-only/);
|
|
25
|
+
assert.match(html, new RegExp(REASON));
|
|
26
|
+
// The live region is mounted and empty: there is nothing to announce yet.
|
|
27
|
+
assert.match(html, /role="status"[^>]*><\/span>/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("shows and announces the same reason once it has been pressed", () => {
|
|
31
|
+
const html = renderToString(
|
|
32
|
+
createElement(BlockedReason, {
|
|
33
|
+
id: "reason",
|
|
34
|
+
reason: REASON,
|
|
35
|
+
nudged: true,
|
|
36
|
+
className: "text-warning",
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
assert.doesNotMatch(html, /role="status"[^>]*><\/span>/);
|
|
40
|
+
assert.match(html, new RegExp(`role="status"[^>]*>${REASON}`));
|
|
41
|
+
assert.doesNotMatch(html, /<p id="reason"[^>]*sr-only/);
|
|
42
|
+
assert.match(html, /text-warning/);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
|
|
3
|
+
export interface BlockedReasonProps {
|
|
4
|
+
/** What `aria-describedby` on the dimmed control points at. */
|
|
5
|
+
id: string;
|
|
6
|
+
reason: string;
|
|
7
|
+
/** The control was pressed while blocked, so the reason comes on screen. */
|
|
8
|
+
nudged?: boolean;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What a dimmed control is still missing (#477 1.7). Nothing disables, so the
|
|
14
|
+
* reason has to reach the user two ways, and they are not the same element.
|
|
15
|
+
*
|
|
16
|
+
* The description carries the reason for as long as it applies, so anything
|
|
17
|
+
* reading the control through the accessibility tree finds it without pressing
|
|
18
|
+
* anything; the press is what unhides it.
|
|
19
|
+
*
|
|
20
|
+
* The announcement is a live region that is mounted with the block and empty
|
|
21
|
+
* until the press, because a live region announces what is written into it and
|
|
22
|
+
* not what it was already holding. Marking the description live at the moment
|
|
23
|
+
* it becomes visible announces nothing.
|
|
24
|
+
*/
|
|
25
|
+
export function BlockedReason({
|
|
26
|
+
id,
|
|
27
|
+
reason,
|
|
28
|
+
nudged,
|
|
29
|
+
className,
|
|
30
|
+
}: BlockedReasonProps) {
|
|
31
|
+
return (
|
|
32
|
+
<>
|
|
33
|
+
<p id={id} className={cn(className, !nudged && "sr-only")}>
|
|
34
|
+
{reason}
|
|
35
|
+
</p>
|
|
36
|
+
<span role="status" aria-live="polite" className="sr-only">
|
|
37
|
+
{nudged ? reason : ""}
|
|
38
|
+
</span>
|
|
39
|
+
</>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -261,6 +261,16 @@ export const ruleBlockedCopy = {
|
|
|
261
261
|
recounting: "Recounting matches — save once the count settles.",
|
|
262
262
|
} as const;
|
|
263
263
|
|
|
264
|
+
/**
|
|
265
|
+
* What the count region says when there is no count to be had. The vector-free
|
|
266
|
+
* matcher will not read message bodies, so a `HasWords` clause cannot be counted
|
|
267
|
+
* before it is saved — which is a different answer from a count of zero, and
|
|
268
|
+
* the only one that keeps an empty sample from reading as "this matches
|
|
269
|
+
* nothing".
|
|
270
|
+
*/
|
|
271
|
+
export const UNCOUNTABLE_PREDICATE_REASON =
|
|
272
|
+
"Can't count matches — “has the words” reads message bodies, which only a saved rule does.";
|
|
273
|
+
|
|
264
274
|
/**
|
|
265
275
|
* Why the count on screen is not yet the count that will be applied, or
|
|
266
276
|
* `undefined` once it has settled. This is what makes RFC 038's
|
|
@@ -186,7 +186,7 @@ function Harness({
|
|
|
186
186
|
sections,
|
|
187
187
|
preset,
|
|
188
188
|
scope,
|
|
189
|
-
|
|
189
|
+
makeFilterBlockedReason,
|
|
190
190
|
suggestions = [],
|
|
191
191
|
}: {
|
|
192
192
|
initialValue?: string;
|
|
@@ -196,7 +196,7 @@ function Harness({
|
|
|
196
196
|
preset: Preset;
|
|
197
197
|
scope?: SearchScope;
|
|
198
198
|
/** Renders the conversion inert with a reason; it is offered either way. */
|
|
199
|
-
|
|
199
|
+
makeFilterBlockedReason?: string;
|
|
200
200
|
/** Completions for the term being typed. */
|
|
201
201
|
suggestions?: Suggestion[];
|
|
202
202
|
}) {
|
|
@@ -282,7 +282,7 @@ function Harness({
|
|
|
282
282
|
scope={scope}
|
|
283
283
|
makeFilter={{
|
|
284
284
|
onClick: () => undefined,
|
|
285
|
-
|
|
285
|
+
blockedReason: makeFilterBlockedReason,
|
|
286
286
|
}}
|
|
287
287
|
suggest={{
|
|
288
288
|
comboboxProps: suggest.comboboxProps,
|
|
@@ -343,9 +343,9 @@ export const ScopedSearch: Story = {
|
|
|
343
343
|
};
|
|
344
344
|
|
|
345
345
|
/**
|
|
346
|
-
* A query with nothing a filter could match on: the conversion
|
|
347
|
-
*
|
|
348
|
-
*
|
|
346
|
+
* A query with nothing a filter could match on: the conversion stays offered and
|
|
347
|
+
* dimmed, rather than withheld and leaving the row to appear and vanish as the
|
|
348
|
+
* user types. Pressing it puts the reason on screen.
|
|
349
349
|
*/
|
|
350
350
|
export const NothingToConvert: Story = {
|
|
351
351
|
render: () => (
|
|
@@ -353,7 +353,7 @@ export const NothingToConvert: Story = {
|
|
|
353
353
|
initialValue="has:attachment"
|
|
354
354
|
sections={resultSections}
|
|
355
355
|
preset="inbox"
|
|
356
|
-
|
|
356
|
+
makeFilterBlockedReason="Add a sender or words to filter on"
|
|
357
357
|
/>
|
|
358
358
|
),
|
|
359
359
|
};
|
|
@@ -99,16 +99,17 @@ describe("SearchResults", () => {
|
|
|
99
99
|
assert.doesNotMatch(html, /disabled=""/);
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
-
it("
|
|
102
|
+
it("keeps the filter offer pressable when nothing converts, carrying its reason", () => {
|
|
103
103
|
const html = renderToString(
|
|
104
104
|
createElement(SearchResults, {
|
|
105
105
|
value: "has:attachment",
|
|
106
106
|
sections: [{ id: "results", label: "Results", results: [] }],
|
|
107
|
-
makeFilter: { onClick: noop,
|
|
107
|
+
makeFilter: { onClick: noop, blockedReason: "Add a sender or words" },
|
|
108
108
|
}),
|
|
109
109
|
);
|
|
110
110
|
assert.match(html, /Make this a filter/);
|
|
111
|
-
assert.
|
|
111
|
+
assert.doesNotMatch(html, /disabled/);
|
|
112
|
+
assert.match(html, /aria-describedby/);
|
|
112
113
|
assert.match(html, /Add a sender or words/);
|
|
113
114
|
});
|
|
114
115
|
|
|
@@ -190,15 +190,18 @@ export const WithMakeFilter: Story = {
|
|
|
190
190
|
),
|
|
191
191
|
};
|
|
192
192
|
|
|
193
|
-
/**
|
|
194
|
-
|
|
193
|
+
/**
|
|
194
|
+
* The filter offer dimmed — a search of only non-clause facets has nothing to
|
|
195
|
+
* convert. It stays pressable, and pressing it puts the reason on screen.
|
|
196
|
+
*/
|
|
197
|
+
export const MakeFilterBlocked: Story = {
|
|
195
198
|
render: () => (
|
|
196
199
|
<Harness
|
|
197
200
|
value="has:attachment"
|
|
198
201
|
sections={resultSections}
|
|
199
202
|
makeFilter={{
|
|
200
203
|
onClick: () => {},
|
|
201
|
-
|
|
204
|
+
blockedReason: "Add a sender or words to filter on",
|
|
202
205
|
}}
|
|
203
206
|
/>
|
|
204
207
|
),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ChevronDown, Clock, Filter } from "lucide-react";
|
|
2
|
-
import { useState } from "react";
|
|
2
|
+
import { useId, useState } from "react";
|
|
3
3
|
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { BlockedReason } from "./blocked-reason.js";
|
|
4
5
|
import type { FolderRole } from "./folder-role.js";
|
|
5
6
|
import { type SearchResult, SearchResultRow } from "./search-result-row.js";
|
|
6
7
|
import { SearchTokenChips } from "./search-token-chip.js";
|
|
@@ -86,21 +87,22 @@ export interface SearchResultsProps {
|
|
|
86
87
|
scope?: SearchScope;
|
|
87
88
|
/**
|
|
88
89
|
* "Make this a filter" (RFC 038 D5) — offered above the results while a query
|
|
89
|
-
* is active,
|
|
90
|
-
* affordance; a `
|
|
91
|
-
* only non-clause facets has nothing to convert).
|
|
90
|
+
* is active, opening the selection wizard on clauses derived from it. Omit to
|
|
91
|
+
* drop the affordance; a `blockedReason` dims it and states what is missing (a
|
|
92
|
+
* search of only non-clause facets has nothing to convert).
|
|
92
93
|
*/
|
|
93
94
|
makeFilter?: MakeFilterActionProps;
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
export interface MakeFilterActionProps {
|
|
97
98
|
onClick: () => void;
|
|
98
|
-
/**
|
|
99
|
-
|
|
99
|
+
/** What the query is still missing. Dims the action; never disables it. */
|
|
100
|
+
blockedReason?: string;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
/**
|
|
103
|
-
* "Make this a filter" — the
|
|
104
|
+
* "Make this a filter" — the wizard's second entry, offered while a search is
|
|
105
|
+
* active.
|
|
104
106
|
*
|
|
105
107
|
* A standalone row rather than a part of the results body, because a search is
|
|
106
108
|
* shown in more than one way: the read-only `SearchResults` panel, and a list
|
|
@@ -108,34 +110,47 @@ export interface MakeFilterActionProps {
|
|
|
108
110
|
* not to either rendering, so the caller mounts it above whichever body is up and
|
|
109
111
|
* it stays put when the body swaps. `SearchResults` renders it inline as a
|
|
110
112
|
* convenience for callers that show only the panel.
|
|
113
|
+
*
|
|
114
|
+
* Nothing disables (#477 1.7). A query with nothing to convert dims the action
|
|
115
|
+
* and leaves it pressable — pressing it is what brings the reason on screen,
|
|
116
|
+
* which `disabled` would take away along with the control itself.
|
|
111
117
|
*/
|
|
112
118
|
export function MakeFilterAction({
|
|
113
119
|
onClick,
|
|
114
|
-
|
|
120
|
+
blockedReason,
|
|
115
121
|
}: MakeFilterActionProps) {
|
|
116
|
-
const
|
|
122
|
+
const reasonId = useId();
|
|
123
|
+
const [nudged, setNudged] = useState(false);
|
|
117
124
|
return (
|
|
118
125
|
<div className="border-b border-line px-row-inset py-1.5">
|
|
119
126
|
<button
|
|
120
127
|
type="button"
|
|
121
|
-
onClick={
|
|
122
|
-
|
|
123
|
-
|
|
128
|
+
onClick={() => {
|
|
129
|
+
if (blockedReason) {
|
|
130
|
+
setNudged(true);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
onClick();
|
|
134
|
+
}}
|
|
135
|
+
aria-describedby={blockedReason ? reasonId : undefined}
|
|
124
136
|
className={cn(
|
|
125
137
|
"flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs font-medium transition-colors",
|
|
126
|
-
|
|
127
|
-
? "
|
|
138
|
+
blockedReason
|
|
139
|
+
? "text-fg-subtle opacity-55"
|
|
128
140
|
: "text-accent hover:bg-surface-sunken",
|
|
129
141
|
)}
|
|
130
142
|
>
|
|
131
143
|
<Filter className="size-3.5 shrink-0" aria-hidden="true" />
|
|
132
144
|
<span>Make this a filter</span>
|
|
133
|
-
{disabled && (
|
|
134
|
-
<span className="ml-auto truncate text-2xs font-normal text-fg-subtle">
|
|
135
|
-
{disabledReason}
|
|
136
|
-
</span>
|
|
137
|
-
)}
|
|
138
145
|
</button>
|
|
146
|
+
{blockedReason && (
|
|
147
|
+
<BlockedReason
|
|
148
|
+
id={reasonId}
|
|
149
|
+
reason={blockedReason}
|
|
150
|
+
nudged={nudged}
|
|
151
|
+
className="px-2 pt-1 text-2xs text-warning"
|
|
152
|
+
/>
|
|
153
|
+
)}
|
|
139
154
|
</div>
|
|
140
155
|
);
|
|
141
156
|
}
|
|
@@ -286,12 +301,7 @@ export function SearchResults({
|
|
|
286
301
|
const chips = tokens && tokens.length > 0 && (
|
|
287
302
|
<SearchTokenChips tokens={tokens} />
|
|
288
303
|
);
|
|
289
|
-
const filterAction = makeFilter &&
|
|
290
|
-
<MakeFilterAction
|
|
291
|
-
onClick={makeFilter.onClick}
|
|
292
|
-
disabledReason={makeFilter.disabledReason}
|
|
293
|
-
/>
|
|
294
|
-
);
|
|
304
|
+
const filterAction = makeFilter && <MakeFilterAction {...makeFilter} />;
|
|
295
305
|
|
|
296
306
|
if (loading) {
|
|
297
307
|
return (
|
|
@@ -109,16 +109,18 @@ describe("SelectionTopBar", () => {
|
|
|
109
109
|
);
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
-
it("
|
|
112
|
+
it("keeps Junk and Mark read in the overflow menu, never as glyphs", () => {
|
|
113
113
|
const html = renderToString(
|
|
114
114
|
createElement(SelectionTopBar, {
|
|
115
115
|
...handlers,
|
|
116
116
|
count: 2,
|
|
117
|
-
|
|
117
|
+
onJunk: () => undefined,
|
|
118
|
+
onMarkRead: () => undefined,
|
|
118
119
|
}),
|
|
119
120
|
);
|
|
120
121
|
assert.match(html, /aria-label="More actions"/);
|
|
121
|
-
assert.doesNotMatch(row(html, "actions"), /aria-label="
|
|
122
|
+
assert.doesNotMatch(row(html, "actions"), /aria-label="Junk"/);
|
|
123
|
+
assert.doesNotMatch(row(html, "actions"), /aria-label="Mark read"/);
|
|
122
124
|
});
|
|
123
125
|
|
|
124
126
|
it("replaces the title with the count from the first ticked row", () => {
|
|
@@ -150,11 +152,7 @@ describe("SelectionTopBar", () => {
|
|
|
150
152
|
count: 3,
|
|
151
153
|
selectAll,
|
|
152
154
|
onOrganize: () => undefined,
|
|
153
|
-
|
|
154
|
-
"button",
|
|
155
|
-
{ type: "button", "aria-label": "Move selected messages" },
|
|
156
|
-
"Move",
|
|
157
|
-
),
|
|
155
|
+
onMove: () => undefined,
|
|
158
156
|
}),
|
|
159
157
|
);
|
|
160
158
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import {
|
|
2
|
+
import { Menu, Search, Tag } from "lucide-react";
|
|
3
3
|
import { Button } from "./button.js";
|
|
4
4
|
import { PopoverMenu } from "./popover-menu.js";
|
|
5
5
|
import { SearchBar } from "./search-bar.js";
|
|
@@ -14,8 +14,8 @@ const meta: Meta<typeof SelectionTopBar> = {
|
|
|
14
14
|
onCancel: () => undefined,
|
|
15
15
|
onMarkRead: () => undefined,
|
|
16
16
|
onJunk: () => undefined,
|
|
17
|
+
onMove: () => undefined,
|
|
17
18
|
onOrganize: () => undefined,
|
|
18
|
-
onSomethingElse: () => undefined,
|
|
19
19
|
onDelete: () => undefined,
|
|
20
20
|
},
|
|
21
21
|
// Full viewport width — a fixed w-[390px] wrapper inside a 390px Storybook
|
|
@@ -30,18 +30,6 @@ export default meta;
|
|
|
30
30
|
|
|
31
31
|
type Story = StoryObj<typeof SelectionTopBar>;
|
|
32
32
|
|
|
33
|
-
/** Stand-in for the caller's move-to-folder trigger, which owns its own
|
|
34
|
-
* folder-picker data. The bar only reserves the slot. */
|
|
35
|
-
const MoveSlot = () => (
|
|
36
|
-
<button
|
|
37
|
-
type="button"
|
|
38
|
-
aria-label="Move selected messages"
|
|
39
|
-
className="inline-flex size-11 shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-raised"
|
|
40
|
-
>
|
|
41
|
-
<FolderInput className="size-5" />
|
|
42
|
-
</button>
|
|
43
|
-
);
|
|
44
|
-
|
|
45
33
|
/** Stand-in for the caller's apply-label picker, whose list belongs to the
|
|
46
34
|
* account. A worded row, so it reads as one of the menu's actions. */
|
|
47
35
|
const LabelSlot = () => (
|
|
@@ -147,16 +135,17 @@ export const IdleSearching: Story = {
|
|
|
147
135
|
/**
|
|
148
136
|
* One ticked row. The count takes the title's place and the header's own
|
|
149
137
|
* chrome stands down; the verbs arrive with it: Delete, Move and Organize
|
|
150
|
-
* carry a glyph, Junk
|
|
151
|
-
* Below 768px select-all takes a second row, so row one
|
|
138
|
+
* carry a glyph, Junk and Mark read live under the kebab. Every one of them
|
|
139
|
+
* opens the wizard. Below 768px select-all takes a second row, so row one
|
|
140
|
+
* stays a count and a
|
|
152
141
|
* row of verbs with a back arrow out of selection.
|
|
153
142
|
*/
|
|
154
143
|
export const One: Story = {
|
|
155
|
-
args: { ...chrome, count: 1, selectAll
|
|
144
|
+
args: { ...chrome, count: 1, selectAll },
|
|
156
145
|
};
|
|
157
146
|
|
|
158
147
|
export const Many: Story = {
|
|
159
|
-
args: { count: 3, selectAll
|
|
148
|
+
args: { count: 3, selectAll },
|
|
160
149
|
};
|
|
161
150
|
|
|
162
151
|
/**
|
|
@@ -167,7 +156,6 @@ export const WithLabelPicker: Story = {
|
|
|
167
156
|
args: {
|
|
168
157
|
count: 3,
|
|
169
158
|
selectAll,
|
|
170
|
-
moveSlot: <MoveSlot />,
|
|
171
159
|
overflowSlot: <LabelSlot />,
|
|
172
160
|
},
|
|
173
161
|
};
|
|
@@ -175,13 +163,13 @@ export const WithLabelPicker: Story = {
|
|
|
175
163
|
/** A selection spanning folders or accounts has no move target and no
|
|
176
164
|
* account to file a rule under: the bar carries Delete and the overflow. */
|
|
177
165
|
export const WithoutMoveOrOrganize: Story = {
|
|
178
|
-
args: { count: 2, onOrganize: undefined, selectAll },
|
|
166
|
+
args: { count: 2, onMove: undefined, onOrganize: undefined, selectAll },
|
|
179
167
|
};
|
|
180
168
|
|
|
181
169
|
/** A mutation in flight: Delete carries the spinner and every other verb
|
|
182
170
|
* stands down, because nothing else can act until it lands. */
|
|
183
171
|
export const Busy: Story = {
|
|
184
|
-
args: { count: 2, isBusy: true,
|
|
172
|
+
args: { count: 2, isBusy: true, selectAll },
|
|
185
173
|
};
|
|
186
174
|
|
|
187
175
|
export const CrossAccountHint: Story = {
|
|
@@ -204,7 +192,6 @@ export const CrossAccountHint: Story = {
|
|
|
204
192
|
export const AllSelected: Story = {
|
|
205
193
|
args: {
|
|
206
194
|
count: 47,
|
|
207
|
-
moveSlot: <MoveSlot />,
|
|
208
195
|
selectAll: {
|
|
209
196
|
checked: true,
|
|
210
197
|
indeterminate: false,
|
|
@@ -260,7 +247,6 @@ export const Escalated: Story = {
|
|
|
260
247
|
args: {
|
|
261
248
|
count: 3412,
|
|
262
249
|
statusLabel: 'All 3,412 matching "npm" selected',
|
|
263
|
-
moveSlot: <MoveSlot />,
|
|
264
250
|
notice: {
|
|
265
251
|
tone: "info",
|
|
266
252
|
text: "",
|
|
@@ -367,7 +353,6 @@ export const MarkingReadWithProgress: Story = {
|
|
|
367
353
|
export const PartialFailureMove: Story = {
|
|
368
354
|
args: {
|
|
369
355
|
count: 340,
|
|
370
|
-
moveSlot: <MoveSlot />,
|
|
371
356
|
notice: {
|
|
372
357
|
tone: "danger",
|
|
373
358
|
text: "3,072 moved. 340 couldn't be moved.",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ArrowLeft,
|
|
3
|
+
FolderInput,
|
|
3
4
|
Loader2,
|
|
4
5
|
MailOpen,
|
|
5
6
|
ShieldAlert,
|
|
6
7
|
Sparkles,
|
|
7
8
|
Trash2,
|
|
8
|
-
Wand2,
|
|
9
9
|
} from "lucide-react";
|
|
10
10
|
import type { ReactNode } from "react";
|
|
11
11
|
import { Banner, type BannerTone } from "./banner.js";
|
|
@@ -40,6 +40,17 @@ export interface SelectionTopBarProps {
|
|
|
40
40
|
/** Leaves selection, from the back arrow at the head of the bar. */
|
|
41
41
|
onCancel: () => void;
|
|
42
42
|
onDelete: () => void;
|
|
43
|
+
/**
|
|
44
|
+
* Opens the wizard on Move. Omitted where a destination cannot be resolved —
|
|
45
|
+
* a selection spanning accounts, or a surface with no owning mailbox.
|
|
46
|
+
*/
|
|
47
|
+
onMove?: () => void;
|
|
48
|
+
/**
|
|
49
|
+
* The Move verb for a selection the wizard cannot take: an escalated
|
|
50
|
+
* predicate, which no bounded list of ids stands in for, so it keeps the
|
|
51
|
+
* caller's own folder picker. Rendered only in `onMove`'s place.
|
|
52
|
+
*/
|
|
53
|
+
moveSlot?: ReactNode;
|
|
43
54
|
/** Opens Organize for the selection. Omitted where organize cannot be
|
|
44
55
|
* scoped to one account. */
|
|
45
56
|
onOrganize?: () => void;
|
|
@@ -48,12 +59,6 @@ export interface SelectionTopBarProps {
|
|
|
48
59
|
onJunk?: () => void;
|
|
49
60
|
/** Optional — dropped from the overflow menu while `isBusy`. */
|
|
50
61
|
onMarkRead?: () => void;
|
|
51
|
-
/**
|
|
52
|
-
* Opens the free-text organize panel. An overflow verb rather than a glyph:
|
|
53
|
-
* it collapses a verb, a destination and a scope into one tap, which is the
|
|
54
|
-
* opposite of what the bar's own verbs do.
|
|
55
|
-
*/
|
|
56
|
-
onSomethingElse?: () => void;
|
|
57
62
|
/**
|
|
58
63
|
* The list header's own chrome, rendered only while nothing is ticked: the
|
|
59
64
|
* nav button, the unread count beside the title, and the search affordance.
|
|
@@ -74,11 +79,6 @@ export interface SelectionTopBarProps {
|
|
|
74
79
|
* bar over, so the second way into the same wizard stands down.
|
|
75
80
|
*/
|
|
76
81
|
idleSlot?: ReactNode;
|
|
77
|
-
/**
|
|
78
|
-
* The Move verb. A render prop because the trigger owns the folder picker
|
|
79
|
-
* and its API dependencies; the bar only gives it a place in the verb row.
|
|
80
|
-
*/
|
|
81
|
-
moveSlot?: ReactNode;
|
|
82
82
|
/**
|
|
83
83
|
* Extra rows at the foot of the overflow menu — the apply-label picker,
|
|
84
84
|
* whose list belongs to the account rather than to the bar. Withdrawn while
|
|
@@ -171,16 +171,16 @@ export function SelectionTopBar({
|
|
|
171
171
|
count,
|
|
172
172
|
onCancel,
|
|
173
173
|
onDelete,
|
|
174
|
+
onMove,
|
|
175
|
+
moveSlot,
|
|
174
176
|
onOrganize,
|
|
175
177
|
onJunk,
|
|
176
178
|
onMarkRead,
|
|
177
|
-
onSomethingElse,
|
|
178
179
|
navSlot,
|
|
179
180
|
titleMeta,
|
|
180
181
|
searchSlot,
|
|
181
182
|
searchField,
|
|
182
183
|
idleSlot,
|
|
183
|
-
moveSlot,
|
|
184
184
|
overflowSlot,
|
|
185
185
|
isBusy = false,
|
|
186
186
|
isCounting = false,
|
|
@@ -211,15 +211,6 @@ export function SelectionTopBar({
|
|
|
211
211
|
onSelect: onMarkRead,
|
|
212
212
|
});
|
|
213
213
|
}
|
|
214
|
-
if (onSomethingElse && !isBusy && !isCounting) {
|
|
215
|
-
overflowItems.push({
|
|
216
|
-
key: "something-else",
|
|
217
|
-
label: "Something else",
|
|
218
|
-
icon: <Wand2 className="size-4" />,
|
|
219
|
-
onSelect: onSomethingElse,
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
|
|
223
214
|
return (
|
|
224
215
|
<header
|
|
225
216
|
data-selection-bar=""
|
|
@@ -287,7 +278,19 @@ export function SelectionTopBar({
|
|
|
287
278
|
aria-busy={isBusy || undefined}
|
|
288
279
|
className="shrink-0"
|
|
289
280
|
/>
|
|
290
|
-
{!isBusy &&
|
|
281
|
+
{!isBusy &&
|
|
282
|
+
(onMove ? (
|
|
283
|
+
<Button
|
|
284
|
+
variant="ghost"
|
|
285
|
+
size="touch"
|
|
286
|
+
icon={<FolderInput className="size-5" />}
|
|
287
|
+
onClick={onMove}
|
|
288
|
+
aria-label="Move selected messages"
|
|
289
|
+
className="shrink-0"
|
|
290
|
+
/>
|
|
291
|
+
) : (
|
|
292
|
+
moveSlot
|
|
293
|
+
))}
|
|
291
294
|
{!isBusy && onOrganize && (
|
|
292
295
|
<Button
|
|
293
296
|
variant="ghost"
|
|
@@ -4,7 +4,10 @@ import { createElement } from "react";
|
|
|
4
4
|
import { renderToString } from "react-dom/server";
|
|
5
5
|
import type { MatchCount, StepId, WizardDraft } from "../lib/wizard-steps.js";
|
|
6
6
|
import { stepsFor } from "../lib/wizard-steps.js";
|
|
7
|
-
import
|
|
7
|
+
import {
|
|
8
|
+
type RuleClause,
|
|
9
|
+
UNCOUNTABLE_PREDICATE_REASON,
|
|
10
|
+
} from "./filter-rule.js";
|
|
8
11
|
import {
|
|
9
12
|
FolderStepBody,
|
|
10
13
|
MatchStepBody,
|
|
@@ -172,6 +175,23 @@ describe("SelectionSample", () => {
|
|
|
172
175
|
assert.match(html, /Nothing matches this yet/);
|
|
173
176
|
});
|
|
174
177
|
|
|
178
|
+
// A count that could not be taken is not a count of zero. A body-text clause
|
|
179
|
+
// is the case that makes the difference load-bearing: it matches, once a
|
|
180
|
+
// saved rule is what runs it, so "nothing matches this yet" is wrong rather
|
|
181
|
+
// than merely unexplained (#477 3.5).
|
|
182
|
+
it("says a count could not be taken instead of that nothing matched", () => {
|
|
183
|
+
const html = renderToString(
|
|
184
|
+
createElement(SelectionSample, {
|
|
185
|
+
messages: [],
|
|
186
|
+
count: { status: "error", reason: UNCOUNTABLE_PREDICATE_REASON },
|
|
187
|
+
label: "What this matches",
|
|
188
|
+
emptyReason: "noMatch",
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
assert.match(text(html), /only a saved rule does/);
|
|
192
|
+
assert.doesNotMatch(html, /Nothing matches this yet/);
|
|
193
|
+
});
|
|
194
|
+
|
|
175
195
|
it("says the total is unknown when the match has not run", () => {
|
|
176
196
|
const html = renderToString(
|
|
177
197
|
createElement(SelectionSample, {
|
|
@@ -581,7 +601,7 @@ describe("SelectionWizard", () => {
|
|
|
581
601
|
wizardProps({ verb: "organize", steps, step: "properties" }),
|
|
582
602
|
),
|
|
583
603
|
);
|
|
584
|
-
assert.match(text(html), /Step 1 of
|
|
604
|
+
assert.match(text(html), /Step 1 of 5 · Apply to/);
|
|
585
605
|
assert.match(html, /What should this apply to\?/);
|
|
586
606
|
assert.match(html, /These 2 messages/);
|
|
587
607
|
assert.doesNotMatch(html, /Match properties/);
|
|
@@ -657,23 +677,43 @@ describe("SelectionWizard", () => {
|
|
|
657
677
|
}),
|
|
658
678
|
),
|
|
659
679
|
);
|
|
660
|
-
assert.match(html, /Add a property to match on\./);
|
|
661
|
-
assert.match(html, /
|
|
680
|
+
assert.match(text(html), /Add a property to match on\./);
|
|
681
|
+
assert.match(html, /opacity-55/);
|
|
682
|
+
// Nothing disables (#477 1.7), and `aria-disabled` disables it for
|
|
683
|
+
// everyone driving the page by anything other than a mouse.
|
|
684
|
+
assert.doesNotMatch(html, /aria-disabled/);
|
|
662
685
|
assert.doesNotMatch(html, /<button[^>]*disabled=""/);
|
|
663
686
|
});
|
|
664
687
|
|
|
665
|
-
it("
|
|
666
|
-
const
|
|
688
|
+
it("carries the blocked reason on the control before it is pressed, and announces it after", () => {
|
|
689
|
+
const reason = "Add a property to match on.";
|
|
690
|
+
const quiet = renderToString(
|
|
691
|
+
createElement(
|
|
692
|
+
SelectionWizard,
|
|
693
|
+
wizardProps({ step: "properties", blockedReason: reason }),
|
|
694
|
+
),
|
|
695
|
+
);
|
|
696
|
+
// Described, so it is heard on the control; not on screen and nothing
|
|
697
|
+
// announced until the control is pressed.
|
|
698
|
+
assert.match(quiet, /aria-describedby="/);
|
|
699
|
+
assert.match(quiet, /<p id="[^"]*"[^>]*sr-only/);
|
|
700
|
+
assert.match(quiet, /role="status"[^>]*><\/span>/);
|
|
701
|
+
|
|
702
|
+
const nudged = renderToString(
|
|
667
703
|
createElement(
|
|
668
704
|
SelectionWizard,
|
|
669
705
|
wizardProps({
|
|
670
706
|
step: "properties",
|
|
671
|
-
blockedReason:
|
|
707
|
+
blockedReason: reason,
|
|
708
|
+
nudged: true,
|
|
672
709
|
}),
|
|
673
710
|
),
|
|
674
711
|
);
|
|
675
|
-
|
|
676
|
-
|
|
712
|
+
// The reason is written into the live region, which is what a screen
|
|
713
|
+
// reader announces — marking the description live would announce nothing.
|
|
714
|
+
assert.doesNotMatch(nudged, /role="status"[^>]*><\/span>/);
|
|
715
|
+
assert.match(nudged, new RegExp(`role="status"[^>]*>${reason}`));
|
|
716
|
+
assert.doesNotMatch(nudged, /<p id="[^"]*"[^>]*sr-only/);
|
|
677
717
|
});
|
|
678
718
|
|
|
679
719
|
it("commits under the scope's own label, in the verb's own tone", () => {
|