@remit/ui 0.0.12 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -308,6 +308,13 @@ export interface AppShellProps {
308
308
  * fits or when the message view is showing.
309
309
  */
310
310
  initialTouchState?: TouchSeed;
311
+ /**
312
+ * Replaces the narrow-width list pane header when a selection is active —
313
+ * forwarded to `MessageListPane`'s `selectionBar` slot. The caller owns
314
+ * selection state and toolbar actions; when omitted, `initialTouchState`
315
+ * still drives the pane's own built-in touch-triage bar.
316
+ */
317
+ selectionBar?: ReactNode;
311
318
  intelligence?: IntelligenceData;
312
319
  /** Pane 4 visible. Defaults to true when intelligence is present. */
313
320
  intelligenceOpen?: boolean;
@@ -300,9 +300,7 @@ describe("AppShell touch-state seeds (story/SSR affordance)", () => {
300
300
  /aria-label="Cancel selection"/,
301
301
  "the selection bar is shown",
302
302
  );
303
- // SSR splits interpolated text with comment markers, so match the parts.
304
- assert.match(html, /messages<!-- --> selected/, "selection wording shown");
305
- assert.match(html, />2<!-- -->/, "the seeded count of 2 is shown");
303
+ assert.match(html, /2 messages selected/, "selection wording shown");
306
304
  // Selection mode is not a swipe — no action zones revealed.
307
305
  assert.equal(
308
306
  count(html, trailingAction),
@@ -345,6 +343,23 @@ describe("AppShell touch-state seeds (story/SSR affordance)", () => {
345
343
  );
346
344
  });
347
345
 
346
+ it("selectionBar overrides the built-in bar and forwards to the list pane", () => {
347
+ const html = render({
348
+ ...touchBase,
349
+ selectionBar: createElement(
350
+ "div",
351
+ { "data-testid": "custom-bar" },
352
+ "Deleting 1,200 of 3,412…",
353
+ ),
354
+ });
355
+ assert.match(html, /Deleting 1,200 of 3,412…/, "the override renders");
356
+ assert.doesNotMatch(
357
+ html,
358
+ /aria-label="Cancel selection"/,
359
+ "the built-in bar is not also rendered",
360
+ );
361
+ });
362
+
348
363
  it("ignores the touch seed at/above 1024 (desktop list)", () => {
349
364
  const html = render({
350
365
  ...touchBase,
@@ -70,6 +70,7 @@ export function AppShell({
70
70
  thread,
71
71
  initialNarrowView = "list",
72
72
  initialTouchState,
73
+ selectionBar,
73
74
  intelligence,
74
75
  intelligenceOpen = true,
75
76
  density,
@@ -126,6 +127,7 @@ export function AppShell({
126
127
  }}
127
128
  onSelectBriefCategory={selectCategory}
128
129
  initialTouchState={initialTouchState}
130
+ selectionBar={selectionBar}
129
131
  />
130
132
  );
131
133
 
@@ -185,6 +187,7 @@ function AppShellList({
185
187
  onSelectThread,
186
188
  onSelectBriefCategory,
187
189
  initialTouchState,
190
+ selectionBar,
188
191
  }: Pick<
189
192
  AppShellProps,
190
193
  | "thread"
@@ -202,6 +205,7 @@ function AppShellList({
202
205
  | "density"
203
206
  | "onSelectThread"
204
207
  | "initialTouchState"
208
+ | "selectionBar"
205
209
  > & {
206
210
  narrowView: NarrowView;
207
211
  onBackToList: () => void;
@@ -244,6 +248,7 @@ function AppShellList({
244
248
  onOpenNav={showNavPane ? undefined : layout?.openNav}
245
249
  isDesktop={showReadingPane}
246
250
  initialTouchState={initialTouchState}
251
+ selectionBar={selectionBar}
247
252
  />
248
253
  );
249
254
  }
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { isVirtualFolderRole, provenanceFolderLabel } from "./folder-role.js";
4
+
5
+ describe("provenanceFolderLabel", () => {
6
+ it("names an appointed role by its canonical label", () => {
7
+ assert.equal(provenanceFolderLabel({ role: "archive" }), "Archive");
8
+ assert.equal(provenanceFolderLabel({ role: "sent" }), "Sent");
9
+ });
10
+
11
+ it("reads a junk appointment as Spam whatever the server calls it", () => {
12
+ assert.equal(
13
+ provenanceFolderLabel({ role: "junk", providerPath: "Bulk Mail" }),
14
+ "Spam",
15
+ );
16
+ });
17
+
18
+ it("falls back to the leaf of a folder nobody appointed", () => {
19
+ assert.equal(
20
+ provenanceFolderLabel({ providerPath: "Projects/Bookkeeping" }),
21
+ "Bookkeeping",
22
+ );
23
+ });
24
+
25
+ it("refuses to label a view rather than a place", () => {
26
+ assert.equal(provenanceFolderLabel({ role: "all" }), undefined);
27
+ assert.equal(provenanceFolderLabel({ role: "flagged" }), undefined);
28
+ });
29
+
30
+ it("refuses to label Gmail's own reserved namespace", () => {
31
+ assert.equal(
32
+ provenanceFolderLabel({ providerPath: "[Gmail]/All Mail" }),
33
+ undefined,
34
+ );
35
+ assert.equal(
36
+ provenanceFolderLabel({ providerPath: "[Gmail]/Starred" }),
37
+ undefined,
38
+ );
39
+ });
40
+
41
+ it("refuses the googlemail.com spelling of the same namespace", () => {
42
+ assert.equal(
43
+ provenanceFolderLabel({ providerPath: "[Google Mail]/All Mail" }),
44
+ undefined,
45
+ );
46
+ assert.equal(
47
+ provenanceFolderLabel({ providerPath: "[Google Mail]/Starred" }),
48
+ undefined,
49
+ );
50
+ });
51
+
52
+ it("labels a user folder that merely mentions Gmail", () => {
53
+ assert.equal(provenanceFolderLabel({ providerPath: "Gmail" }), "Gmail");
54
+ });
55
+
56
+ it("has nothing to say about a folder it knows nothing about", () => {
57
+ assert.equal(provenanceFolderLabel({}), undefined);
58
+ });
59
+ });
60
+
61
+ describe("isVirtualFolderRole", () => {
62
+ it("counts All Mail and Starred as views", () => {
63
+ assert.equal(isVirtualFolderRole("all"), true);
64
+ assert.equal(isVirtualFolderRole("flagged"), true);
65
+ });
66
+
67
+ it("counts real folders as places", () => {
68
+ assert.equal(isVirtualFolderRole("inbox"), false);
69
+ assert.equal(isVirtualFolderRole("junk"), false);
70
+ assert.equal(isVirtualFolderRole("trash"), false);
71
+ });
72
+ });
@@ -54,6 +54,65 @@ export function providerLeaf(providerPath: string): string {
54
54
  return parts[parts.length - 1] || providerPath;
55
55
  }
56
56
 
57
+ /* ------------------------------------------------------------------ */
58
+ /* Provenance: where a search result actually lives */
59
+ /* ------------------------------------------------------------------ */
60
+
61
+ /**
62
+ * The folder a search result was read from. `role` is the account's IMAP
63
+ * special-use appointment (`junk` is `\Junk`); accounts that expose a folder
64
+ * nobody appointed carry only a `providerPath`.
65
+ */
66
+ export interface ResultFolder {
67
+ role?: FolderRole;
68
+ /** Provider path as the server spells it, e.g. `Projects/Bookkeeping`. */
69
+ providerPath?: string;
70
+ }
71
+
72
+ /**
73
+ * Roles that name a view rather than a place a message is filed. A message in
74
+ * All Mail or Starred is also somewhere real, so labelling a result with one of
75
+ * these says nothing about where it came from.
76
+ */
77
+ const VIRTUAL_ROLES: ReadonlySet<FolderRole> = new Set(["all", "flagged"]);
78
+
79
+ export function isVirtualFolderRole(role: FolderRole): boolean {
80
+ return VIRTUAL_ROLES.has(role);
81
+ }
82
+
83
+ /**
84
+ * Gmail exposes its views as ordinary folders under a reserved namespace. An
85
+ * account that appointed no role to them leaves the path as the only signal, so
86
+ * the namespace is matched by name — the one place a name is the honest test,
87
+ * because it is the provider's own reserved prefix and not a user's folder.
88
+ *
89
+ * Accounts provisioned under googlemail.com get the same namespace spelled
90
+ * `[Google Mail]`, so both forms count. A user folder plainly called `Gmail`
91
+ * does not — the brackets are what make the prefix reserved.
92
+ */
93
+ const GMAIL_NAMESPACES: ReadonlySet<string> = new Set([
94
+ "[Gmail]",
95
+ "[Google Mail]",
96
+ ]);
97
+
98
+ /**
99
+ * Label for the folder a result came from, or `undefined` when that folder is a
100
+ * view rather than a place — in which case no label is better than a misleading
101
+ * one. An appointed role wins over the provider's spelling, so a folder the
102
+ * account calls `Junk` still reads as "Spam".
103
+ */
104
+ export function provenanceFolderLabel(
105
+ folder: ResultFolder,
106
+ ): string | undefined {
107
+ if (folder.role) {
108
+ if (isVirtualFolderRole(folder.role)) return undefined;
109
+ return canonicalRoleLabel(folder.role);
110
+ }
111
+ if (!folder.providerPath) return undefined;
112
+ if (GMAIL_NAMESPACES.has(folder.providerPath.split("/")[0])) return undefined;
113
+ return providerLeaf(folder.providerPath);
114
+ }
115
+
57
116
  export function roleIcon(role: FolderRole): ReactNode {
58
117
  if (role === "inbox") return <Inbox className="size-4" />;
59
118
  if (role === "drafts") return <FileText className="size-4" />;
@@ -212,8 +212,15 @@ export const CustomListBody: Story = {
212
212
  decorators: [desktopFrame],
213
213
  };
214
214
 
215
- /** External `selectionBar` slot — the pane delegates the header to the caller
216
- * when a selection is active. */
215
+ /**
216
+ * External `selectionBar` slot mechanism, exercised at desktop width with
217
+ * `SelectionTopBar` as a convenient stand-in node — any slot content works
218
+ * here, the point is that the pane header is replaced. This is NOT a
219
+ * production composition: the live desktop toolbar is `SelectionToolbar`
220
+ * (web-client only, not in this kit); `MessageList.tsx` only ever puts
221
+ * `SelectionTopBar` in this slot when `!isDesktop` — see `NarrowExternalSelectionBar`
222
+ * below for that production-accurate case.
223
+ */
217
224
  export const ExternalSelectionBar: Story = {
218
225
  args: {
219
226
  isDesktop: true,
@@ -230,6 +237,27 @@ export const ExternalSelectionBar: Story = {
230
237
  decorators: [desktopFrame],
231
238
  };
232
239
 
240
+ /**
241
+ * The production composition (`MessageList.tsx:798` gates on `!isDesktop`):
242
+ * `SelectionTopBar` in the `selectionBar` slot at narrow width. Desktop never
243
+ * renders this component in the slot — only `NarrowTouchList`'s width does.
244
+ */
245
+ export const NarrowExternalSelectionBar: Story = {
246
+ args: {
247
+ isDesktop: false,
248
+ flatList: true,
249
+ selectionBar: (
250
+ <SelectionTopBar
251
+ count={2}
252
+ onCancel={() => undefined}
253
+ onMarkRead={() => undefined}
254
+ onDelete={() => undefined}
255
+ />
256
+ ),
257
+ },
258
+ decorators: [narrowFrame],
259
+ };
260
+
233
261
  /** Fail-loud error state — the specific failure detail is surfaced under the
234
262
  * headline (not a bare "something went wrong"), with a way back (Retry) and a
235
263
  * place for the failure to go (Report a problem). */
@@ -0,0 +1,78 @@
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 { MobileSearchView } from "./mobile-search-view.js";
6
+ import type { SearchResult } from "./search-result-row.js";
7
+
8
+ const noop = () => {};
9
+
10
+ const archived: SearchResult = {
11
+ id: "a1",
12
+ sender: "Mollie",
13
+ subject: "Invoice 2026-02",
14
+ snippet: "Payment already settled.",
15
+ date: "Feb 24",
16
+ folder: { role: "archive" },
17
+ };
18
+
19
+ const spam: SearchResult = {
20
+ id: "s1",
21
+ sender: "billing@unknown-vendor.test",
22
+ subject: "URGENT invoice attached",
23
+ snippet: "Wire the amount below.",
24
+ date: "Feb 11",
25
+ folder: { role: "junk" },
26
+ };
27
+
28
+ const sections = [
29
+ { id: "top", label: "Top matches", results: [archived, spam] },
30
+ ];
31
+
32
+ const base = {
33
+ value: "invoice",
34
+ onChange: noop,
35
+ onClear: noop,
36
+ onCancel: noop,
37
+ sections,
38
+ };
39
+
40
+ describe("MobileSearchView search scope", () => {
41
+ it("offers held-out spam on the phone tier too", () => {
42
+ const html = renderToString(
43
+ createElement(MobileSearchView, {
44
+ ...base,
45
+ scope: { kind: "global" as const },
46
+ onScopeToSpam: noop,
47
+ }),
48
+ );
49
+ assert.doesNotMatch(html, /unknown-vendor/);
50
+ assert.match(html, /result from Spam/);
51
+ assert.match(html, /Archive/);
52
+ });
53
+
54
+ it("shows neither spam nor provenance labels when scoped", () => {
55
+ const html = renderToString(
56
+ createElement(MobileSearchView, {
57
+ ...base,
58
+ scope: { kind: "folder" as const, role: "inbox" as const },
59
+ onScopeToSpam: noop,
60
+ }),
61
+ );
62
+ assert.doesNotMatch(html, /unknown-vendor/);
63
+ assert.doesNotMatch(html, /from Spam/);
64
+ assert.doesNotMatch(html, /Archive/);
65
+ });
66
+
67
+ it("passes a caller-supplied spam total through", () => {
68
+ const html = renderToString(
69
+ createElement(MobileSearchView, {
70
+ ...base,
71
+ scope: { kind: "global" as const },
72
+ spamMatchCount: 42,
73
+ onScopeToSpam: noop,
74
+ }),
75
+ );
76
+ assert.match(html, />42</);
77
+ });
78
+ });
@@ -8,7 +8,7 @@ import {
8
8
  import { MobileSearchView } from "./mobile-search-view.js";
9
9
  import type { SearchChip } from "./search-chip-input.js";
10
10
  import type { SearchResult } from "./search-result-row.js";
11
- import type { SearchResultSection } from "./search-results.js";
11
+ import type { SearchResultSection, SearchScope } from "./search-results.js";
12
12
 
13
13
  const phoneFrame: Decorator = (Story) => (
14
14
  <div
@@ -97,6 +97,56 @@ const emptySections: SearchResultSection[] = [
97
97
  { id: "related", label: "Related", results: [] },
98
98
  ];
99
99
 
100
+ /** Matches spread across ordinary folders, each carrying where it was read from. */
101
+ const crossFolderMatches: SearchResult[] = [
102
+ { ...topMatches[0], folder: { role: "inbox" } },
103
+ { ...topMatches[1], folder: { role: "inbox" } },
104
+ {
105
+ id: "x1",
106
+ sender: "Mollie",
107
+ subject: "Invoice 2026-02 — archived",
108
+ snippet: "Filed last month; payment already settled.",
109
+ date: "Feb 24",
110
+ folder: { role: "archive" },
111
+ },
112
+ {
113
+ id: "x2",
114
+ sender: "Accountant",
115
+ subject: "Invoices for the quarter",
116
+ snippet: "The quarterly set, filed with the rest of the bookkeeping.",
117
+ date: "Jan 30",
118
+ folder: { providerPath: "Projects/Bookkeeping" },
119
+ },
120
+ ];
121
+
122
+ /** Matches in the account's `\Junk` folder. */
123
+ const spamMatches: SearchResult[] = [
124
+ {
125
+ id: "s1",
126
+ sender: "billing@unknown-vendor.test",
127
+ subject: "URGENT invoice attached",
128
+ snippet: "Wire the amount below within 24 hours to avoid suspension.",
129
+ date: "Feb 11",
130
+ folder: { role: "junk" },
131
+ },
132
+ {
133
+ id: "s2",
134
+ sender: "invoices@pay-now.test",
135
+ subject: "Outstanding invoice — final notice",
136
+ snippet: "Your account is overdue. Settle immediately.",
137
+ date: "Feb 4",
138
+ folder: { role: "junk" },
139
+ },
140
+ ];
141
+
142
+ const acrossFoldersSections: SearchResultSection[] = [
143
+ {
144
+ id: "top",
145
+ label: "Top matches",
146
+ results: [...crossFolderMatches, ...spamMatches],
147
+ },
148
+ ];
149
+
100
150
  type Preset = "brief" | "inbox";
101
151
 
102
152
  function Harness({
@@ -105,12 +155,18 @@ function Harness({
105
155
  loading,
106
156
  sections,
107
157
  preset,
158
+ scope,
159
+ spamMatchCount,
160
+ onScopeToSpam,
108
161
  }: {
109
162
  initialValue?: string;
110
163
  initialChips?: SearchChip[];
111
164
  loading?: boolean;
112
165
  sections?: SearchResultSection[];
113
166
  preset: Preset;
167
+ scope?: SearchScope;
168
+ spamMatchCount?: number;
169
+ onScopeToSpam?: () => void;
114
170
  }) {
115
171
  const [value, setValue] = useState(initialValue);
116
172
  const [chips, setChips] = useState<SearchChip[]>(initialChips);
@@ -183,6 +239,9 @@ function Harness({
183
239
  sections={sections}
184
240
  loading={loading}
185
241
  onSelectResult={setOpened}
242
+ scope={scope}
243
+ spamMatchCount={spamMatchCount}
244
+ onScopeToSpam={onScopeToSpam}
186
245
  />
187
246
  );
188
247
  }
@@ -257,14 +316,70 @@ export const RelatedSelectable: Story = {
257
316
  * top bar uses, inside the full-screen takeover's own chrome. The chip is
258
317
  * removable in place — backspace at the start of the text reaches it just as it
259
318
  * does on desktop.
319
+ *
320
+ * The chip and the scope say the same thing, which is the point: an `in:spam`
321
+ * chip is what a Spam-scoped search looks like in the bar.
260
322
  */
261
323
  export const ScopedByChip: Story = {
262
324
  render: () => (
263
325
  <Harness
264
326
  initialValue="invoice"
265
327
  initialChips={[{ id: "in:spam", label: "in:spam" }]}
266
- sections={resultSections}
328
+ sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
329
+ scope={{ kind: "folder", role: "junk" }}
330
+ preset="inbox"
331
+ />
332
+ ),
333
+ };
334
+
335
+ /**
336
+ * Global search on the phone, holding spam out and offering it above the
337
+ * results — the same treatment the desktop list pane gives it, because both
338
+ * tiers render the one `SearchResults` body. Rows name the folder they came
339
+ * from; the two spam matches in the same data are not among them.
340
+ */
341
+ export const GlobalAcrossFolders: Story = {
342
+ render: () => (
343
+ <Harness
344
+ initialValue="invoice"
345
+ sections={acrossFoldersSections}
346
+ scope={{ kind: "global" }}
347
+ onScopeToSpam={() => {}}
348
+ preset="brief"
349
+ />
350
+ ),
351
+ };
352
+
353
+ /**
354
+ * The same rows scoped to the inbox. No spam, no count, no offer, and no
355
+ * provenance labels — the chip in the bar already says where the search is
356
+ * looking.
357
+ */
358
+ export const ScopedToInbox: Story = {
359
+ render: () => (
360
+ <Harness
361
+ initialValue="invoice"
362
+ initialChips={[{ id: "in:inbox", label: "in:inbox" }]}
363
+ sections={acrossFoldersSections}
364
+ scope={{ kind: "folder", role: "inbox" }}
365
+ onScopeToSpam={() => {}}
267
366
  preset="inbox"
268
367
  />
269
368
  ),
270
369
  };
370
+
371
+ /**
372
+ * A global phone search whose only matches are in Spam: the offer stands above
373
+ * the empty state rather than leaving the search looking fruitless.
374
+ */
375
+ export const GlobalOnlySpamMatches: Story = {
376
+ render: () => (
377
+ <Harness
378
+ initialValue="invoice"
379
+ sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
380
+ scope={{ kind: "global" }}
381
+ onScopeToSpam={() => {}}
382
+ preset="brief"
383
+ />
384
+ ),
385
+ };
@@ -4,7 +4,11 @@ import { FilterSheet, type FilterSheetProps } from "./filter-sheet.js";
4
4
  import { SearchBar } from "./search-bar.js";
5
5
  import type { SearchChip } from "./search-chip-input.js";
6
6
  import type { SearchResult } from "./search-result-row.js";
7
- import { type SearchResultSection, SearchResults } from "./search-results.js";
7
+ import {
8
+ type SearchResultSection,
9
+ SearchResults,
10
+ type SearchScope,
11
+ } from "./search-results.js";
8
12
 
9
13
  export interface MobileSearchViewProps {
10
14
  value: string;
@@ -40,6 +44,12 @@ export interface MobileSearchViewProps {
40
44
  */
41
45
  chips?: readonly SearchChip[];
42
46
  onRemoveChip?: (id: string) => void;
47
+ /** What the search covers; see `SearchResultsProps`. Defaults to global. */
48
+ scope?: SearchScope;
49
+ /** Total spam matches found; see `SearchResultsProps`. */
50
+ spamMatchCount?: number;
51
+ /** Scope the search to Spam; see `SearchResultsProps`. */
52
+ onScopeToSpam?: () => void;
43
53
  }
44
54
 
45
55
  /**
@@ -52,6 +62,9 @@ export interface MobileSearchViewProps {
52
62
  * the inboxes use) so search carries identical filters; pass no `filter` to drop
53
63
  * the chrome. Desktop reuses the same `SearchResults` body in the list pane.
54
64
  * Presentational and prop-driven.
65
+ *
66
+ * Search scope passes straight through, so the phone tier holds spam out,
67
+ * offers it and labels provenance on exactly the same terms as desktop.
55
68
  */
56
69
  export function MobileSearchView({
57
70
  value,
@@ -67,6 +80,9 @@ export function MobileSearchView({
67
80
  tokens,
68
81
  chips,
69
82
  onRemoveChip,
83
+ scope,
84
+ spamMatchCount,
85
+ onScopeToSpam,
70
86
  }: MobileSearchViewProps) {
71
87
  const body = (
72
88
  <SearchResults
@@ -77,6 +93,9 @@ export function MobileSearchView({
77
93
  loading={loading}
78
94
  onSelectResult={onSelectResult}
79
95
  tokens={tokens}
96
+ scope={scope}
97
+ spamMatchCount={spamMatchCount}
98
+ onScopeToSpam={onScopeToSpam}
80
99
  />
81
100
  );
82
101
 
@@ -2,6 +2,7 @@ import { Flag } from "lucide-react";
2
2
  import type { ReactNode } from "react";
3
3
  import { cn } from "../lib/cn.js";
4
4
  import { Badge } from "./badge.js";
5
+ import { provenanceFolderLabel, type ResultFolder } from "./folder-role.js";
5
6
 
6
7
  export type SearchResultTone =
7
8
  | "neutral"
@@ -27,6 +28,12 @@ export interface SearchResult {
27
28
  threadId?: string;
28
29
  /** The mailbox the result lives in; paired with {@link threadId} to open it. */
29
30
  mailboxId?: string;
31
+ /**
32
+ * The folder this row was read from. A search that reaches every folder
33
+ * returns rows from all over, so the row says where it came from; see
34
+ * {@link provenanceFolderLabel} for which folders can be named.
35
+ */
36
+ folder?: ResultFolder;
30
37
  /**
31
38
  * Why a semantic ("Related") hit matched — a plain-language label derived
32
39
  * from `matchedChunkType` (e.g. "body", "subject", "attachment"), so the user
@@ -43,6 +50,12 @@ export interface SearchResultRowProps {
43
50
  onClick?: () => void;
44
51
  /** When given, literal (case-insensitive) matches are bolded in subject/snippet. */
45
52
  query?: string;
53
+ /**
54
+ * Show the folder the row came from. Defaults to true. A search confined to
55
+ * one folder turns it off — every row would carry the same label, which is
56
+ * noise rather than provenance.
57
+ */
58
+ showFolder?: boolean;
46
59
  }
47
60
 
48
61
  function highlight(text: string, query?: string): ReactNode {
@@ -79,7 +92,12 @@ export function SearchResultRow({
79
92
  result,
80
93
  onClick,
81
94
  query,
95
+ showFolder = true,
82
96
  }: SearchResultRowProps) {
97
+ const folderLabel =
98
+ showFolder && result.folder
99
+ ? provenanceFolderLabel(result.folder)
100
+ : undefined;
83
101
  return (
84
102
  <button
85
103
  type="button"
@@ -118,6 +136,11 @@ export function SearchResultRow({
118
136
  <span className="min-w-0 flex-1 truncate text-xs text-fg-subtle">
119
137
  {highlight(result.snippet, query)}
120
138
  </span>
139
+ {folderLabel && (
140
+ <Badge tone="neutral" className="shrink-0">
141
+ {folderLabel}
142
+ </Badge>
143
+ )}
121
144
  {result.category && (
122
145
  <Badge tone={result.category.tone ?? "neutral"} className="shrink-0">
123
146
  {result.category.label}