@remit/ui 0.0.1

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 (188) hide show
  1. package/package.json +50 -0
  2. package/src/components/address-display.render.test.ts +93 -0
  3. package/src/components/address-display.stories.tsx +55 -0
  4. package/src/components/address-display.tsx +132 -0
  5. package/src/components/address-tag.render.test.ts +39 -0
  6. package/src/components/address-tag.stories.tsx +41 -0
  7. package/src/components/address-tag.tsx +38 -0
  8. package/src/components/app-password-hint.render.test.ts +23 -0
  9. package/src/components/app-password-hint.stories.tsx +19 -0
  10. package/src/components/app-password-hint.tsx +24 -0
  11. package/src/components/app-shell-slotted.render.test.ts +128 -0
  12. package/src/components/app-shell-slotted.tsx +267 -0
  13. package/src/components/app-shell-types.ts +328 -0
  14. package/src/components/app-shell.render.test.ts +415 -0
  15. package/src/components/app-shell.tsx +277 -0
  16. package/src/components/auth-card.render.test.ts +24 -0
  17. package/src/components/auth-card.stories.tsx +50 -0
  18. package/src/components/auth-card.tsx +32 -0
  19. package/src/components/auth-footer.render.test.ts +17 -0
  20. package/src/components/auth-footer.stories.tsx +23 -0
  21. package/src/components/auth-footer.tsx +14 -0
  22. package/src/components/auth-hero.render.test.ts +31 -0
  23. package/src/components/auth-hero.stories.tsx +19 -0
  24. package/src/components/auth-hero.tsx +41 -0
  25. package/src/components/auto-moved-badge.render.test.ts +42 -0
  26. package/src/components/auto-moved-badge.stories.tsx +48 -0
  27. package/src/components/auto-moved-badge.tsx +57 -0
  28. package/src/components/avatar.tsx +60 -0
  29. package/src/components/badge.tsx +49 -0
  30. package/src/components/banner.render.test.ts +58 -0
  31. package/src/components/banner.stories.tsx +92 -0
  32. package/src/components/banner.tsx +81 -0
  33. package/src/components/bottom-sheet.render.test.ts +48 -0
  34. package/src/components/bottom-sheet.stories.tsx +71 -0
  35. package/src/components/bottom-sheet.tsx +146 -0
  36. package/src/components/brief-section.render.test.ts +98 -0
  37. package/src/components/brief-section.stories.tsx +68 -0
  38. package/src/components/brief-section.tsx +102 -0
  39. package/src/components/brief-sections.render.test.ts +72 -0
  40. package/src/components/brief-sections.stories.tsx +178 -0
  41. package/src/components/brief-sections.tsx +201 -0
  42. package/src/components/button.tsx +48 -0
  43. package/src/components/card.tsx +48 -0
  44. package/src/components/category-badge.render.test.ts +40 -0
  45. package/src/components/category-badge.stories.tsx +37 -0
  46. package/src/components/category-badge.tsx +71 -0
  47. package/src/components/checkbox.render.test.ts +65 -0
  48. package/src/components/checkbox.stories.tsx +77 -0
  49. package/src/components/checkbox.tsx +78 -0
  50. package/src/components/compose-action-bar.render.test.ts +47 -0
  51. package/src/components/compose-action-bar.stories.tsx +35 -0
  52. package/src/components/compose-action-bar.tsx +95 -0
  53. package/src/components/compose-form-shell.render.test.ts +54 -0
  54. package/src/components/compose-form-shell.stories.tsx +96 -0
  55. package/src/components/compose-form-shell.tsx +49 -0
  56. package/src/components/danger-zone-section.tsx +36 -0
  57. package/src/components/dialog.render.test.ts +55 -0
  58. package/src/components/dialog.tsx +104 -0
  59. package/src/components/email-frame-css.ts +137 -0
  60. package/src/components/field-label.render.test.ts +16 -0
  61. package/src/components/field-label.tsx +19 -0
  62. package/src/components/filter-sheet.render.test.ts +76 -0
  63. package/src/components/filter-sheet.stories.tsx +146 -0
  64. package/src/components/filter-sheet.tsx +316 -0
  65. package/src/components/folder-role.tsx +66 -0
  66. package/src/components/input.tsx +44 -0
  67. package/src/components/intelligence-panel.stories.tsx +149 -0
  68. package/src/components/intelligence-panel.tsx +546 -0
  69. package/src/components/isolated-email-frame.render.test.ts +147 -0
  70. package/src/components/isolated-email-frame.stories.tsx +192 -0
  71. package/src/components/isolated-email-frame.tsx +265 -0
  72. package/src/components/kbd.tsx +17 -0
  73. package/src/components/keyboard-hint-bar.tsx +58 -0
  74. package/src/components/list-item.tsx +39 -0
  75. package/src/components/mail-action-toolbar.render.test.ts +43 -0
  76. package/src/components/mail-action-toolbar.tsx +180 -0
  77. package/src/components/mail-header.render.test.ts +69 -0
  78. package/src/components/mail-header.stories.tsx +233 -0
  79. package/src/components/mail-header.tsx +123 -0
  80. package/src/components/message-body-view.render.test.ts +59 -0
  81. package/src/components/message-body-view.stories.tsx +146 -0
  82. package/src/components/message-body-view.tsx +150 -0
  83. package/src/components/message-header.render.test.ts +58 -0
  84. package/src/components/message-header.stories.tsx +79 -0
  85. package/src/components/message-header.tsx +59 -0
  86. package/src/components/message-list-pane.render.test.ts +136 -0
  87. package/src/components/message-list-pane.stories.tsx +246 -0
  88. package/src/components/message-list-pane.tsx +248 -0
  89. package/src/components/message-list-state.render.test.ts +72 -0
  90. package/src/components/message-list-state.tsx +97 -0
  91. package/src/components/message-row.render.test.ts +48 -0
  92. package/src/components/message-row.stories.tsx +121 -0
  93. package/src/components/message-row.tsx +231 -0
  94. package/src/components/mobile-message-action-bar.render.test.ts +87 -0
  95. package/src/components/mobile-message-action-bar.stories.tsx +57 -0
  96. package/src/components/mobile-message-action-bar.tsx +175 -0
  97. package/src/components/mobile-reading-pane.render.test.ts +102 -0
  98. package/src/components/mobile-reading-pane.tsx +188 -0
  99. package/src/components/mobile-search-view.stories.tsx +247 -0
  100. package/src/components/mobile-search-view.tsx +100 -0
  101. package/src/components/move-mailbox-picker.render.test.ts +113 -0
  102. package/src/components/move-mailbox-picker.stories.tsx +76 -0
  103. package/src/components/move-mailbox-picker.tsx +324 -0
  104. package/src/components/nav-sidebar.render.test.ts +351 -0
  105. package/src/components/nav-sidebar.stories.tsx +216 -0
  106. package/src/components/nav-sidebar.tsx +625 -0
  107. package/src/components/outbox-row.render.test.ts +61 -0
  108. package/src/components/outbox-row.stories.tsx +53 -0
  109. package/src/components/outbox-row.tsx +120 -0
  110. package/src/components/outbox-status-badge.tsx +60 -0
  111. package/src/components/pane-layout.render.test.ts +105 -0
  112. package/src/components/popover-menu.render.test.ts +32 -0
  113. package/src/components/popover-menu.stories.tsx +57 -0
  114. package/src/components/popover-menu.tsx +110 -0
  115. package/src/components/primitives.stories.tsx +173 -0
  116. package/src/components/pull-to-refresh.render.test.ts +46 -0
  117. package/src/components/pull-to-refresh.stories.tsx +71 -0
  118. package/src/components/pull-to-refresh.tsx +54 -0
  119. package/src/components/quoted-text.render.test.ts +39 -0
  120. package/src/components/quoted-text.stories.tsx +67 -0
  121. package/src/components/quoted-text.tsx +67 -0
  122. package/src/components/reading-pane-empty.tsx +44 -0
  123. package/src/components/reading-pane.render.test.ts +148 -0
  124. package/src/components/reading-pane.stories.tsx +194 -0
  125. package/src/components/reading-pane.tsx +351 -0
  126. package/src/components/rescue-banner.render.test.ts +32 -0
  127. package/src/components/rescue-banner.stories.tsx +31 -0
  128. package/src/components/rescue-banner.tsx +50 -0
  129. package/src/components/rescue-candidate-row.render.test.ts +68 -0
  130. package/src/components/rescue-candidate-row.stories.tsx +108 -0
  131. package/src/components/rescue-candidate-row.tsx +94 -0
  132. package/src/components/rescue-from-spam-flow.render.test.ts +100 -0
  133. package/src/components/rescue-from-spam-flow.stories.tsx +92 -0
  134. package/src/components/rescue-from-spam-flow.tsx +342 -0
  135. package/src/components/resizable.tsx +55 -0
  136. package/src/components/role-appointment-list.render.test.ts +97 -0
  137. package/src/components/role-appointment-list.stories.tsx +138 -0
  138. package/src/components/role-appointment-list.tsx +227 -0
  139. package/src/components/row-actions.render.test.ts +83 -0
  140. package/src/components/row-actions.stories.tsx +87 -0
  141. package/src/components/row-actions.tsx +139 -0
  142. package/src/components/search-bar.render.test.ts +55 -0
  143. package/src/components/search-bar.stories.tsx +46 -0
  144. package/src/components/search-bar.tsx +118 -0
  145. package/src/components/search-result-row.tsx +139 -0
  146. package/src/components/search-results.render.test.ts +89 -0
  147. package/src/components/search-results.stories.tsx +126 -0
  148. package/src/components/search-results.tsx +215 -0
  149. package/src/components/search-token-chip.render.test.ts +42 -0
  150. package/src/components/search-token-chip.stories.tsx +42 -0
  151. package/src/components/search-token-chip.tsx +66 -0
  152. package/src/components/security-select.render.test.ts +33 -0
  153. package/src/components/security-select.tsx +53 -0
  154. package/src/components/segmented-control.tsx +70 -0
  155. package/src/components/select.tsx +33 -0
  156. package/src/components/selection-top-bar.render.test.ts +67 -0
  157. package/src/components/selection-top-bar.stories.tsx +39 -0
  158. package/src/components/selection-top-bar.tsx +95 -0
  159. package/src/components/sender-group-switch.tsx +63 -0
  160. package/src/components/sender-trust-indicator.render.test.ts +67 -0
  161. package/src/components/sender-trust-indicator.stories.tsx +58 -0
  162. package/src/components/sender-trust-indicator.tsx +87 -0
  163. package/src/components/settings-primitives.render.test.ts +160 -0
  164. package/src/components/settings-primitives.stories.tsx +80 -0
  165. package/src/components/settings-screen.tsx +354 -0
  166. package/src/components/swipeable-row.render.test.ts +106 -0
  167. package/src/components/swipeable-row.stories.tsx +117 -0
  168. package/src/components/swipeable-row.tsx +268 -0
  169. package/src/components/touch-list-body.render.test.ts +66 -0
  170. package/src/components/touch-list-body.stories.tsx +76 -0
  171. package/src/components/touch-list.tsx +87 -0
  172. package/src/components/wizard.render.test.ts +49 -0
  173. package/src/components/wizard.stories.tsx +113 -0
  174. package/src/components/wizard.tsx +321 -0
  175. package/src/filter-presets.test.ts +93 -0
  176. package/src/filter-presets.ts +101 -0
  177. package/src/index.ts +378 -0
  178. package/src/lib/cid-resolver.ts +35 -0
  179. package/src/lib/cn.ts +7 -0
  180. package/src/lib/email-layout-clamp.test.ts +109 -0
  181. package/src/lib/email-layout-clamp.ts +64 -0
  182. package/src/lib/email-render-treatment.test.ts +82 -0
  183. package/src/lib/email-render-treatment.ts +53 -0
  184. package/src/lib/email-sanitizer.test.ts +362 -0
  185. package/src/lib/email-sanitizer.ts +298 -0
  186. package/src/lib/row-keyboard.test.ts +41 -0
  187. package/src/lib/row-keyboard.ts +20 -0
  188. package/src/tokens.css +217 -0
@@ -0,0 +1,118 @@
1
+ import { Search, X } from "lucide-react";
2
+ import { useCallback, useEffect, useRef } from "react";
3
+ import { cn } from "../lib/cn.js";
4
+
5
+ export interface SearchBarProps {
6
+ value: string;
7
+ onChange: (value: string) => void;
8
+ /**
9
+ * Full clear (X button): the consumer resets the query AND any open thread
10
+ * so the view returns to the plain list with nothing pre-opened (#538).
11
+ */
12
+ onClear: () => void;
13
+ /**
14
+ * Query-only clear (Esc key): resets just the query and leaves any open
15
+ * thread untouched — one keypress, one effect (#489). Falls back to
16
+ * `onClear` when omitted.
17
+ */
18
+ onClearQuery?: () => void;
19
+ placeholder?: string;
20
+ /**
21
+ * Bind the global "/" shortcut that focuses the field from anywhere on the
22
+ * page. Defaults to true; set false where a page hosts more than one bar.
23
+ */
24
+ globalFocusKey?: boolean;
25
+ /**
26
+ * Show the inline clear (X) inside the field. Defaults to true. Set false in
27
+ * the mobile takeover / collapsed header, where a single outer X owns
28
+ * clear-and-close so there is exactly one X (Esc still clears the query).
29
+ */
30
+ showClearButton?: boolean;
31
+ }
32
+
33
+ const isEditableTarget = (target: EventTarget | null): boolean => {
34
+ if (!(target instanceof HTMLElement)) return false;
35
+ return (
36
+ target.tagName === "INPUT" ||
37
+ target.tagName === "TEXTAREA" ||
38
+ target.isContentEditable
39
+ );
40
+ };
41
+
42
+ export const SearchBar = ({
43
+ value,
44
+ onChange,
45
+ onClear,
46
+ onClearQuery,
47
+ placeholder = "Search mail...",
48
+ globalFocusKey = true,
49
+ showClearButton = true,
50
+ }: SearchBarProps) => {
51
+ const inputRef = useRef<HTMLInputElement>(null);
52
+ const clearQuery = onClearQuery ?? onClear;
53
+
54
+ const handleClear = useCallback(() => {
55
+ onClear();
56
+ inputRef.current?.focus();
57
+ }, [onClear]);
58
+
59
+ const handleKeyDown = useCallback(
60
+ (event: React.KeyboardEvent<HTMLInputElement>) => {
61
+ if (event.key !== "Escape") return;
62
+ if (value) {
63
+ clearQuery();
64
+ return;
65
+ }
66
+ inputRef.current?.blur();
67
+ },
68
+ [value, clearQuery],
69
+ );
70
+
71
+ useEffect(() => {
72
+ if (!globalFocusKey) return;
73
+ const handleGlobalSlash = (event: KeyboardEvent) => {
74
+ if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) {
75
+ return;
76
+ }
77
+ if (isEditableTarget(event.target)) return;
78
+ event.preventDefault();
79
+ inputRef.current?.focus();
80
+ };
81
+ window.addEventListener("keydown", handleGlobalSlash);
82
+ return () => window.removeEventListener("keydown", handleGlobalSlash);
83
+ }, [globalFocusKey]);
84
+
85
+ return (
86
+ <div className="relative w-full">
87
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-fg-muted pointer-events-none" />
88
+ <input
89
+ ref={inputRef}
90
+ id="mail-search"
91
+ name="q"
92
+ type="text"
93
+ aria-label="Search mail"
94
+ value={value}
95
+ onChange={(e) => onChange(e.target.value)}
96
+ onKeyDown={handleKeyDown}
97
+ placeholder={placeholder}
98
+ className={cn(
99
+ "w-full pl-9 pr-9 py-1.5 text-sm rounded-md",
100
+ "bg-surface-sunken/50 border border-transparent",
101
+ "focus:bg-canvas focus:border-line focus:outline-none focus:ring-2 focus:ring-ring",
102
+ "placeholder:text-fg-muted",
103
+ "transition-colors",
104
+ )}
105
+ />
106
+ {value && showClearButton && (
107
+ <button
108
+ type="button"
109
+ onClick={handleClear}
110
+ className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-surface-raised transition-colors"
111
+ aria-label="Clear search"
112
+ >
113
+ <X className="size-4 text-fg-muted" />
114
+ </button>
115
+ )}
116
+ </div>
117
+ );
118
+ };
@@ -0,0 +1,139 @@
1
+ import { Flag } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { cn } from "../lib/cn.js";
4
+ import { Badge } from "./badge.js";
5
+
6
+ export type SearchResultTone =
7
+ | "neutral"
8
+ | "accent"
9
+ | "positive"
10
+ | "warning"
11
+ | "danger";
12
+
13
+ export interface SearchResult {
14
+ id: string;
15
+ sender: string;
16
+ subject: string;
17
+ snippet: string;
18
+ date: string;
19
+ unread?: boolean;
20
+ flagged?: boolean;
21
+ category?: { label: string; tone?: SearchResultTone };
22
+ /**
23
+ * The thread this result belongs to. Lets the consumer open the conversation
24
+ * directly — even when the message isn't in the currently loaded list, which is
25
+ * the case for semantic "Related" hits surfaced from anywhere in the mailbox.
26
+ */
27
+ threadId?: string;
28
+ /** The mailbox the result lives in; paired with {@link threadId} to open it. */
29
+ mailboxId?: string;
30
+ /**
31
+ * Why a semantic ("Related") hit matched — a plain-language label derived
32
+ * from `matchedChunkType` (e.g. "body", "subject", "attachment"), so the user
33
+ * understands why the result showed up. Absent for literal "Top matches"
34
+ * rows, which match by construction.
35
+ */
36
+ matchedChunkLabel?: string;
37
+ /** Relevance figure (0–1) from the semantic engine; rendered beside the chip. */
38
+ score?: number;
39
+ }
40
+
41
+ export interface SearchResultRowProps {
42
+ result: SearchResult;
43
+ onClick?: () => void;
44
+ /** When given, literal (case-insensitive) matches are bolded in subject/snippet. */
45
+ query?: string;
46
+ }
47
+
48
+ function highlight(text: string, query?: string): ReactNode {
49
+ const term = query?.trim();
50
+ if (!term) return text;
51
+ const lower = text.toLowerCase();
52
+ const needle = term.toLowerCase();
53
+ const parts: ReactNode[] = [];
54
+ let cursor = 0;
55
+ let match = lower.indexOf(needle, cursor);
56
+ let key = 0;
57
+ while (match !== -1) {
58
+ if (match > cursor) parts.push(text.slice(cursor, match));
59
+ parts.push(
60
+ <mark key={key++} className="bg-transparent font-semibold text-fg">
61
+ {text.slice(match, match + needle.length)}
62
+ </mark>,
63
+ );
64
+ cursor = match + needle.length;
65
+ match = lower.indexOf(needle, cursor);
66
+ }
67
+ if (cursor < text.length) parts.push(text.slice(cursor));
68
+ return parts;
69
+ }
70
+
71
+ /**
72
+ * One tappable search result. Mirrors the collapsed reading-pane row rhythm:
73
+ * sender + right-aligned date on the top line, the subject, then a one-line
74
+ * truncated snippet, with an optional category Badge and a flag indicator. The
75
+ * sender bolds when unread. Presentational and prop-driven; the app supplies
76
+ * `onClick` and the optional `query` to bold literal matches.
77
+ */
78
+ export function SearchResultRow({
79
+ result,
80
+ onClick,
81
+ query,
82
+ }: SearchResultRowProps) {
83
+ return (
84
+ <button
85
+ type="button"
86
+ onClick={onClick}
87
+ className="flex w-full flex-col gap-0.5 border-b border-line px-row-inset py-2.5 text-left transition-colors hover:bg-surface-sunken"
88
+ >
89
+ <div className="flex items-baseline gap-2">
90
+ <span
91
+ className={cn(
92
+ "min-w-0 flex-1 truncate text-sm",
93
+ result.unread
94
+ ? "font-semibold text-fg"
95
+ : "font-medium text-fg-muted",
96
+ )}
97
+ >
98
+ {result.sender}
99
+ </span>
100
+ <span className="shrink-0 text-2xs text-fg-subtle tabular-nums">
101
+ {result.date}
102
+ </span>
103
+ </div>
104
+ <div className="flex items-center gap-1.5">
105
+ <span
106
+ className={cn(
107
+ "min-w-0 flex-1 truncate text-sm",
108
+ result.unread ? "text-fg" : "text-fg-muted",
109
+ )}
110
+ >
111
+ {highlight(result.subject, query)}
112
+ </span>
113
+ {result.flagged && (
114
+ <Flag className="size-3.5 shrink-0 fill-warning text-warning" />
115
+ )}
116
+ </div>
117
+ <div className="flex items-center gap-2">
118
+ <span className="min-w-0 flex-1 truncate text-xs text-fg-subtle">
119
+ {highlight(result.snippet, query)}
120
+ </span>
121
+ {result.category && (
122
+ <Badge tone={result.category.tone ?? "neutral"} className="shrink-0">
123
+ {result.category.label}
124
+ </Badge>
125
+ )}
126
+ {result.matchedChunkLabel && (
127
+ <Badge tone="neutral" className="shrink-0">
128
+ {`matched: ${result.matchedChunkLabel}`}
129
+ </Badge>
130
+ )}
131
+ {result.score != null && (
132
+ <span className="shrink-0 text-2xs text-fg-subtle tabular-nums">
133
+ {result.score.toFixed(2)}
134
+ </span>
135
+ )}
136
+ </div>
137
+ </button>
138
+ );
139
+ }
@@ -0,0 +1,89 @@
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 type { SearchResult } from "./search-result-row.js";
6
+ import { type SearchResultSection, SearchResults } from "./search-results.js";
7
+
8
+ const noop = () => {};
9
+
10
+ const result: SearchResult = {
11
+ id: "r1",
12
+ sender: "Stripe",
13
+ subject: "Your invoice for March is ready",
14
+ snippet: "Invoice #4821 paid.",
15
+ date: "9:42",
16
+ };
17
+
18
+ const sections: SearchResultSection[] = [
19
+ { id: "results", label: "Results", results: [result] },
20
+ ];
21
+
22
+ describe("SearchResults", () => {
23
+ it("renders the section header and matching rows for a query", () => {
24
+ const html = renderToString(
25
+ createElement(SearchResults, { value: "invoice", sections }),
26
+ );
27
+ assert.match(html, /Results/);
28
+ assert.match(html, /Stripe/);
29
+ assert.match(html, /for March is ready/);
30
+ });
31
+
32
+ it("shows recent searches when the query is empty", () => {
33
+ const html = renderToString(
34
+ createElement(SearchResults, {
35
+ value: "",
36
+ recentSearches: ["invoice march"],
37
+ onPickRecent: noop,
38
+ }),
39
+ );
40
+ assert.match(html, /Recent searches/);
41
+ assert.match(html, /invoice march/);
42
+ });
43
+
44
+ it("shows the empty state when a query matches nothing", () => {
45
+ const html = renderToString(
46
+ createElement(SearchResults, {
47
+ value: "asdfqwer",
48
+ sections: [{ id: "results", label: "Results", results: [] }],
49
+ }),
50
+ );
51
+ assert.match(html, /No matches for/);
52
+ });
53
+
54
+ it("renders the matched-chunk chip and score on a semantic hit", () => {
55
+ const semanticResult: SearchResult = {
56
+ ...result,
57
+ matchedChunkLabel: "subject",
58
+ score: 0.87,
59
+ };
60
+ const html = renderToString(
61
+ createElement(SearchResults, {
62
+ value: "invoice",
63
+ sections: [
64
+ { id: "related", label: "Related", results: [semanticResult] },
65
+ ],
66
+ }),
67
+ );
68
+ assert.match(html, /matched: subject/);
69
+ assert.match(html, /0\.87/);
70
+ });
71
+
72
+ it("renders removable filter-token chips above the sections", () => {
73
+ const html = renderToString(
74
+ createElement(SearchResults, {
75
+ value: "invoice from:stripe.com",
76
+ sections,
77
+ tokens: [{ label: "From: stripe.com", onRemove: noop }],
78
+ }),
79
+ );
80
+ assert.match(html, /From: stripe\.com/);
81
+ });
82
+
83
+ it("omits the chip row when there are no tokens", () => {
84
+ const html = renderToString(
85
+ createElement(SearchResults, { value: "invoice", sections, tokens: [] }),
86
+ );
87
+ assert.doesNotMatch(html, /Remove filter/);
88
+ });
89
+ });
@@ -0,0 +1,126 @@
1
+ import type { Decorator, Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import type { SearchResult } from "./search-result-row.js";
4
+ import { type SearchResultSection, SearchResults } from "./search-results.js";
5
+
6
+ /**
7
+ * Frames the body the way the desktop/tablet list pane does: a narrow column
8
+ * with its own scroll container, so the sticky section headers read correctly.
9
+ */
10
+ const listPaneFrame: Decorator = (Story) => (
11
+ <div
12
+ className="overflow-y-auto rounded-lg border border-line bg-canvas"
13
+ style={{ width: 360, height: 640 }}
14
+ >
15
+ <Story />
16
+ </div>
17
+ );
18
+
19
+ const recentSearches = ["invoice march", "from: stripe", "flight confirmation"];
20
+
21
+ const topMatches: SearchResult[] = [
22
+ {
23
+ id: "r1",
24
+ sender: "Stripe",
25
+ subject: "Your invoice for March is ready",
26
+ snippet: "Invoice #4821 — €149.00 paid on Visa ending 4242.",
27
+ date: "9:42",
28
+ unread: true,
29
+ category: { label: "Receipt", tone: "positive" },
30
+ },
31
+ {
32
+ id: "r2",
33
+ sender: "Hetzner Online",
34
+ subject: "Invoice 2026-03 available in your account",
35
+ snippet: "Dear customer, your invoice for the period is attached.",
36
+ date: "Mar 3",
37
+ category: { label: "Finance", tone: "accent" },
38
+ },
39
+ {
40
+ id: "r3",
41
+ sender: "Anna de Vries",
42
+ subject: "Re: Q1 invoice approval",
43
+ snippet: "Approved — can you forward the PDF invoice to finance?",
44
+ date: "Mar 1",
45
+ flagged: true,
46
+ },
47
+ ];
48
+
49
+ const related: SearchResult[] = [
50
+ {
51
+ id: "r5",
52
+ sender: "QuickBooks",
53
+ subject: "Reminder: 2 invoices awaiting payment",
54
+ snippet: "You have outstanding invoices totalling €430.00.",
55
+ date: "Feb 20",
56
+ matchedChunkLabel: "body",
57
+ score: 0.92,
58
+ },
59
+ {
60
+ id: "r6",
61
+ sender: "noreply@vendor.io",
62
+ subject: "Overdue invoice notice",
63
+ snippet: "This invoice is now 14 days overdue. Please remit payment.",
64
+ date: "Feb 12",
65
+ matchedChunkLabel: "subject",
66
+ score: 0.81,
67
+ },
68
+ ];
69
+
70
+ const resultSections: SearchResultSection[] = [
71
+ { id: "top", label: "Top matches", results: topMatches },
72
+ { id: "related", label: "Related", results: related },
73
+ ];
74
+
75
+ const emptySections: SearchResultSection[] = [
76
+ { id: "top", label: "Top matches", results: [] },
77
+ ];
78
+
79
+ function Harness(props: Parameters<typeof SearchResults>[0]) {
80
+ const [value, setValue] = useState(props.value);
81
+ return <SearchResults {...props} value={value} onPickRecent={setValue} />;
82
+ }
83
+
84
+ const meta: Meta<typeof SearchResults> = {
85
+ title: "Kit/SearchResults",
86
+ component: SearchResults,
87
+ parameters: { layout: "centered" },
88
+ decorators: [listPaneFrame],
89
+ };
90
+ export default meta;
91
+
92
+ type Story = StoryObj<typeof SearchResults>;
93
+
94
+ /** The sectioned results the desktop list pane swaps in while a query is active. */
95
+ export const Results: Story = {
96
+ render: () => <Harness value="invoice" sections={resultSections} />,
97
+ };
98
+
99
+ /** A query that matches nothing. */
100
+ export const NoResults: Story = {
101
+ render: () => <Harness value="asdfqwer" sections={emptySections} />,
102
+ };
103
+
104
+ /** Results still loading. */
105
+ export const Loading: Story = {
106
+ render: () => <Harness value="invoice" loading />,
107
+ };
108
+
109
+ /** Empty query: recent searches (the list pane shows the normal list instead). */
110
+ export const Idle: Story = {
111
+ render: () => <Harness value="" recentSearches={recentSearches} />,
112
+ };
113
+
114
+ /** Typed filter tokens (`from:`, `has:attachment`, …) render as removable chips above the sections. */
115
+ export const WithFilterTokens: Story = {
116
+ render: () => (
117
+ <Harness
118
+ value="invoice from:stripe.com has:attachment"
119
+ sections={resultSections}
120
+ tokens={[
121
+ { label: "From: stripe.com", onRemove: () => {} },
122
+ { label: "Has attachment", onRemove: () => {} },
123
+ ]}
124
+ />
125
+ ),
126
+ };
@@ -0,0 +1,215 @@
1
+ import { ChevronDown, Clock } from "lucide-react";
2
+ import { useState } from "react";
3
+ import { cn } from "../lib/cn.js";
4
+ import { type SearchResult, SearchResultRow } from "./search-result-row.js";
5
+ import { SearchTokenChips } from "./search-token-chip.js";
6
+
7
+ /** Rows shown before the "Show N more" expander kicks in. */
8
+ const SECTION_ROW_CAP = 6;
9
+
10
+ export interface SearchResultSection {
11
+ id: string;
12
+ label: string;
13
+ results: SearchResult[];
14
+ /** Seed the section-collapse state — lets a story render the header-only view. */
15
+ initialCollapsed?: boolean;
16
+ }
17
+
18
+ export interface SearchResultsProps {
19
+ /** The current query — narrows what's shown and bolds literal matches. */
20
+ value: string;
21
+ /** Recent searches shown when the query is empty. */
22
+ recentSearches?: string[];
23
+ onPickRecent?: (query: string) => void;
24
+ /** Grouped result sections (e.g. "Top matches", "Related"). */
25
+ sections?: SearchResultSection[];
26
+ loading?: boolean;
27
+ onSelectResult?: (result: SearchResult) => void;
28
+ /**
29
+ * Active filter-token chips parsed from the query (`from:`, `has:attachment`,
30
+ * …), removable. Rendered above the results/loading/empty states so they stay
31
+ * visible whatever the query returns; omit or pass an empty array when the
32
+ * query has no recognized tokens.
33
+ */
34
+ tokens?: { label: string; onRemove: () => void }[];
35
+ }
36
+
37
+ /**
38
+ * One result section: a sticky header that collapses the section to just its
39
+ * label + count, and a "Show N more" control that reveals rows past the cap.
40
+ * Mirrors `BriefSection`'s header rhythm so search and the brief stay in lockstep
41
+ * without sharing data shapes. Always tappable — it never disables.
42
+ */
43
+ function CollapsibleResultSection({
44
+ section,
45
+ query,
46
+ onSelectResult,
47
+ }: {
48
+ section: SearchResultSection;
49
+ query?: string;
50
+ onSelectResult?: (result: SearchResult) => void;
51
+ }) {
52
+ const [collapsed, setCollapsed] = useState(section.initialCollapsed ?? false);
53
+ const [expanded, setExpanded] = useState(false);
54
+
55
+ const overCap = section.results.length > SECTION_ROW_CAP;
56
+ const capped = !expanded && overCap;
57
+ const visible = capped
58
+ ? section.results.slice(0, SECTION_ROW_CAP)
59
+ : section.results;
60
+ const hiddenCount = section.results.length - visible.length;
61
+
62
+ return (
63
+ <div>
64
+ <button
65
+ type="button"
66
+ aria-expanded={!collapsed}
67
+ onClick={() => setCollapsed((v) => !v)}
68
+ className="sticky top-0 z-10 flex h-section-row w-full items-center gap-1.5 border-b border-line bg-surface-sunken px-row-inset text-left transition-colors hover:bg-surface"
69
+ >
70
+ <span className="flex-1 text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
71
+ {section.label}
72
+ </span>
73
+ <span className="text-2xs text-fg-subtle tabular-nums">
74
+ {section.results.length}
75
+ </span>
76
+ <ChevronDown
77
+ className={cn(
78
+ "size-3 shrink-0 text-fg-subtle transition-transform duration-200",
79
+ collapsed ? "rotate-0" : "rotate-180",
80
+ )}
81
+ />
82
+ </button>
83
+ {!collapsed && (
84
+ <>
85
+ {visible.map((result) => (
86
+ <SearchResultRow
87
+ key={result.id}
88
+ result={result}
89
+ query={query}
90
+ onClick={
91
+ onSelectResult ? () => onSelectResult(result) : undefined
92
+ }
93
+ />
94
+ ))}
95
+ {overCap && (
96
+ <button
97
+ type="button"
98
+ onClick={() => setExpanded((v) => !v)}
99
+ className="flex w-full items-center justify-center border-b border-line px-row-inset py-1.5 text-2xs font-medium text-accent transition-colors hover:bg-surface"
100
+ >
101
+ {expanded ? "Show less" : `Show ${hiddenCount} more`}
102
+ {!expanded && <ChevronDown className="ml-1 size-3" />}
103
+ </button>
104
+ )}
105
+ </>
106
+ )}
107
+ </div>
108
+ );
109
+ }
110
+
111
+ /**
112
+ * The sectioned search-results body shared by the phone `MobileSearchView`
113
+ * takeover and the desktop/tablet list pane. It swaps between recent searches
114
+ * (empty query), a loading skeleton, an empty state, and the collapsible result
115
+ * sections — one implementation so both tiers render identical rows. The caller
116
+ * owns the surrounding chrome and scroll container (a `FilterSheet`, the
117
+ * takeover header, or the list-pane body). Presentational and prop-driven.
118
+ */
119
+ export function SearchResults({
120
+ value,
121
+ recentSearches,
122
+ onPickRecent,
123
+ sections,
124
+ loading,
125
+ onSelectResult,
126
+ tokens,
127
+ }: SearchResultsProps) {
128
+ const hasQuery = value.trim().length > 0;
129
+ const hasResults = (sections ?? []).some(
130
+ (section) => section.results.length > 0,
131
+ );
132
+
133
+ if (!hasQuery) {
134
+ if (recentSearches && recentSearches.length > 0) {
135
+ return (
136
+ <div className="flex flex-col">
137
+ <h3 className="px-row-inset pb-1 pt-3 text-2xs font-semibold uppercase tracking-wide text-fg-subtle">
138
+ Recent searches
139
+ </h3>
140
+ {recentSearches.map((recent) => (
141
+ <button
142
+ key={recent}
143
+ type="button"
144
+ onClick={() => onPickRecent?.(recent)}
145
+ className="flex w-full items-center gap-2.5 border-b border-line px-row-inset py-2.5 text-left transition-colors hover:bg-surface-sunken"
146
+ >
147
+ <Clock className="size-4 shrink-0 text-fg-subtle" />
148
+ <span className="min-w-0 flex-1 truncate text-sm text-fg-muted">
149
+ {recent}
150
+ </span>
151
+ </button>
152
+ ))}
153
+ </div>
154
+ );
155
+ }
156
+ return (
157
+ <p className="px-row-inset py-6 text-center text-sm text-fg-subtle">
158
+ Search across your mail by sender, subject or words in the message.
159
+ </p>
160
+ );
161
+ }
162
+
163
+ const chips = tokens && tokens.length > 0 && (
164
+ <SearchTokenChips tokens={tokens} />
165
+ );
166
+
167
+ if (loading) {
168
+ return (
169
+ <div className="flex flex-col">
170
+ {chips}
171
+ <div className="flex flex-col gap-3 px-row-inset py-4">
172
+ {[0, 1, 2, 3].map((row) => (
173
+ <div key={row} className="flex flex-col gap-1.5">
174
+ <div className="h-3 w-1/3 animate-pulse rounded bg-surface-sunken" />
175
+ <div className="h-3 w-2/3 animate-pulse rounded bg-surface-sunken" />
176
+ <div className="h-2.5 w-1/2 animate-pulse rounded bg-surface-sunken" />
177
+ </div>
178
+ ))}
179
+ </div>
180
+ </div>
181
+ );
182
+ }
183
+
184
+ if (!hasResults) {
185
+ return (
186
+ <div className="flex flex-col">
187
+ {chips}
188
+ <div className="px-row-inset py-10 text-center">
189
+ <p className="text-sm font-medium text-fg">
190
+ No matches for &ldquo;{value}&rdquo;
191
+ </p>
192
+ <p className="mt-1 text-xs text-fg-subtle">
193
+ Try a different word, or adjust the filters above.
194
+ </p>
195
+ </div>
196
+ </div>
197
+ );
198
+ }
199
+
200
+ return (
201
+ <div className="flex flex-col">
202
+ {chips}
203
+ {sections
204
+ ?.filter((section) => section.results.length > 0)
205
+ .map((section) => (
206
+ <CollapsibleResultSection
207
+ key={section.id}
208
+ section={section}
209
+ query={value}
210
+ onSelectResult={onSelectResult}
211
+ />
212
+ ))}
213
+ </div>
214
+ );
215
+ }