@remit/ui 0.0.61 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.61",
3
+ "version": "0.0.62",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -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
- makeFilterDisabledReason,
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
- makeFilterDisabledReason?: string;
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
- disabledReason: makeFilterDisabledReason,
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 is offered inert
347
- * with the reason, rather than withheld and leaving the row to appear and vanish
348
- * as the user types.
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
- makeFilterDisabledReason="Add a sender or words to filter on"
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("disables the filter offer with its reason when nothing converts", () => {
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, disabledReason: "Add a sender or words" },
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.match(html, /disabled/);
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
- /** The filter offer disabled — a search of only non-clause facets has nothing to convert. */
194
- export const MakeFilterDisabled: Story = {
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
- disabledReason: "Add a sender or words to filter on",
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, converting the search to a pre-filled rule. Omit to drop the
90
- * affordance; a `disabledReason` renders it inert with the reason (a search of
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
- /** Renders the action inert and states why, e.g. nothing in the query converts. */
99
- disabledReason?: string;
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 conversion entry offered while a search is active.
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
- disabledReason,
120
+ blockedReason,
115
121
  }: MakeFilterActionProps) {
116
- const disabled = disabledReason !== undefined;
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={onClick}
122
- disabled={disabled}
123
- title={disabledReason}
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
- disabled
127
- ? "cursor-not-allowed text-fg-subtle"
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 (
@@ -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 type { RuleClause } from "./filter-rule.js";
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, {
@@ -665,33 +685,35 @@ describe("SelectionWizard", () => {
665
685
  assert.doesNotMatch(html, /<button[^>]*disabled=""/);
666
686
  });
667
687
 
668
- it("carries the blocked reason on the control before it is pressed, and shows it after", () => {
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.";
669
690
  const quiet = renderToString(
670
691
  createElement(
671
692
  SelectionWizard,
672
- wizardProps({
673
- step: "properties",
674
- blockedReason: "Add a property to match on.",
675
- }),
693
+ wizardProps({ step: "properties", blockedReason: reason }),
676
694
  ),
677
695
  );
678
- // Described, so it is heard on the control; not on screen until pressed.
696
+ // Described, so it is heard on the control; not on screen and nothing
697
+ // announced until the control is pressed.
679
698
  assert.match(quiet, /aria-describedby="/);
680
- assert.match(quiet, /sr-only/);
681
- assert.doesNotMatch(quiet, /role="status"/);
699
+ assert.match(quiet, /<p id="[^"]*"[^>]*sr-only/);
700
+ assert.match(quiet, /role="status"[^>]*><\/span>/);
682
701
 
683
702
  const nudged = renderToString(
684
703
  createElement(
685
704
  SelectionWizard,
686
705
  wizardProps({
687
706
  step: "properties",
688
- blockedReason: "Add a property to match on.",
707
+ blockedReason: reason,
689
708
  nudged: true,
690
709
  }),
691
710
  ),
692
711
  );
693
- assert.match(nudged, /role="status"/);
694
- assert.doesNotMatch(nudged, /sr-only/);
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/);
695
717
  });
696
718
 
697
719
  it("commits under the scope's own label, in the verb's own tone", () => {
@@ -42,6 +42,7 @@ import {
42
42
  type WizardDraft,
43
43
  } from "../lib/wizard-steps.js";
44
44
  import { Badge } from "./badge.js";
45
+ import { BlockedReason } from "./blocked-reason.js";
45
46
  import { Button } from "./button.js";
46
47
  import { FieldLabel } from "./field-label.js";
47
48
  import {
@@ -279,6 +280,25 @@ const sampleFooter = (count: MatchCount, shown: number): string => {
279
280
  return previewCountSummary(count);
280
281
  };
281
282
 
283
+ /**
284
+ * What an empty sample says. A count that could not be taken says so; only a
285
+ * count that came back empty may say nothing matches (#477 3.5).
286
+ *
287
+ * A body-text clause is the case that makes the difference load-bearing: the
288
+ * vector-free matcher refuses to evaluate it, so there is no count and no rows,
289
+ * and "nothing matches this yet" is not merely unexplained but wrong — the rule
290
+ * matches, once a saved rule is what runs it.
291
+ */
292
+ const sampleEmptyLine = (
293
+ count: MatchCount,
294
+ emptyReason: SampleEmptyReason | undefined,
295
+ loading: boolean | undefined,
296
+ ): string => {
297
+ if (loading) return "Fetching the messages this covers…";
298
+ if (count.status === "error") return count.reason;
299
+ return sampleEmptyCopy(emptyReason ?? "noMatch");
300
+ };
301
+
282
302
  /**
283
303
  * The members of the match, closing every screen that names one. A named match
284
304
  * with no members shown is an unseen bulk action, so the rows scroll in their own
@@ -298,9 +318,7 @@ export function SelectionSample({
298
318
  </h2>
299
319
  {messages.length === 0 ? (
300
320
  <p className="px-3 py-4 text-xs text-fg-muted">
301
- {loading
302
- ? "Fetching the messages this covers…"
303
- : sampleEmptyCopy(emptyReason ?? "noMatch")}
321
+ {sampleEmptyLine(count, emptyReason, loading)}
304
322
  </p>
305
323
  ) : (
306
324
  <>
@@ -345,9 +363,8 @@ export interface FooterNavProps {
345
363
  /**
346
364
  * Nothing disables (#477 1.7). A Continue with an answer still missing is dimmed
347
365
  * and stays pressable — and stays pressable to assistive technology too, which
348
- * `aria-disabled` would have taken away along with the reason. The reason is on
349
- * the control the whole time it applies, through `aria-describedby`; pressing it
350
- * is what brings the reason on screen.
366
+ * `aria-disabled` would have taken away along with the reason. `BlockedReason`
367
+ * carries the two ways that reason reaches the user.
351
368
  */
352
369
  export function FooterNav({
353
370
  backLabel = "Back",
@@ -362,13 +379,12 @@ export function FooterNav({
362
379
  return (
363
380
  <div className="space-y-2">
364
381
  {blockedReason && (
365
- <p
382
+ <BlockedReason
366
383
  id={reasonId}
367
- role={nudged ? "status" : undefined}
368
- className={cn("px-1 text-2xs text-warning", !nudged && "sr-only")}
369
- >
370
- {blockedReason}
371
- </p>
384
+ reason={blockedReason}
385
+ nudged={nudged}
386
+ className="px-1 text-2xs text-warning"
387
+ />
372
388
  )}
373
389
  <div className="flex items-center gap-3">
374
390
  <Button
package/src/index.ts CHANGED
@@ -67,6 +67,10 @@ export {
67
67
  type BannerTone,
68
68
  type BannerVariant,
69
69
  } from "./components/banner.js";
70
+ export {
71
+ BlockedReason,
72
+ type BlockedReasonProps,
73
+ } from "./components/blocked-reason.js";
70
74
  export {
71
75
  BottomSheet,
72
76
  type BottomSheetProps,
@@ -174,6 +178,7 @@ export {
174
178
  type RuleWiden,
175
179
  ruleBlockedCopy,
176
180
  scopeLabel,
181
+ UNCOUNTABLE_PREDICATE_REASON,
177
182
  unreadableBodyClauses,
178
183
  widenChipLabel,
179
184
  } from "./components/filter-rule.js";
@@ -628,11 +633,11 @@ export {
628
633
  } from "./lib/roving-focus.js";
629
634
  export { type RuleNameParts, suggestRuleName } from "./lib/rule-name.js";
630
635
  export {
631
- buildSearchRule,
632
636
  type DroppedFacet,
633
637
  type DroppedFacetType,
634
638
  isConvertible,
635
639
  type SearchConversion,
640
+ searchConversionNotice,
636
641
  } from "./lib/search-rule.js";
637
642
  export {
638
643
  collapsibleDomain,
@@ -1,9 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import {
4
- buildSearchRule,
5
4
  isConvertible,
6
5
  type SearchConversion,
6
+ searchConversionNotice,
7
7
  } from "./search-rule.js";
8
8
 
9
9
  const conversion = (
@@ -43,31 +43,32 @@ describe("isConvertible", () => {
43
43
  });
44
44
  });
45
45
 
46
- describe("buildSearchRule", () => {
47
- it("builds a standing rule with stable clause ids, no widen, empty name", () => {
48
- const rule = buildSearchRule(
46
+ describe("searchConversionNotice", () => {
47
+ it("names the folder the search was scoped to and every facet dropped", () => {
48
+ const notice = searchConversionNotice(
49
49
  conversion({
50
- clauses: [
51
- { field: "From", value: "a@b.com" },
52
- { field: "HasWords", value: "nightly" },
53
- ],
50
+ clauses: [{ field: "HasWords", value: "npm" }],
51
+ keptTerms: true,
52
+ scopedOut: { mailboxId: "mbx-archive", label: "Archive" },
53
+ droppedFacets: [{ type: "isUnread", label: "Unread" }],
54
+ droppedSemantic: true,
54
55
  }),
55
56
  );
56
- assert.equal(rule.scope, "standing");
57
- assert.equal(rule.widen, undefined);
58
- assert.equal(rule.name, "");
59
- assert.deepEqual(
60
- rule.clauses.map((clause) => clause.id),
61
- ["search-0", "search-1"],
62
- );
57
+ assert.deepEqual(notice, {
58
+ scopedOutFolder: "Archive",
59
+ droppedFacets: ["Unread"],
60
+ droppedSemantic: true,
61
+ });
63
62
  });
64
63
 
65
- it("honors an explicit scope and move target", () => {
66
- const rule = buildSearchRule(
67
- conversion({ clauses: [{ field: "HasWords", value: "nightly" }] }),
68
- { scope: "once", moveMailboxId: "mbx-archive" },
64
+ it("states nothing for a conversion that carried everything", () => {
65
+ const notice = searchConversionNotice(
66
+ conversion({ clauses: [{ field: "From", value: "a@b.com" }] }),
69
67
  );
70
- assert.equal(rule.scope, "once");
71
- assert.equal(rule.moveMailboxId, "mbx-archive");
68
+ assert.deepEqual(notice, {
69
+ scopedOutFolder: undefined,
70
+ droppedFacets: [],
71
+ droppedSemantic: false,
72
+ });
72
73
  });
73
74
  });
@@ -1,16 +1,12 @@
1
1
  /**
2
- * What a search converts to, and the rule built from it (RFC 038 D5). The shape
3
- * carries the clauses alongside everything the search held that a filter cannot,
4
- * so a conversion can never drop a facet without saying so; `search-conversion.ts`
5
- * beside it owns the copy that states it.
2
+ * What a search converts to (RFC 038 D5). The shape carries the clauses
3
+ * alongside everything the search held that a filter cannot, so a conversion can
4
+ * never drop a facet without saying so; `search-conversion.ts` beside it owns
5
+ * the copy that states it.
6
6
  */
7
7
 
8
- import type {
9
- ClauseField,
10
- FilterRule,
11
- MatchOperator,
12
- RuleScope,
13
- } from "../components/filter-rule.js";
8
+ import type { ClauseField, MatchOperator } from "../components/filter-rule.js";
9
+ import type { SearchConversionNotice } from "../components/search-conversion.js";
14
10
 
15
11
  /**
16
12
  * A search facet a filter has no clause for. Attachment, read state, starred,
@@ -68,29 +64,15 @@ export interface SearchConversion {
68
64
  export const isConvertible = (conversion: SearchConversion): boolean =>
69
65
  conversion.clauses.length > 0;
70
66
 
71
- interface BuildRuleOptions {
72
- scope?: RuleScope;
73
- moveMailboxId?: string;
74
- }
75
-
76
67
  /**
77
- * The rule the editor opens on, from a conversion. A search-derived rule defaults
78
- * to a standing filter "make this a filter" is a request to keep applying it —
79
- * and the editor lets the user drop it back to a one-time apply. It carries no
80
- * widen: a free-text query has no message anchor, so the semantic chip is not
81
- * offered on this surface (its loss is stated in the conversion notice instead).
68
+ * The conversion as the notice reads it. One mapping, beside both shapes, so
69
+ * every surface that opens on a converted search states the same losses in the
70
+ * same words.
82
71
  */
83
- export const buildSearchRule = (
72
+ export const searchConversionNotice = (
84
73
  conversion: SearchConversion,
85
- { scope = "standing", moveMailboxId }: BuildRuleOptions = {},
86
- ): FilterRule => ({
87
- clauses: conversion.clauses.map((clause, index) => ({
88
- id: `search-${index}`,
89
- field: clause.field,
90
- value: clause.value,
91
- })),
92
- matchOperator: conversion.matchOperator,
93
- moveMailboxId,
94
- scope,
95
- name: "",
74
+ ): SearchConversionNotice => ({
75
+ scopedOutFolder: conversion.scopedOut?.label,
76
+ droppedFacets: conversion.droppedFacets.map((facet) => facet.label),
77
+ droppedSemantic: conversion.droppedSemantic,
96
78
  });