@remit/ui 0.0.56 → 0.0.57

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 (34) hide show
  1. package/package.json +1 -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 +37 -0
  30. package/src/lib/suggest-keys.test.ts +101 -0
  31. package/src/lib/suggest-keys.ts +63 -0
  32. package/src/lib/use-long-press.ts +70 -37
  33. package/src/lib/use-suggest-list.test.ts +169 -0
  34. package/src/lib/use-suggest-list.ts +124 -0
@@ -1,6 +1,7 @@
1
1
  import { Search, X } from "lucide-react";
2
2
  import { useCallback, useEffect, useId, useRef, useState } from "react";
3
3
  import { cn } from "../lib/cn.js";
4
+ import type { ComboboxProps } from "../lib/use-suggest-list.js";
4
5
  import {
5
6
  type ChipFocusTarget,
6
7
  focusAfterRemoval,
@@ -21,6 +22,41 @@ export interface SearchChip {
21
22
  tone?: SearchChipTone;
22
23
  }
23
24
 
25
+ /**
26
+ * Where the caret must land, as one object per request. Identity is the signal —
27
+ * the field applies a request once and never again, so a caret the host asked
28
+ * for cannot fight the one the user moved afterwards.
29
+ */
30
+ export interface SearchCaretRequest {
31
+ cursor: number;
32
+ }
33
+
34
+ /**
35
+ * The completion wiring a search field carries when its host offers suggestions
36
+ * for what is being typed.
37
+ *
38
+ * The host owns the list: what is on it, what picking one means, and where it
39
+ * renders — under the field, in flow, so a soft keyboard cannot hide what was
40
+ * typed. The field owns only the input element, and gives the host the three
41
+ * things that live there: where the caret is, first refusal on the keys the
42
+ * list uses, and the ARIA the combobox pattern needs.
43
+ *
44
+ * Keys go to the list before the field's own handling, so Escape closes the
45
+ * list and leaves the query standing; a second Escape clears it as always.
46
+ */
47
+ export interface SearchFieldSuggest {
48
+ /** ARIA wiring for the input, from `useSuggestList`. */
49
+ comboboxProps: ComboboxProps;
50
+ /** Returns true when the list consumed the key and the field should stop. */
51
+ onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => boolean;
52
+ /** The caret moved — the host recomputes the term under it. */
53
+ onCaretChange: (cursor: number) => void;
54
+ /** Focus left or entered the field; an unfocused field offers nothing. */
55
+ onFocusChange?: (focused: boolean) => void;
56
+ /** Where to put the caret after the host applied a suggestion. */
57
+ caret?: SearchCaretRequest;
58
+ }
59
+
24
60
  export interface SearchChipInputProps {
25
61
  /**
26
62
  * The narrowing terms, in expression order. Chips are host-owned: this
@@ -71,6 +107,11 @@ export interface SearchChipInputProps {
71
107
  inputId?: string;
72
108
  /** Accessible name for the chip grid. */
73
109
  chipsLabel?: string;
110
+ /**
111
+ * Completions for what is being typed. Omit for a field that offers none;
112
+ * see {@link SearchFieldSuggest} for what the field and the host each own.
113
+ */
114
+ suggest?: SearchFieldSuggest;
74
115
  className?: string;
75
116
  }
76
117
 
@@ -105,6 +146,12 @@ const isEditableTarget = (target: EventTarget | null): boolean => {
105
146
  * - After a removal, focus lands on the chip that took its place, else the
106
147
  * one before it, else the text input.
107
148
  *
149
+ * **Completions.** With `suggest`, the field reports its caret and hands the
150
+ * list first refusal on each key; the host builds the offer and renders it. The
151
+ * offer is a shortcut and never a constraint — nothing on the list can change
152
+ * what was typed, and a term with no matches leaves the plain text field this
153
+ * always is.
154
+ *
108
155
  * Removal is never keyboard-only: every chip carries a remove button, which is
109
156
  * what touch and soft-keyboard users need (a soft keyboard gives no reliable
110
157
  * Backspace-into-chip signal).
@@ -129,6 +176,7 @@ export const SearchChipInput = ({
129
176
  size = "sm",
130
177
  inputId,
131
178
  chipsLabel = "Search filters",
179
+ suggest,
132
180
  className,
133
181
  }: SearchChipInputProps) => {
134
182
  // The field wraps itself in a <label for>, and `for` binds to the FIRST
@@ -169,6 +217,24 @@ export const SearchChipInput = ({
169
217
  chipRefs.current[target]?.focus();
170
218
  });
171
219
 
220
+ /** The caret request already honoured; a new object is a new request. */
221
+ const appliedCaret = useRef<SearchCaretRequest | undefined>(undefined);
222
+ useEffect(() => {
223
+ const request = suggest?.caret;
224
+ if (!request || request === appliedCaret.current) return;
225
+ appliedCaret.current = request;
226
+ const input = inputRef.current;
227
+ // Only while the field holds focus — applying a suggestion never takes the
228
+ // caret away from wherever the user has since gone.
229
+ if (!input || input.ownerDocument.activeElement !== input) return;
230
+ input.setSelectionRange(request.cursor, request.cursor);
231
+ suggest?.onCaretChange(request.cursor);
232
+ });
233
+
234
+ const reportCaret = (input: HTMLInputElement) => {
235
+ suggest?.onCaretChange(input.selectionStart ?? input.value.length);
236
+ };
237
+
172
238
  const moveFocus = useCallback((target: ChipFocusTarget) => {
173
239
  setFocusedChip(target);
174
240
  pendingFocus.current = target;
@@ -197,6 +263,9 @@ export const SearchChipInput = ({
197
263
  const handleInputKeyDown = useCallback(
198
264
  (event: React.KeyboardEvent<HTMLInputElement>) => {
199
265
  const input = event.currentTarget;
266
+ // The list gets the key first: while it is open, Escape closes it and the
267
+ // query stands, and Enter takes the highlighted completion.
268
+ if (suggest?.onKeyDown(event)) return;
200
269
  const action = resolveChipInputKey({
201
270
  key: event.key,
202
271
  shiftKey: event.shiftKey,
@@ -221,7 +290,7 @@ export const SearchChipInput = ({
221
290
  return;
222
291
  }
223
292
  },
224
- [chips.length, value, clearQuery, moveFocus],
293
+ [chips.length, value, clearQuery, moveFocus, suggest],
225
294
  );
226
295
 
227
296
  const handleChipKeyDown = useCallback(
@@ -333,9 +402,22 @@ export const SearchChipInput = ({
333
402
  autoComplete="off"
334
403
  value={value}
335
404
  tabIndex={focusedChip === null ? 0 : -1}
336
- onChange={(e) => onChange(e.target.value)}
405
+ onChange={(e) => {
406
+ onChange(e.target.value);
407
+ reportCaret(e.target);
408
+ }}
337
409
  onKeyDown={handleInputKeyDown}
338
- onFocus={() => setFocusedChip(null)}
410
+ // Arrow keys and clicks move the caret without changing the text, and
411
+ // the term under it is what the list completes.
412
+ onKeyUp={(e) => reportCaret(e.currentTarget)}
413
+ onClick={(e) => reportCaret(e.currentTarget)}
414
+ onFocus={(e) => {
415
+ setFocusedChip(null);
416
+ suggest?.onFocusChange?.(true);
417
+ reportCaret(e.currentTarget);
418
+ }}
419
+ onBlur={() => suggest?.onFocusChange?.(false)}
420
+ {...suggest?.comboboxProps}
339
421
  // Once the expression carries chips the field is self-describing; a
340
422
  // placeholder there would read as another term.
341
423
  placeholder={hasChips ? undefined : placeholder}
@@ -1,8 +1,8 @@
1
- import { Flag } from "lucide-react";
2
- import type { ReactNode } from "react";
3
1
  import { cn } from "../lib/cn.js";
2
+ import type { ThreadRowData } from "./app-shell-types.js";
4
3
  import { Badge } from "./badge.js";
5
4
  import { provenanceFolderLabel, type ResultFolder } from "./folder-role.js";
5
+ import { ComfortableRowBody, comfortableRowClass } from "./message-row.js";
6
6
 
7
7
  export type SearchResultTone =
8
8
  | "neutral"
@@ -14,6 +14,12 @@ export type SearchResultTone =
14
14
  export interface SearchResult {
15
15
  id: string;
16
16
  sender: string;
17
+ /**
18
+ * Sender address. The avatar's color is keyed on it, so a message keeps the
19
+ * same circle in a search result as in the list it came from. Absent for
20
+ * semantic hits, whose index carries no address; those key on the name.
21
+ */
22
+ senderEmail?: string;
17
23
  subject: string;
18
24
  snippet: string;
19
25
  date: string;
@@ -58,35 +64,37 @@ export interface SearchResultRowProps {
58
64
  showFolder?: boolean;
59
65
  }
60
66
 
61
- function highlight(text: string, query?: string): ReactNode {
62
- const term = query?.trim();
63
- if (!term) return text;
64
- const lower = text.toLowerCase();
65
- const needle = term.toLowerCase();
66
- const parts: ReactNode[] = [];
67
- let cursor = 0;
68
- let match = lower.indexOf(needle, cursor);
69
- let key = 0;
70
- while (match !== -1) {
71
- if (match > cursor) parts.push(text.slice(cursor, match));
72
- parts.push(
73
- <mark key={key++} className="bg-transparent font-semibold text-fg">
74
- {text.slice(match, match + needle.length)}
75
- </mark>,
76
- );
77
- cursor = match + needle.length;
78
- match = lower.indexOf(needle, cursor);
79
- }
80
- if (cursor < text.length) parts.push(text.slice(cursor));
81
- return parts;
67
+ /**
68
+ * A search result as list-row data. The engines return less than a list row
69
+ * holds — no attachment flag, no thread count, no labels — so those fall away
70
+ * rather than being invented. The category is not mapped here: the search
71
+ * carries its own labelled chip, which the row renders in the badge slot.
72
+ */
73
+ function searchResultRowData(result: SearchResult): ThreadRowData {
74
+ return {
75
+ id: result.id,
76
+ fromName: result.sender,
77
+ fromEmail: result.senderEmail ?? result.sender,
78
+ subject: result.subject,
79
+ snippet: result.snippet,
80
+ timeLabel: result.date,
81
+ isRead: !result.unread,
82
+ starred: result.flagged,
83
+ ...(result.mailboxId ? { mailboxId: result.mailboxId } : {}),
84
+ };
82
85
  }
83
86
 
84
87
  /**
85
- * One tappable search result. Mirrors the collapsed reading-pane row rhythm:
86
- * sender + right-aligned date on the top line, the subject, then a one-line
87
- * truncated snippet, with an optional category Badge and a flag indicator. The
88
- * sender bolds when unread. Presentational and prop-driven; the app supplies
89
- * `onClick` and the optional `query` to bold literal matches.
88
+ * One tappable search result the same row the lists render.
89
+ *
90
+ * Search is a mode of the list, not a separate surface, so the row body is the
91
+ * shared `ComfortableRowBody`: the sender avatar, the unread dot, the star and
92
+ * the sender/subject/snippet rhythm all come from one implementation, and a
93
+ * message looks the same whether it was found or scrolled to. Everything only a
94
+ * search knows — the folder a row was read from, the search's own category
95
+ * chip, why a semantic hit matched and how strongly — rides in the row's badge
96
+ * slot. Presentational and prop-driven; the app supplies `onClick` and the
97
+ * optional `query` to bold literal matches.
90
98
  */
91
99
  export function SearchResultRow({
92
100
  result,
@@ -102,61 +110,39 @@ export function SearchResultRow({
102
110
  <button
103
111
  type="button"
104
112
  onClick={onClick}
105
- 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"
113
+ className={cn("group", comfortableRowClass({}), "border-b border-line")}
106
114
  >
107
- <div className="flex items-baseline gap-2">
108
- <span
109
- className={cn(
110
- "min-w-0 flex-1 truncate text-sm",
111
- result.unread
112
- ? "font-semibold text-fg"
113
- : "font-medium text-fg-muted",
114
- )}
115
- >
116
- {result.sender}
117
- </span>
118
- <span className="shrink-0 text-2xs text-fg-subtle tabular-nums">
119
- {result.date}
120
- </span>
121
- </div>
122
- <div className="flex items-center gap-1.5">
123
- <span
124
- className={cn(
125
- "min-w-0 flex-1 truncate text-sm",
126
- result.unread ? "text-fg" : "text-fg-muted",
127
- )}
128
- >
129
- {highlight(result.subject, query)}
130
- </span>
131
- {result.flagged && (
132
- <Flag className="size-3.5 shrink-0 fill-warning text-warning" />
133
- )}
134
- </div>
135
- <div className="flex items-center gap-2">
136
- <span className="min-w-0 flex-1 truncate text-xs text-fg-subtle">
137
- {highlight(result.snippet, query)}
138
- </span>
139
- {folderLabel && (
140
- <Badge tone="neutral" className="shrink-0">
141
- {folderLabel}
142
- </Badge>
143
- )}
144
- {result.category && (
145
- <Badge tone={result.category.tone ?? "neutral"} className="shrink-0">
146
- {result.category.label}
147
- </Badge>
148
- )}
149
- {result.matchedChunkLabel && (
150
- <Badge tone="neutral" className="shrink-0">
151
- {`matched: ${result.matchedChunkLabel}`}
152
- </Badge>
153
- )}
154
- {result.score != null && (
155
- <span className="shrink-0 text-2xs text-fg-subtle tabular-nums">
156
- {result.score.toFixed(2)}
157
- </span>
158
- )}
159
- </div>
115
+ <ComfortableRowBody
116
+ thread={searchResultRowData(result)}
117
+ highlightQuery={query}
118
+ badge={
119
+ <>
120
+ {folderLabel && (
121
+ <Badge tone="neutral" className="shrink-0">
122
+ {folderLabel}
123
+ </Badge>
124
+ )}
125
+ {result.category && (
126
+ <Badge
127
+ tone={result.category.tone ?? "neutral"}
128
+ className="shrink-0"
129
+ >
130
+ {result.category.label}
131
+ </Badge>
132
+ )}
133
+ {result.matchedChunkLabel && (
134
+ <Badge tone="neutral" className="shrink-0">
135
+ {`matched: ${result.matchedChunkLabel}`}
136
+ </Badge>
137
+ )}
138
+ {result.score != null && (
139
+ <span className="shrink-0 text-2xs text-fg-subtle tabular-nums">
140
+ {result.score.toFixed(2)}
141
+ </span>
142
+ )}
143
+ </>
144
+ }
145
+ />
160
146
  </button>
161
147
  );
162
148
  }
@@ -22,6 +22,7 @@ const topMatches: SearchResult[] = [
22
22
  {
23
23
  id: "r1",
24
24
  sender: "Stripe",
25
+ senderEmail: "receipts@stripe.com",
25
26
  subject: "Your invoice for March is ready",
26
27
  snippet: "Invoice #4821 — €149.00 paid on Visa ending 4242.",
27
28
  date: "9:42",
@@ -31,6 +32,7 @@ const topMatches: SearchResult[] = [
31
32
  {
32
33
  id: "r2",
33
34
  sender: "Hetzner Online",
35
+ senderEmail: "billing@hetzner.com",
34
36
  subject: "Invoice 2026-03 available in your account",
35
37
  snippet: "Dear customer, your invoice for the period is attached.",
36
38
  date: "Mar 3",
@@ -39,6 +41,7 @@ const topMatches: SearchResult[] = [
39
41
  {
40
42
  id: "r3",
41
43
  sender: "Anna de Vries",
44
+ senderEmail: "anna@devries.nl",
42
45
  subject: "Re: Q1 invoice approval",
43
46
  snippet: "Approved — can you forward the PDF invoice to finance?",
44
47
  date: "Mar 1",
@@ -46,6 +49,10 @@ const topMatches: SearchResult[] = [
46
49
  },
47
50
  ];
48
51
 
52
+ /**
53
+ * Semantic hits. The index carries no sender address, so these rows have no
54
+ * `senderEmail` — their avatar keys on the display name instead.
55
+ */
49
56
  const related: SearchResult[] = [
50
57
  {
51
58
  id: "r5",
@@ -81,6 +88,7 @@ const crossFolderMatches: SearchResult[] = [
81
88
  {
82
89
  id: "x1",
83
90
  sender: "Mollie",
91
+ senderEmail: "info@mollie.com",
84
92
  subject: "Invoice 2026-02 — archived",
85
93
  snippet: "Filed last month; payment already settled.",
86
94
  date: "Feb 24",
@@ -90,6 +98,7 @@ const crossFolderMatches: SearchResult[] = [
90
98
  {
91
99
  id: "x2",
92
100
  sender: "me",
101
+ senderEmail: "matthijs@example.com",
93
102
  subject: "Re: invoice query",
94
103
  snippet: "Attaching the invoice you asked for.",
95
104
  date: "Feb 18",
@@ -98,6 +107,7 @@ const crossFolderMatches: SearchResult[] = [
98
107
  {
99
108
  id: "x4",
100
109
  sender: "Accountant",
110
+ senderEmail: "jan@boekhouding.example",
101
111
  subject: "Invoices for the quarter",
102
112
  snippet: "The quarterly set, filed with the rest of the bookkeeping.",
103
113
  date: "Jan 30",
@@ -113,6 +123,7 @@ const spamMatches: SearchResult[] = [
113
123
  {
114
124
  id: "s1",
115
125
  sender: "billing@unknown-vendor.test",
126
+ senderEmail: "billing@unknown-vendor.test",
116
127
  subject: "URGENT invoice attached",
117
128
  snippet: "Wire the amount below within 24 hours to avoid suspension.",
118
129
  date: "Feb 11",
@@ -121,6 +132,7 @@ const spamMatches: SearchResult[] = [
121
132
  {
122
133
  id: "s2",
123
134
  sender: "invoices@pay-now.test",
135
+ senderEmail: "invoices@pay-now.test",
124
136
  subject: "Outstanding invoice — final notice",
125
137
  snippet: "Your account is overdue. Settle immediately.",
126
138
  date: "Feb 4",
@@ -90,17 +90,29 @@ export interface SearchResultsProps {
90
90
  * affordance; a `disabledReason` renders it inert with the reason (a search of
91
91
  * only non-clause facets has nothing to convert).
92
92
  */
93
- makeFilter?: { onClick: () => void; disabledReason?: string };
93
+ makeFilter?: MakeFilterActionProps;
94
94
  }
95
95
 
96
- /** "Make this a filter" — the conversion entry offered above active search results. */
97
- function MakeFilterButton({
98
- onClick,
99
- disabledReason,
100
- }: {
96
+ export interface MakeFilterActionProps {
101
97
  onClick: () => void;
98
+ /** Renders the action inert and states why, e.g. nothing in the query converts. */
102
99
  disabledReason?: string;
103
- }) {
100
+ }
101
+
102
+ /**
103
+ * "Make this a filter" — the conversion entry offered while a search is active.
104
+ *
105
+ * A standalone row rather than a part of the results body, because a search is
106
+ * shown in more than one way: the read-only `SearchResults` panel, and a list
107
+ * view whose own rows narrow to the query. The affordance belongs to the search,
108
+ * not to either rendering, so the caller mounts it above whichever body is up and
109
+ * it stays put when the body swaps. `SearchResults` renders it inline as a
110
+ * convenience for callers that show only the panel.
111
+ */
112
+ export function MakeFilterAction({
113
+ onClick,
114
+ disabledReason,
115
+ }: MakeFilterActionProps) {
104
116
  const disabled = disabledReason !== undefined;
105
117
  return (
106
118
  <div className="border-b border-line px-row-inset py-1.5">
@@ -275,7 +287,7 @@ export function SearchResults({
275
287
  <SearchTokenChips tokens={tokens} />
276
288
  );
277
289
  const filterAction = makeFilter && (
278
- <MakeFilterButton
290
+ <MakeFilterAction
279
291
  onClick={makeFilter.onClick}
280
292
  disabledReason={makeFilter.disabledReason}
281
293
  />
@@ -0,0 +1,59 @@
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 { SuggestList, type SuggestListProps } from "./suggest-list.js";
6
+
7
+ const noop = () => {};
8
+
9
+ const render = (overrides: Partial<SuggestListProps> = {}) =>
10
+ renderToString(
11
+ createElement(SuggestList, {
12
+ id: "list",
13
+ suggestions: [
14
+ {
15
+ value: "receipts@stripe.com",
16
+ label: "Stripe",
17
+ hint: "receipts@stripe.com",
18
+ source: "selected",
19
+ },
20
+ { value: "rides@lyft.com" },
21
+ ],
22
+ activeIndex: -1,
23
+ optionId: (index: number) => `list-option-${index}`,
24
+ onPick: noop,
25
+ onHighlight: noop,
26
+ label: "From suggestions",
27
+ ...overrides,
28
+ }),
29
+ );
30
+
31
+ describe("SuggestList", () => {
32
+ it("renders nothing when there is nothing to suggest", () => {
33
+ assert.equal(render({ suggestions: [] }), "");
34
+ });
35
+
36
+ it("renders a labelled listbox of options", () => {
37
+ const html = render();
38
+ assert.match(html, /role="listbox"/);
39
+ assert.match(html, /aria-label="From suggestions"/);
40
+ assert.equal(html.match(/role="option"/g)?.length, 2);
41
+ assert.match(html, /id="list-option-0"/);
42
+ });
43
+
44
+ it("falls back to the value when a suggestion carries no label", () => {
45
+ assert.match(render(), /rides@lyft\.com/);
46
+ });
47
+
48
+ it("shows the hint and where a suggestion came from", () => {
49
+ const html = render();
50
+ assert.match(html, /Stripe/);
51
+ assert.match(html, /selected/);
52
+ });
53
+
54
+ it("marks only the highlighted option as selected", () => {
55
+ const html = render({ activeIndex: 1 });
56
+ assert.equal(html.match(/aria-selected="true"/g)?.length, 1);
57
+ assert.equal(html.match(/aria-selected="false"/g)?.length, 1);
58
+ });
59
+ });
@@ -0,0 +1,96 @@
1
+ import { cn } from "../lib/cn.js";
2
+
3
+ export interface Suggestion {
4
+ /** The value the field takes when this option is picked. */
5
+ value: string;
6
+ /** What the row reads. Defaults to the value. */
7
+ label?: string;
8
+ /** Secondary text on the row — the address behind a display name, say. */
9
+ hint?: string;
10
+ /** Where the suggestion came from, when that is worth saying ("selected"). */
11
+ source?: string;
12
+ }
13
+
14
+ export interface SuggestListProps {
15
+ id: string;
16
+ suggestions: readonly Suggestion[];
17
+ /** The highlighted option, `-1` when the typed value is what stands. */
18
+ activeIndex: number;
19
+ optionId: (index: number) => string;
20
+ onPick: (suggestion: Suggestion) => void;
21
+ onHighlight: (index: number) => void;
22
+ /** Names the list for screen readers. */
23
+ label: string;
24
+ className?: string;
25
+ }
26
+
27
+ /**
28
+ * The suggestion listbox behind a typeahead field — markup only. The open state,
29
+ * the highlight, and the keyboard live in `useSuggestList`, so every typeahead in
30
+ * the app shares one behaviour and one look.
31
+ *
32
+ * The caller places it. Under the field in normal flow is the default worth
33
+ * reaching for on a surface a phone uses: it takes its own space rather than
34
+ * covering the field, so a soft keyboard cannot hide what was typed.
35
+ */
36
+ export function SuggestList({
37
+ id,
38
+ suggestions,
39
+ activeIndex,
40
+ optionId,
41
+ onPick,
42
+ onHighlight,
43
+ label,
44
+ className,
45
+ }: SuggestListProps) {
46
+ if (suggestions.length === 0) return null;
47
+ return (
48
+ <div
49
+ id={id}
50
+ role="listbox"
51
+ aria-label={label}
52
+ className={cn(
53
+ "max-h-44 overflow-y-auto rounded-md border border-line bg-surface py-1 shadow-sm",
54
+ className,
55
+ )}
56
+ >
57
+ {suggestions.map((suggestion, index) => (
58
+ <div
59
+ key={suggestion.value}
60
+ id={optionId(index)}
61
+ role="option"
62
+ // The combobox input keeps focus throughout and points at the
63
+ // highlighted option with aria-activedescendant; an option that took
64
+ // focus itself would close the soft keyboard mid-typing.
65
+ tabIndex={-1}
66
+ aria-selected={index === activeIndex}
67
+ className={cn(
68
+ "flex cursor-pointer items-baseline gap-2 px-3 py-2 text-sm text-fg",
69
+ index === activeIndex && "bg-accent-2-soft",
70
+ )}
71
+ // Pointer-down rather than click: the field must not lose focus and
72
+ // run its blur handling before the pick lands.
73
+ onMouseDown={(event) => {
74
+ event.preventDefault();
75
+ onPick(suggestion);
76
+ }}
77
+ onMouseEnter={() => onHighlight(index)}
78
+ >
79
+ <span className="min-w-0 flex-1 truncate">
80
+ {suggestion.label ?? suggestion.value}
81
+ </span>
82
+ {suggestion.hint && (
83
+ <span className="shrink-0 truncate text-2xs text-fg-subtle">
84
+ {suggestion.hint}
85
+ </span>
86
+ )}
87
+ {suggestion.source && (
88
+ <span className="shrink-0 text-2xs text-fg-subtle">
89
+ {suggestion.source}
90
+ </span>
91
+ )}
92
+ </div>
93
+ ))}
94
+ </div>
95
+ );
96
+ }