@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.
- package/package.json +2 -1
- package/src/components/app-shell-types.ts +8 -1
- package/src/components/auto-moved-badge.stories.tsx +3 -21
- package/src/components/auto-moved-badge.tsx +8 -17
- package/src/components/brief-empty.stories.tsx +51 -0
- package/src/components/brief-empty.tsx +60 -0
- package/src/components/dialog.tsx +10 -5
- package/src/components/filter-clause-chip.tsx +39 -1
- package/src/components/filter-rule-editor.stories.tsx +163 -1
- package/src/components/filter-rule-editor.tsx +47 -0
- package/src/components/filter-rule.render.test.ts +33 -0
- package/src/components/filter-rule.ts +141 -0
- package/src/components/input.tsx +7 -0
- package/src/components/mail-header.tsx +10 -0
- package/src/components/message-row.tsx +41 -3
- package/src/components/mobile-search-view.render.test.ts +31 -0
- package/src/components/mobile-search-view.stories.tsx +115 -6
- package/src/components/mobile-search-view.tsx +34 -8
- package/src/components/password-input.render.test.ts +149 -0
- package/src/components/password-input.tsx +45 -0
- package/src/components/primitives.stories.tsx +28 -0
- package/src/components/search-bar.tsx +9 -0
- package/src/components/search-chip-input.tsx +85 -3
- package/src/components/search-result-row.tsx +68 -82
- package/src/components/search-results.stories.tsx +12 -0
- package/src/components/search-results.tsx +20 -8
- package/src/components/suggest-list.render.test.ts +59 -0
- package/src/components/suggest-list.tsx +96 -0
- package/src/index.ts +55 -0
- package/src/lib/property-prefill.test.ts +173 -0
- package/src/lib/property-prefill.ts +151 -0
- package/src/lib/search-rule.test.ts +73 -0
- package/src/lib/search-rule.ts +96 -0
- package/src/lib/sender-derivation.test.ts +106 -0
- package/src/lib/sender-derivation.ts +85 -0
- package/src/lib/suggest-keys.test.ts +101 -0
- package/src/lib/suggest-keys.ts +63 -0
- package/src/lib/use-long-press.ts +70 -37
- package/src/lib/use-suggest-list.test.ts +169 -0
- 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) =>
|
|
405
|
+
onChange={(e) => {
|
|
406
|
+
onChange(e.target.value);
|
|
407
|
+
reportCaret(e.target);
|
|
408
|
+
}}
|
|
337
409
|
onKeyDown={handleInputKeyDown}
|
|
338
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* sender
|
|
89
|
-
*
|
|
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="
|
|
113
|
+
className={cn("group", comfortableRowClass({}), "border-b border-line")}
|
|
106
114
|
>
|
|
107
|
-
<
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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?:
|
|
93
|
+
makeFilter?: MakeFilterActionProps;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
|
|
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
|
-
<
|
|
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
|
+
}
|