@remit/ui 0.0.56 → 0.0.58

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.
Files changed (40) hide show
  1. package/package.json +2 -1
  2. package/src/components/app-shell-types.ts +8 -1
  3. package/src/components/auto-moved-badge.stories.tsx +3 -21
  4. package/src/components/auto-moved-badge.tsx +8 -17
  5. package/src/components/brief-empty.stories.tsx +51 -0
  6. package/src/components/brief-empty.tsx +60 -0
  7. package/src/components/dialog.tsx +10 -5
  8. package/src/components/filter-clause-chip.tsx +39 -1
  9. package/src/components/filter-rule-editor.stories.tsx +163 -1
  10. package/src/components/filter-rule-editor.tsx +47 -0
  11. package/src/components/filter-rule.render.test.ts +33 -0
  12. package/src/components/filter-rule.ts +141 -0
  13. package/src/components/input.tsx +7 -0
  14. package/src/components/mail-header.tsx +10 -0
  15. package/src/components/message-row.tsx +41 -3
  16. package/src/components/mobile-search-view.render.test.ts +31 -0
  17. package/src/components/mobile-search-view.stories.tsx +115 -6
  18. package/src/components/mobile-search-view.tsx +34 -8
  19. package/src/components/password-input.render.test.ts +149 -0
  20. package/src/components/password-input.tsx +45 -0
  21. package/src/components/primitives.stories.tsx +28 -0
  22. package/src/components/search-bar.tsx +9 -0
  23. package/src/components/search-chip-input.tsx +85 -3
  24. package/src/components/search-result-row.tsx +68 -82
  25. package/src/components/search-results.stories.tsx +12 -0
  26. package/src/components/search-results.tsx +20 -8
  27. package/src/components/suggest-list.render.test.ts +59 -0
  28. package/src/components/suggest-list.tsx +96 -0
  29. package/src/index.ts +55 -0
  30. package/src/lib/property-prefill.test.ts +173 -0
  31. package/src/lib/property-prefill.ts +151 -0
  32. package/src/lib/search-rule.test.ts +73 -0
  33. package/src/lib/search-rule.ts +96 -0
  34. package/src/lib/sender-derivation.test.ts +106 -0
  35. package/src/lib/sender-derivation.ts +85 -0
  36. package/src/lib/suggest-keys.test.ts +101 -0
  37. package/src/lib/suggest-keys.ts +63 -0
  38. package/src/lib/use-long-press.ts +70 -37
  39. package/src/lib/use-suggest-list.test.ts +169 -0
  40. package/src/lib/use-suggest-list.ts +124 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.56",
3
+ "version": "0.0.58",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -26,6 +26,7 @@
26
26
  "react-resizable-panels": "^2.1.9",
27
27
  "react-simple-pull-to-refresh": "^1.3.4",
28
28
  "tailwind-merge": "^2",
29
+ "tldts": "^7.4.9",
29
30
  "@types/dompurify": "*"
30
31
  },
31
32
  "peerDependencies": {
@@ -199,7 +199,14 @@ export interface ThreadRowLabel {
199
199
 
200
200
  export interface ThreadRowData {
201
201
  id: string;
202
- accountId: string;
202
+ /**
203
+ * Owning IMAP account — the `accountId` path parameter of the account API,
204
+ * never the caller's `accountConfigId`. Optional because rows from
205
+ * per-mailbox listings do not carry it; a required field forced producers to
206
+ * substitute the always-present `accountConfigId`, which 404s every
207
+ * `/accounts/{accountId}/…` call made with it.
208
+ */
209
+ accountId?: string;
203
210
  /** Owning mailbox — used by the `in:` search-token filter. */
204
211
  mailboxId?: string;
205
212
  fromName: string;
@@ -10,14 +10,9 @@ export default meta;
10
10
 
11
11
  type Story = StoryObj<typeof AutoMovedBadge>;
12
12
 
13
- export const ListRow: Story = {
14
- args: { label: "Moved from Junk by Remit", size: "sm" },
15
- };
16
-
17
- export const ReadingViewWithUndo: Story = {
13
+ export const WithUndo: Story = {
18
14
  args: {
19
15
  label: "Moved from Junk by Remit",
20
- size: "md",
21
16
  onUndo: () => alert("Undo"),
22
17
  },
23
18
  };
@@ -25,45 +20,32 @@ export const ReadingViewWithUndo: Story = {
25
20
  export const MovedToJunk: Story = {
26
21
  args: {
27
22
  label: "Moved from Inbox by Remit",
28
- size: "md",
29
23
  onUndo: () => alert("Undo"),
30
24
  },
31
25
  };
32
26
 
33
27
  export const WithoutUndoAction: Story = {
34
- args: { label: "Moved from Junk by Remit", size: "md" },
28
+ args: { label: "Moved from Junk by Remit" },
35
29
  };
36
30
 
37
31
  export const FilterMoveWithManageLink: Story = {
38
32
  args: {
39
33
  label: "Moved from Inbox by Remit",
40
- size: "md",
41
34
  onUndo: () => alert("Undo"),
42
35
  filtersHref: "/settings/filters",
43
36
  },
44
37
  };
45
38
 
46
- export const FilterMoveListRow: Story = {
47
- // The list row is icon + label only — the app gates the Manage link (and
48
- // Undo) to the reading-view `md` size, so no `filtersHref` here.
49
- args: {
50
- label: "Moved from Inbox by Remit",
51
- size: "sm",
52
- },
53
- };
54
-
55
39
  export const SideBySide: Story = {
56
40
  render: () => (
57
41
  <div className="flex flex-col items-start gap-3">
58
- <AutoMovedBadge label="Moved from Junk by Remit" size="sm" />
42
+ <AutoMovedBadge label="Moved from Junk by Remit" />
59
43
  <AutoMovedBadge
60
44
  label="Moved from Junk by Remit"
61
- size="md"
62
45
  onUndo={() => undefined}
63
46
  />
64
47
  <AutoMovedBadge
65
48
  label="Moved from Inbox by Remit"
66
- size="md"
67
49
  onUndo={() => undefined}
68
50
  filtersHref="/settings/filters"
69
51
  />
@@ -5,8 +5,6 @@ import { Badge } from "./badge.js";
5
5
  export interface AutoMovedBadgeProps {
6
6
  /** Plain-language description, e.g. "Moved from Junk by Remit". */
7
7
  label: string;
8
- /** `md` adds an inline Undo action for the open-message header; `sm` (list row) is icon + label only. */
9
- size?: "sm" | "md";
10
8
  /**
11
9
  * Present only when undo is available for this message. Omit to render the
12
10
  * indicator without an action (e.g. the target folder can't be resolved).
@@ -24,16 +22,15 @@ export interface AutoMovedBadgeProps {
24
22
  }
25
23
 
26
24
  /**
27
- * Unobtrusive indicator that Remit auto-moved this message, with an optional
28
- * inline one-click undo and, for a standing-filter move, a link to the filter
29
- * in Settings. Purely presentational the label text, whether the move is
30
- * still in effect (so the badge should render at all), and whether a filter
31
- * link applies are the caller's responsibility; this component has no notion of
32
- * placement/mailbox state.
25
+ * Indicator that Remit auto-moved this message, with an optional inline
26
+ * one-click undo and, for a standing-filter move, a link to the filter in
27
+ * Settings. Sized for a message header, not a list row. Purely presentational —
28
+ * the label text, whether the move is still in effect (so the badge should
29
+ * render at all), and whether a filter link applies are the caller's
30
+ * responsibility; this component has no notion of placement/mailbox state.
33
31
  */
34
32
  export function AutoMovedBadge({
35
33
  label,
36
- size = "sm",
37
34
  onUndo,
38
35
  undoLabel = "Undo",
39
36
  filtersHref,
@@ -41,14 +38,8 @@ export function AutoMovedBadge({
41
38
  className,
42
39
  }: AutoMovedBadgeProps) {
43
40
  return (
44
- <Badge
45
- tone="accent"
46
- className={cn(size === "md" && "py-1 text-xs", className)}
47
- >
48
- <Undo2
49
- className={cn(size === "sm" ? "size-3" : "size-3.5", "shrink-0")}
50
- aria-hidden
51
- />
41
+ <Badge tone="accent" className={cn("py-1 text-xs", className)}>
42
+ <Undo2 className="size-3.5 shrink-0" aria-hidden />
52
43
  <span>{label}</span>
53
44
  {onUndo && (
54
45
  <button
@@ -0,0 +1,51 @@
1
+ import type { Decorator, Meta, StoryObj } from "@storybook/react";
2
+ import { BriefEmpty } from "./brief-empty.js";
3
+
4
+ /** A list pane's width, so the copy wraps the way it does in the app. */
5
+ const paneFrame: Decorator = (Story) => (
6
+ <div className="h-screen w-full bg-surface">
7
+ <div className="mx-auto h-full max-w-md border-x border-line">
8
+ <Story />
9
+ </div>
10
+ </div>
11
+ );
12
+
13
+ const meta: Meta<typeof BriefEmpty> = {
14
+ title: "Screens/Kit/BriefEmpty",
15
+ component: BriefEmpty,
16
+ parameters: { layout: "fullscreen" },
17
+ decorators: [paneFrame],
18
+ };
19
+ export default meta;
20
+
21
+ type Story = StoryObj<typeof BriefEmpty>;
22
+
23
+ /**
24
+ * Nothing needs attention, and the app has everything it is going to get.
25
+ * The only state allowed to make that claim.
26
+ */
27
+ export const CaughtUp: Story = { args: {} };
28
+
29
+ /**
30
+ * The first sync is still running (#452). The list is empty because the mail
31
+ * has not arrived yet, so the brief says that instead of congratulating the
32
+ * user on an inbox it has never seen.
33
+ */
34
+ export const Syncing: Story = {
35
+ args: { sync: { synced: 812, total: 4210 } },
36
+ };
37
+
38
+ /**
39
+ * The first seconds of a sync: the server has not counted the mailbox yet, so
40
+ * there is no fraction to show. The state still reads as in-progress rather
41
+ * than falling back to "caught up".
42
+ */
43
+ export const SyncingCountUnknown: Story = {
44
+ args: { sync: { synced: 0, total: 0 } },
45
+ };
46
+
47
+ /** Phone width (411 px) — the copy is the whole state, so it must not clip. */
48
+ export const SyncingPhone: Story = {
49
+ globals: { viewport: { value: "mobile" } },
50
+ args: { sync: { synced: 1204, total: 18960 } },
51
+ };
@@ -0,0 +1,60 @@
1
+ import { Loader2, Sparkles } from "lucide-react";
2
+
3
+ /**
4
+ * How far the mail sync has got, for the accounts feeding the brief. `total` is
5
+ * 0 while the server has not counted the mailbox yet, which is a real state
6
+ * during the first seconds of a sync — not a missing number.
7
+ */
8
+ export interface BriefSyncProgress {
9
+ synced: number;
10
+ total: number;
11
+ }
12
+
13
+ export interface BriefEmptyProps {
14
+ /**
15
+ * Present only while at least one account reports an in-progress sync phase.
16
+ * Its presence is what separates "we have everything and there is nothing"
17
+ * from "we do not have it yet" — an empty brief looks identical either way,
18
+ * and saying "caught up" during the first sync is a false statement about
19
+ * the user's mail (#452).
20
+ */
21
+ sync?: BriefSyncProgress;
22
+ }
23
+
24
+ /**
25
+ * The brief with no sections. Two states, because an empty list has two
26
+ * causes and only one of them is good news.
27
+ */
28
+ export function BriefEmpty({ sync }: BriefEmptyProps) {
29
+ if (sync) {
30
+ return (
31
+ <div
32
+ className="flex h-full flex-col items-center justify-center gap-2 py-12 text-center px-4"
33
+ role="status"
34
+ aria-live="polite"
35
+ >
36
+ <Loader2
37
+ className="size-8 animate-spin text-fg-subtle"
38
+ aria-hidden="true"
39
+ />
40
+ <p className="text-sm font-medium text-fg">Still syncing your mail</p>
41
+ <p className="text-xs text-fg-subtle">{syncCopy(sync)}</p>
42
+ </div>
43
+ );
44
+ }
45
+
46
+ return (
47
+ <div className="flex h-full flex-col items-center justify-center gap-2 py-12 text-center px-4">
48
+ <Sparkles className="size-8 text-fg-subtle" aria-hidden="true" />
49
+ <p className="text-sm font-medium text-fg">You're caught up</p>
50
+ <p className="text-xs text-fg-subtle">Nothing needs attention.</p>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ function syncCopy({ synced, total }: BriefSyncProgress): string {
56
+ if (total > 0) {
57
+ return `${synced.toLocaleString()} of ${total.toLocaleString()} messages downloaded. This list fills in as they arrive.`;
58
+ }
59
+ return "Downloading your messages. This list fills in as they arrive.";
60
+ }
@@ -31,11 +31,16 @@ export function Dialog({
31
31
 
32
32
  const handleKeyDown = useCallback(
33
33
  (e: KeyboardEvent) => {
34
- if (e.key === "Escape") {
35
- e.preventDefault();
36
- e.stopImmediatePropagation();
37
- onClose();
38
- }
34
+ if (e.key !== "Escape") return;
35
+ // A control inside the dialog can own Escape while it has something of
36
+ // its own to close — an open suggestion list. Escape closes that first;
37
+ // the next Escape closes the dialog.
38
+ const focused = document.activeElement;
39
+ if (focused instanceof Element && focused.closest("[data-escape-owner]"))
40
+ return;
41
+ e.preventDefault();
42
+ e.stopImmediatePropagation();
43
+ onClose();
39
44
  },
40
45
  [onClose],
41
46
  );
@@ -1,5 +1,6 @@
1
1
  import { Plus, Sparkles, X } from "lucide-react";
2
2
  import { cn } from "../lib/cn.js";
3
+ import { useSuggestList } from "../lib/use-suggest-list.js";
3
4
  import { Button } from "./button.js";
4
5
  import {
5
6
  type ClauseField,
@@ -12,6 +13,7 @@ import {
12
13
  } from "./filter-rule.js";
13
14
  import { Input } from "./input.js";
14
15
  import { Select } from "./select.js";
16
+ import { type Suggestion, SuggestList } from "./suggest-list.js";
15
17
 
16
18
  const chipShell =
17
19
  "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs";
@@ -153,6 +155,13 @@ export interface ClauseEditorProps {
153
155
  * match, so the editor never offers a clause the backend cannot evaluate.
154
156
  */
155
157
  fields?: ClauseField[];
158
+ /**
159
+ * Values worth offering for the field being edited — known senders for a
160
+ * `From` clause, their domains for a `FromDomain` one. A shortcut and never a
161
+ * constraint: whatever is typed stands, matches or not, and a field with
162
+ * nothing to offer is a plain text box.
163
+ */
164
+ suggestions?: readonly Suggestion[];
156
165
  onChangeField?: (field: ClauseField) => void;
157
166
  onChangeValue?: (value: string) => void;
158
167
  onSubmit?: () => void;
@@ -163,17 +172,26 @@ export interface ClauseEditorProps {
163
172
  * Inline form for adding or editing one clause — field picker plus a value
164
173
  * input, from the design-system primitives so it never drifts from the rest of
165
174
  * the editor. The chip it produces renders through {@link ClauseChip}.
175
+ *
176
+ * The suggestion list sits under the row in normal flow rather than floating
177
+ * over it, so on a phone a soft keyboard can never hide the field the list
178
+ * belongs to.
166
179
  */
167
180
  export function ClauseEditor({
168
181
  draft,
169
182
  mode,
170
183
  fields = clauseFieldOrder,
184
+ suggestions = [],
171
185
  onChangeField,
172
186
  onChangeValue,
173
187
  onSubmit,
174
188
  onCancel,
175
189
  }: ClauseEditorProps) {
176
190
  const hint = clauseFieldHint(draft.field);
191
+ const suggest = useSuggestList({
192
+ count: suggestions.length,
193
+ onAccept: (index) => onChangeValue?.(suggestions[index].value),
194
+ });
177
195
  return (
178
196
  <div className="space-y-1.5 rounded-lg border border-accent-2 bg-surface p-2">
179
197
  <div className="flex flex-wrap items-center gap-2">
@@ -193,11 +211,17 @@ export function ClauseEditor({
193
211
  aria-label="Clause value"
194
212
  value={draft.value}
195
213
  placeholder="value…"
196
- onChange={(e) => onChangeValue?.(e.target.value)}
214
+ autoComplete="off"
215
+ onChange={(e) => {
216
+ suggest.reopen();
217
+ onChangeValue?.(e.target.value);
218
+ }}
197
219
  onKeyDown={(e) => {
220
+ if (suggest.handleKeyDown(e)) return;
198
221
  if (e.key === "Enter") onSubmit?.();
199
222
  }}
200
223
  className="h-8 min-w-40 flex-1"
224
+ {...suggest.comboboxProps}
201
225
  />
202
226
  <Button
203
227
  variant="primary"
@@ -217,6 +241,20 @@ export function ClauseEditor({
217
241
  className="shrink-0 px-2"
218
242
  />
219
243
  </div>
244
+ {suggest.open && (
245
+ <SuggestList
246
+ id={suggest.listId}
247
+ suggestions={suggestions}
248
+ activeIndex={suggest.activeIndex}
249
+ optionId={suggest.optionId}
250
+ onPick={(suggestion) => {
251
+ onChangeValue?.(suggestion.value);
252
+ suggest.dismiss();
253
+ }}
254
+ onHighlight={suggest.setActiveIndex}
255
+ label={`${clauseFieldLabel(draft.field)} suggestions`}
256
+ />
257
+ )}
220
258
  {hint && <p className="px-0.5 text-2xs text-fg-subtle">{hint}</p>}
221
259
  </div>
222
260
  );
@@ -5,16 +5,19 @@ import { FilterPreviewCount } from "./filter-preview-count.js";
5
5
  import {
6
6
  type ClauseField,
7
7
  clauseFieldLabel,
8
+ demoClauseSuggestions,
8
9
  demoFolders,
9
10
  demoLabels,
10
11
  demoRule,
11
12
  demoSenderFallbackRule,
13
+ demoSubjectPrefillRule,
12
14
  demoVocabularyRule,
13
15
  type FilterRule,
14
16
  type FolderOption,
15
17
  type LabelOption,
16
18
  type PreviewCount,
17
19
  type RuleClause,
20
+ type RuleMatchMode,
18
21
  } from "./filter-rule.js";
19
22
  import {
20
23
  type ClauseEditState,
@@ -53,12 +56,22 @@ const READY = (count: number, stale?: boolean): PreviewCount => ({
53
56
  function LiveEditor({
54
57
  initialRule,
55
58
  semanticAvailable = true,
59
+ initialMatchMode,
60
+ propertyRule,
56
61
  labels = demoLabels,
62
+ initialClauseEdit,
57
63
  onCreateFolder,
58
64
  onCreateLabel,
59
65
  }: {
60
66
  initialRule: FilterRule;
61
67
  semanticAvailable?: boolean;
68
+ /** Offers the match-mode control; omit to render the editor without one. */
69
+ initialMatchMode?: RuleMatchMode;
70
+ /** Opens on the clause form, for the stories about editing a clause. */
71
+ initialClauseEdit?: ClauseEditState;
72
+ /** What the properties mode prefills — the app derives this from the
73
+ * selection's senders and subjects. */
74
+ propertyRule?: FilterRule;
62
75
  labels?: LabelOption[];
63
76
  onCreateFolder?: (
64
77
  name: string,
@@ -67,7 +80,10 @@ function LiveEditor({
67
80
  onCreateLabel?: (name: string) => Promise<LabelOption>;
68
81
  }) {
69
82
  const [rule, setRule] = useState<FilterRule>(initialRule);
70
- const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>();
83
+ const [matchMode, setMatchMode] = useState(initialMatchMode);
84
+ const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>(
85
+ initialClauseEdit,
86
+ );
71
87
 
72
88
  const preview = useMemo<PreviewCount>(
73
89
  () => READY(rule.clauses.length * 23 + (rule.widen ? 40 : 0)),
@@ -130,6 +146,26 @@ function LiveEditor({
130
146
  onChangeMatchOperator: (matchOperator) => {
131
147
  setRule((r) => ({ ...r, matchOperator }));
132
148
  },
149
+ onChangeMatchMode: (mode) => {
150
+ setMatchMode(mode);
151
+ // The app rebuilds only the matchers, keeping the action and scope; the
152
+ // story does the same with its two fixture rules.
153
+ setRule((r) =>
154
+ mode === "properties"
155
+ ? {
156
+ ...r,
157
+ clauses: (propertyRule ?? demoSenderFallbackRule).clauses,
158
+ matchOperator: "any",
159
+ widen: undefined,
160
+ }
161
+ : {
162
+ ...r,
163
+ clauses: initialRule.clauses,
164
+ matchOperator: initialRule.matchOperator,
165
+ widen: initialRule.widen ?? { anchorCount: 2 },
166
+ },
167
+ );
168
+ },
133
169
  onChangeMove: (moveMailboxId) =>
134
170
  setRule((r) => ({ ...r, moveMailboxId: moveMailboxId || undefined })),
135
171
  onChangeLabel: (labelId) =>
@@ -146,7 +182,16 @@ function LiveEditor({
146
182
  labels={labels}
147
183
  preview={preview}
148
184
  semanticAvailable={semanticAvailable}
185
+ matchMode={matchMode}
149
186
  clauseEdit={clauseEdit}
187
+ clauseSuggestions={
188
+ clauseEdit
189
+ ? demoClauseSuggestions(
190
+ clauseEdit.draft.field,
191
+ clauseEdit.draft.value,
192
+ )
193
+ : undefined
194
+ }
150
195
  onCreateFolder={onCreateFolder}
151
196
  onCreateLabel={onCreateLabel}
152
197
  onCommit={() => {}}
@@ -161,6 +206,123 @@ export const Interactive: Story = {
161
206
  render: () => <LiveEditor initialRule={demoRule} />,
162
207
  };
163
208
 
209
+ /**
210
+ * The Organize surface, where the rule can be matched either way. It opens on
211
+ * "Anything similar" — semantic is still the default — and switching to
212
+ * "Its properties" drops the widen for the clauses derived from the messages
213
+ * that were selected. Switch back and the widen returns.
214
+ */
215
+ export const MatchModeSemanticDefault: Story = {
216
+ render: () => (
217
+ <LiveEditor
218
+ initialRule={{ ...demoRule, clauses: [] }}
219
+ initialMatchMode="similar"
220
+ propertyRule={demoSenderFallbackRule}
221
+ />
222
+ ),
223
+ };
224
+
225
+ /**
226
+ * Properties matching with one sender behind the whole selection — a single
227
+ * `From` chip, no semantics involved. Editable like any other chip.
228
+ */
229
+ export const MatchOnSenderProperty: Story = {
230
+ render: () => (
231
+ <LiveEditor
232
+ initialRule={{
233
+ ...demoSenderFallbackRule,
234
+ clauses: demoSenderFallbackRule.clauses.slice(0, 1),
235
+ scope: "once",
236
+ name: "",
237
+ }}
238
+ initialMatchMode="properties"
239
+ propertyRule={demoSenderFallbackRule}
240
+ />
241
+ ),
242
+ };
243
+
244
+ /**
245
+ * Properties matching when the senders differ: the prefill falls back to the
246
+ * part the selected subjects share, so the rule still starts somewhere useful.
247
+ */
248
+ export const MatchOnSubjectProperty: Story = {
249
+ render: () => (
250
+ <LiveEditor
251
+ initialRule={demoSubjectPrefillRule}
252
+ initialMatchMode="properties"
253
+ propertyRule={demoSubjectPrefillRule}
254
+ />
255
+ ),
256
+ };
257
+
258
+ /**
259
+ * Adding a `From` clause, with the value field offering what it knows: the
260
+ * senders of the messages that were selected lead the list — marked "selected",
261
+ * and there before a key is pressed — followed by other known addresses. Typing
262
+ * narrows it; anything typed still stands, match or no match.
263
+ */
264
+ export const ClauseValueSuggestions: Story = {
265
+ render: () => (
266
+ <LiveEditor
267
+ initialRule={{ ...demoRule, widen: undefined }}
268
+ semanticAvailable={false}
269
+ initialClauseEdit={{ mode: "add", draft: { field: "From", value: "" } }}
270
+ />
271
+ ),
272
+ };
273
+
274
+ /**
275
+ * The same field on a `Domain` clause: the addresses collapse to registrable
276
+ * domains, so the value offered is exactly the string the matcher compares.
277
+ */
278
+ export const DomainClauseSuggestions: Story = {
279
+ render: () => (
280
+ <LiveEditor
281
+ initialRule={{ ...demoRule, widen: undefined }}
282
+ semanticAvailable={false}
283
+ initialClauseEdit={{
284
+ mode: "add",
285
+ draft: { field: "FromDomain", value: "" },
286
+ }}
287
+ />
288
+ ),
289
+ };
290
+
291
+ /**
292
+ * Typing something no known sender matches. The list simply goes away — no
293
+ * empty panel, no warning, no block on the value. A clause for an address that
294
+ * has not written yet is a legitimate rule.
295
+ */
296
+ export const ClauseValueNoMatches: Story = {
297
+ render: () => (
298
+ <LiveEditor
299
+ initialRule={{ ...demoRule, widen: undefined }}
300
+ semanticAvailable={false}
301
+ initialClauseEdit={{
302
+ mode: "add",
303
+ draft: { field: "From", value: "someone@nowhere.test" },
304
+ }}
305
+ />
306
+ ),
307
+ };
308
+
309
+ /**
310
+ * A free-text field is left alone: `Subject` has nothing to draw suggestions
311
+ * from, so it stays the plain box it was.
312
+ */
313
+ export const SubjectClauseStaysFreeText: Story = {
314
+ render: () => (
315
+ <LiveEditor
316
+ initialRule={{ ...demoRule, widen: undefined }}
317
+ semanticAvailable={false}
318
+ initialClauseEdit={{
319
+ mode: "add",
320
+ draft: { field: "Subject", value: "receipt" },
321
+ }}
322
+ />
323
+ ),
324
+ };
325
+
164
326
  let newFolderSeq = 0;
165
327
  const mockCreateFolder = (name: string): Promise<FolderOption> =>
166
328
  new Promise((resolve) => {