@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.
- package/package.json +1 -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 +37 -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
|
@@ -27,14 +27,18 @@ import {
|
|
|
27
27
|
type LabelOption,
|
|
28
28
|
type MatchOperator,
|
|
29
29
|
matchJoinWord,
|
|
30
|
+
matchModeHint,
|
|
31
|
+
matchModeLabel,
|
|
30
32
|
matchOperatorLabel,
|
|
31
33
|
type PreviewCount,
|
|
34
|
+
type RuleMatchMode,
|
|
32
35
|
type RuleScope,
|
|
33
36
|
} from "./filter-rule.js";
|
|
34
37
|
import { Input } from "./input.js";
|
|
35
38
|
import { LabelChip } from "./label-chip.js";
|
|
36
39
|
import { SegmentedControl } from "./segmented-control.js";
|
|
37
40
|
import { Select } from "./select.js";
|
|
41
|
+
import type { Suggestion } from "./suggest-list.js";
|
|
38
42
|
|
|
39
43
|
export interface ClauseEditState {
|
|
40
44
|
mode: "add" | "edit";
|
|
@@ -61,6 +65,14 @@ export interface FilterRuleEditorProps {
|
|
|
61
65
|
* widen still renders, marked inactive.
|
|
62
66
|
*/
|
|
63
67
|
semanticAvailable?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* What the rule matches on, when the surface lets the user choose (RFC 038
|
|
70
|
+
* D2/D3). Present only where both modes are reachable — the Organize flow on
|
|
71
|
+
* a deployment that can serve the widen. Absent leaves the mode control off
|
|
72
|
+
* entirely, which is every other entry point onto this editor.
|
|
73
|
+
*/
|
|
74
|
+
matchMode?: RuleMatchMode;
|
|
75
|
+
onChangeMatchMode?: (mode: RuleMatchMode) => void;
|
|
64
76
|
/**
|
|
65
77
|
* The clause fields the add/edit picker offers, in menu order. Defaults to the
|
|
66
78
|
* whole vocabulary; a consumer narrows it to the fields its deployment can
|
|
@@ -82,6 +94,13 @@ export interface FilterRuleEditorProps {
|
|
|
82
94
|
* filter, which is dropped from the scope toggle.
|
|
83
95
|
*/
|
|
84
96
|
anchorLocked?: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Values worth offering for the clause being edited — the consumer derives
|
|
99
|
+
* them from the field and what has been typed so far (known senders for
|
|
100
|
+
* `From`, their domains for `FromDomain`). Absent leaves the value a plain
|
|
101
|
+
* text box, which is what a surface with nothing to suggest should be.
|
|
102
|
+
*/
|
|
103
|
+
clauseSuggestions?: readonly Suggestion[];
|
|
85
104
|
/** The inline clause form, when adding or editing a clause. */
|
|
86
105
|
clauseEdit?: ClauseEditState;
|
|
87
106
|
onStartAddClause?: () => void;
|
|
@@ -125,6 +144,11 @@ const matchOptions: { value: MatchOperator; label: string }[] = [
|
|
|
125
144
|
{ value: "any", label: matchOperatorLabel("any") },
|
|
126
145
|
];
|
|
127
146
|
|
|
147
|
+
const matchModeOptions: { value: RuleMatchMode; label: string }[] = [
|
|
148
|
+
{ value: "similar", label: matchModeLabel("similar") },
|
|
149
|
+
{ value: "properties", label: matchModeLabel("properties") },
|
|
150
|
+
];
|
|
151
|
+
|
|
128
152
|
const scopeOptions: { value: RuleScope; label: string }[] = [
|
|
129
153
|
{ value: "once", label: "Just once" },
|
|
130
154
|
{ value: "standing", label: "Keep doing this" },
|
|
@@ -450,8 +474,11 @@ export function FilterRuleEditor({
|
|
|
450
474
|
preview,
|
|
451
475
|
notice,
|
|
452
476
|
semanticAvailable = false,
|
|
477
|
+
matchMode,
|
|
478
|
+
onChangeMatchMode,
|
|
453
479
|
clauseFields,
|
|
454
480
|
anchorLocked = false,
|
|
481
|
+
clauseSuggestions,
|
|
455
482
|
clauseEdit,
|
|
456
483
|
onStartAddClause,
|
|
457
484
|
onStartEditClause,
|
|
@@ -497,6 +524,24 @@ export function FilterRuleEditor({
|
|
|
497
524
|
|
|
498
525
|
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4">
|
|
499
526
|
{notice}
|
|
527
|
+
|
|
528
|
+
{matchMode && (
|
|
529
|
+
<section className="space-y-1.5">
|
|
530
|
+
<p className="text-xs font-medium text-fg-muted">Match on</p>
|
|
531
|
+
<SegmentedControl
|
|
532
|
+
name="rule-match-mode"
|
|
533
|
+
size="sm"
|
|
534
|
+
aria-label="What the rule matches on"
|
|
535
|
+
options={matchModeOptions}
|
|
536
|
+
value={matchMode}
|
|
537
|
+
onChange={(value) => onChangeMatchMode?.(value)}
|
|
538
|
+
/>
|
|
539
|
+
<p className="text-2xs text-fg-subtle">
|
|
540
|
+
{matchModeHint(matchMode)}
|
|
541
|
+
</p>
|
|
542
|
+
</section>
|
|
543
|
+
)}
|
|
544
|
+
|
|
500
545
|
<section className="space-y-3">
|
|
501
546
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
502
547
|
{rule.clauses.map((clause, index) => (
|
|
@@ -534,6 +579,7 @@ export function FilterRuleEditor({
|
|
|
534
579
|
{!rule.widen &&
|
|
535
580
|
!anchorLocked &&
|
|
536
581
|
semanticAvailable &&
|
|
582
|
+
matchMode !== "properties" &&
|
|
537
583
|
!clauseEdit && (
|
|
538
584
|
<AddChipButton label="…and similar" onClick={onAddWiden} />
|
|
539
585
|
)}
|
|
@@ -544,6 +590,7 @@ export function FilterRuleEditor({
|
|
|
544
590
|
draft={clauseEdit.draft}
|
|
545
591
|
mode={clauseEdit.mode}
|
|
546
592
|
fields={clauseFields}
|
|
593
|
+
suggestions={clauseSuggestions}
|
|
547
594
|
onChangeField={onChangeDraftField}
|
|
548
595
|
onChangeValue={onChangeDraftValue}
|
|
549
596
|
onSubmit={onSubmitClause}
|
|
@@ -369,6 +369,39 @@ describe("ClauseEditor", () => {
|
|
|
369
369
|
assert.doesNotMatch(html, /registrable domain/i);
|
|
370
370
|
assert.doesNotMatch(html, /List-Id/);
|
|
371
371
|
});
|
|
372
|
+
|
|
373
|
+
it("is a plain text box when there is nothing to suggest", () => {
|
|
374
|
+
const html = render(
|
|
375
|
+
createElement(ClauseEditor, {
|
|
376
|
+
draft: { field: "From", value: "" },
|
|
377
|
+
mode: "add",
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
380
|
+
assert.doesNotMatch(html, /role="listbox"/);
|
|
381
|
+
assert.match(html, /aria-expanded="false"/);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("offers the suggestions under the row, named for the field being edited", () => {
|
|
385
|
+
const html = render(
|
|
386
|
+
createElement(ClauseEditor, {
|
|
387
|
+
draft: { field: "From", value: "" },
|
|
388
|
+
mode: "add",
|
|
389
|
+
suggestions: [
|
|
390
|
+
{
|
|
391
|
+
value: "receipts@stripe.com",
|
|
392
|
+
label: "Stripe",
|
|
393
|
+
hint: "receipts@stripe.com",
|
|
394
|
+
source: "selected",
|
|
395
|
+
},
|
|
396
|
+
{ value: "rides@lyft.com" },
|
|
397
|
+
],
|
|
398
|
+
}),
|
|
399
|
+
);
|
|
400
|
+
assert.match(html, /role="listbox"/);
|
|
401
|
+
assert.match(html, /aria-label="From suggestions"/);
|
|
402
|
+
assert.match(html, /aria-expanded="true"/);
|
|
403
|
+
assert.match(html, /receipts@stripe\.com/);
|
|
404
|
+
});
|
|
372
405
|
});
|
|
373
406
|
|
|
374
407
|
describe("clauseFieldHint", () => {
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* Naming tracks the RFC: rule, clause, widen, scope.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type { Suggestion } from "./suggest-list.js";
|
|
11
|
+
|
|
10
12
|
/**
|
|
11
13
|
* The clause fields (RFC 038 D2). `From`, `Subject`, `HasWords` ship now;
|
|
12
14
|
* `ListId` and `FromDomain` arrive with the vocabulary ticket. Every variant
|
|
@@ -34,6 +36,35 @@ export interface RuleClause {
|
|
|
34
36
|
/** How the clauses combine. Maps to the API's `And` / `Or`. */
|
|
35
37
|
export type MatchOperator = "all" | "any";
|
|
36
38
|
|
|
39
|
+
/**
|
|
40
|
+
* What the rule matches on (RFC 038 D2/D3). `similar` rides the semantic widen
|
|
41
|
+
* off the messages the rule was started from; `properties` uses literal clauses
|
|
42
|
+
* only — sender, subject, list — and needs no vector pipeline at all. A surface
|
|
43
|
+
* that offers both opens on `similar` wherever the deployment can serve it.
|
|
44
|
+
*/
|
|
45
|
+
export type RuleMatchMode = "similar" | "properties";
|
|
46
|
+
|
|
47
|
+
const matchModeLabels: Record<RuleMatchMode, string> = {
|
|
48
|
+
similar: "Anything similar",
|
|
49
|
+
properties: "Its properties",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function matchModeLabel(mode: RuleMatchMode): string {
|
|
53
|
+
return matchModeLabels[mode];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const matchModeHints: Record<RuleMatchMode, string> = {
|
|
57
|
+
similar: "Reads the messages you picked and finds more that read like them.",
|
|
58
|
+
properties:
|
|
59
|
+
"Matches on what the messages say about themselves — sender, subject, list. No reading involved.",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** One line saying what a match mode actually does, so the choice is never a
|
|
63
|
+
* guess (ux.md). */
|
|
64
|
+
export function matchModeHint(mode: RuleMatchMode): string {
|
|
65
|
+
return matchModeHints[mode];
|
|
66
|
+
}
|
|
67
|
+
|
|
37
68
|
/**
|
|
38
69
|
* When the rule stops (RFC 038 D1). `once` is a one-time action, `standing`
|
|
39
70
|
* persists, `until` expires on a picked date.
|
|
@@ -124,6 +155,36 @@ export function clauseFieldHint(field: ClauseField): string | undefined {
|
|
|
124
155
|
return clauseFieldHints[field];
|
|
125
156
|
}
|
|
126
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Whether a clause field matches on message body text, which only the live
|
|
160
|
+
* index-time filter and the semantic widen can read.
|
|
161
|
+
*
|
|
162
|
+
* The vector-free matcher serves `From`/`Subject` from the core thread rows and
|
|
163
|
+
* carries no faithful body, so it rejects a body-text clause outright rather
|
|
164
|
+
* than narrowing the match silently (`assertNoBodyContentClause`,
|
|
165
|
+
* backend/service/organize.ts). A rule with no active widen therefore cannot be
|
|
166
|
+
* counted or applied one-time with such a clause in it — it can only be a
|
|
167
|
+
* standing rule, where the index-time matcher reads the whole body.
|
|
168
|
+
*/
|
|
169
|
+
export function matchesBodyText(field: ClauseField): boolean {
|
|
170
|
+
return field === "HasWords";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Whether the rule's semantic widen is present and evaluable. */
|
|
174
|
+
export function hasActiveWiden(rule: FilterRule): boolean {
|
|
175
|
+
return rule.widen !== undefined && !rule.widen.inactive;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The rule's body-text clauses that nothing on its current match path can read
|
|
180
|
+
* — a `HasWords` chip with no active widen behind it. Empty for every rule the
|
|
181
|
+
* literal matcher can evaluate.
|
|
182
|
+
*/
|
|
183
|
+
export function unreadableBodyClauses(rule: FilterRule): RuleClause[] {
|
|
184
|
+
if (hasActiveWiden(rule)) return [];
|
|
185
|
+
return rule.clauses.filter((clause) => matchesBodyText(clause.field));
|
|
186
|
+
}
|
|
187
|
+
|
|
127
188
|
/** The fields a new clause can be added as, in menu order. */
|
|
128
189
|
export const clauseFieldOrder: ClauseField[] = [
|
|
129
190
|
"From",
|
|
@@ -198,6 +259,11 @@ export function commitBlockedReason(
|
|
|
198
259
|
rule.clauses.length > 0 ||
|
|
199
260
|
(rule.widen !== undefined && !rule.widen.inactive);
|
|
200
261
|
if (!hasMatch) return "Add a clause so the rule has something to match.";
|
|
262
|
+
// A one-time apply runs the vector-free matcher, which cannot read message
|
|
263
|
+
// bodies. Saved as a rule the same clause works, so say that rather than
|
|
264
|
+
// refusing the clause outright.
|
|
265
|
+
if (rule.scope === "once" && unreadableBodyClauses(rule).length > 0)
|
|
266
|
+
return "Applying once can't read message bodies. Save this as a rule instead, or drop the “has the words” clause.";
|
|
201
267
|
if (!rule.moveMailboxId && !rule.labelId)
|
|
202
268
|
return "Pick a folder to move into, or a label to apply.";
|
|
203
269
|
if (
|
|
@@ -252,6 +318,20 @@ export const demoVocabularyRule: FilterRule = {
|
|
|
252
318
|
name: "Python lists",
|
|
253
319
|
};
|
|
254
320
|
|
|
321
|
+
/**
|
|
322
|
+
* The properties mode's opening rule when the selection's senders differ: the
|
|
323
|
+
* part their subjects share, prefilled as an ordinary editable `Subject` chip.
|
|
324
|
+
*/
|
|
325
|
+
export const demoSubjectPrefillRule: FilterRule = {
|
|
326
|
+
clauses: [
|
|
327
|
+
{ id: "c1", field: "Subject", value: "Your receipt from", derived: true },
|
|
328
|
+
],
|
|
329
|
+
matchOperator: "any",
|
|
330
|
+
moveMailboxId: "mbx-receipts",
|
|
331
|
+
scope: "once",
|
|
332
|
+
name: "",
|
|
333
|
+
};
|
|
334
|
+
|
|
255
335
|
export const demoSenderFallbackRule: FilterRule = {
|
|
256
336
|
clauses: [
|
|
257
337
|
{ id: "c1", field: "From", value: "receipts@stripe.com", derived: true },
|
|
@@ -262,3 +342,64 @@ export const demoSenderFallbackRule: FilterRule = {
|
|
|
262
342
|
scope: "standing",
|
|
263
343
|
name: "Receipts",
|
|
264
344
|
};
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Stand-in for what the app offers in a clause value field: the addresses of the
|
|
348
|
+
* messages that were selected, then addresses a lookup knows about. The app
|
|
349
|
+
* derives the same shape from its selection and the address search — the story
|
|
350
|
+
* only needs the shape, not the source.
|
|
351
|
+
*/
|
|
352
|
+
const demoAddressPool: Suggestion[] = [
|
|
353
|
+
{
|
|
354
|
+
value: "receipts@stripe.com",
|
|
355
|
+
label: "Stripe",
|
|
356
|
+
hint: "receipts@stripe.com",
|
|
357
|
+
source: "selected",
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
value: "receipts@lyft.com",
|
|
361
|
+
label: "Lyft",
|
|
362
|
+
hint: "receipts@lyft.com",
|
|
363
|
+
source: "selected",
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
value: "no-reply@booking.com",
|
|
367
|
+
label: "Booking.com",
|
|
368
|
+
hint: "no-reply@booking.com",
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
value: "invoice@digitalocean.com",
|
|
372
|
+
label: "DigitalOcean",
|
|
373
|
+
hint: "invoice@digitalocean.com",
|
|
374
|
+
},
|
|
375
|
+
{ value: "billing@github.com", label: "GitHub", hint: "billing@github.com" },
|
|
376
|
+
];
|
|
377
|
+
|
|
378
|
+
const demoDomainPool: Suggestion[] = [
|
|
379
|
+
{ value: "stripe.com", source: "selected" },
|
|
380
|
+
{ value: "lyft.com", source: "selected" },
|
|
381
|
+
{ value: "booking.com" },
|
|
382
|
+
{ value: "digitalocean.com" },
|
|
383
|
+
{ value: "github.com" },
|
|
384
|
+
];
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The suggestions a story's clause value field offers, matching the app's rule:
|
|
388
|
+
* address fields draw on known senders, every other field is free text, and what
|
|
389
|
+
* is typed narrows the list without ever constraining it.
|
|
390
|
+
*/
|
|
391
|
+
export function demoClauseSuggestions(
|
|
392
|
+
field: ClauseField,
|
|
393
|
+
value: string,
|
|
394
|
+
): Suggestion[] {
|
|
395
|
+
if (field !== "From" && field !== "FromDomain") return [];
|
|
396
|
+
const pool = field === "From" ? demoAddressPool : demoDomainPool;
|
|
397
|
+
const needle = value.trim().toLowerCase();
|
|
398
|
+
return pool.filter(
|
|
399
|
+
(suggestion) =>
|
|
400
|
+
suggestion.value.toLowerCase() !== needle &&
|
|
401
|
+
(needle === "" ||
|
|
402
|
+
suggestion.value.toLowerCase().includes(needle) ||
|
|
403
|
+
(suggestion.label ?? "").toLowerCase().includes(needle)),
|
|
404
|
+
);
|
|
405
|
+
}
|
package/src/components/input.tsx
CHANGED
|
@@ -12,6 +12,11 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
|
12
12
|
* rows where the surrounding container owns the chrome.
|
|
13
13
|
*/
|
|
14
14
|
variant?: InputVariant;
|
|
15
|
+
/**
|
|
16
|
+
* Optional trailing control rendered inside the field, after the input —
|
|
17
|
+
* it takes its own space in the row, so it never covers the typed value.
|
|
18
|
+
*/
|
|
19
|
+
trailing?: ReactNode;
|
|
15
20
|
/** Forwarded to the underlying `<input>`. */
|
|
16
21
|
ref?: Ref<HTMLInputElement>;
|
|
17
22
|
}
|
|
@@ -28,6 +33,7 @@ export function Input({
|
|
|
28
33
|
icon,
|
|
29
34
|
className,
|
|
30
35
|
variant = "default",
|
|
36
|
+
trailing,
|
|
31
37
|
ref,
|
|
32
38
|
...props
|
|
33
39
|
}: InputProps) {
|
|
@@ -39,6 +45,7 @@ export function Input({
|
|
|
39
45
|
className="min-w-0 flex-1 bg-transparent text-fg placeholder:text-fg-subtle outline-none"
|
|
40
46
|
{...props}
|
|
41
47
|
/>
|
|
48
|
+
{trailing}
|
|
42
49
|
</div>
|
|
43
50
|
);
|
|
44
51
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Menu, Search, X } from "lucide-react";
|
|
2
2
|
import { Button } from "./button.js";
|
|
3
3
|
import { SearchBar } from "./search-bar.js";
|
|
4
|
+
import type { SearchFieldSuggest } from "./search-chip-input.js";
|
|
4
5
|
|
|
5
6
|
export interface MailHeaderProps {
|
|
6
7
|
/** Top-row title — the view name, e.g. "Daily brief" or "Inbox". */
|
|
@@ -28,6 +29,13 @@ export interface MailHeaderProps {
|
|
|
28
29
|
* the page never mounts two search inputs competing for the same focus.
|
|
29
30
|
*/
|
|
30
31
|
showSearch?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Completions for what is being typed; see `SearchChipInput`. The list is
|
|
34
|
+
* not rendered here — the header is a fixed-height row, so the consumer
|
|
35
|
+
* renders it in the pane directly below, where it takes its own space
|
|
36
|
+
* instead of covering the field.
|
|
37
|
+
*/
|
|
38
|
+
searchSuggest?: SearchFieldSuggest;
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
/**
|
|
@@ -51,6 +59,7 @@ export function MailHeader({
|
|
|
51
59
|
searchOpen,
|
|
52
60
|
onSearchOpenChange,
|
|
53
61
|
showSearch = true,
|
|
62
|
+
searchSuggest,
|
|
54
63
|
}: MailHeaderProps) {
|
|
55
64
|
const unreadLabel = `${unreadCount.toLocaleString()} unread`;
|
|
56
65
|
const clearSearch = onSearchClear ?? (() => onSearchChange(""));
|
|
@@ -66,6 +75,7 @@ export function MailHeader({
|
|
|
66
75
|
onClear={clearSearch}
|
|
67
76
|
globalFocusKey={false}
|
|
68
77
|
showClearButton={showClearButton}
|
|
78
|
+
suggest={searchSuggest}
|
|
69
79
|
/>
|
|
70
80
|
);
|
|
71
81
|
|
|
@@ -101,6 +101,34 @@ export function CompactRowBody({ thread }: { thread: ThreadRowData }) {
|
|
|
101
101
|
);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Bold the literal (case-insensitive) occurrences of `query` in `text`. Used by
|
|
106
|
+
* the search rows so a match is visible in the subject and snippet it was found
|
|
107
|
+
* in; an absent or blank query leaves the text alone.
|
|
108
|
+
*/
|
|
109
|
+
function highlightMatches(text: string, query?: string): ReactNode {
|
|
110
|
+
const term = query?.trim();
|
|
111
|
+
if (!term) return text;
|
|
112
|
+
const lower = text.toLowerCase();
|
|
113
|
+
const needle = term.toLowerCase();
|
|
114
|
+
const parts: ReactNode[] = [];
|
|
115
|
+
let cursor = 0;
|
|
116
|
+
let match = lower.indexOf(needle, cursor);
|
|
117
|
+
let key = 0;
|
|
118
|
+
while (match !== -1) {
|
|
119
|
+
if (match > cursor) parts.push(text.slice(cursor, match));
|
|
120
|
+
parts.push(
|
|
121
|
+
<mark key={key++} className="bg-transparent font-semibold text-fg">
|
|
122
|
+
{text.slice(match, match + needle.length)}
|
|
123
|
+
</mark>,
|
|
124
|
+
);
|
|
125
|
+
cursor = match + needle.length;
|
|
126
|
+
match = lower.indexOf(needle, cursor);
|
|
127
|
+
}
|
|
128
|
+
if (cursor < text.length) parts.push(text.slice(cursor));
|
|
129
|
+
return parts;
|
|
130
|
+
}
|
|
131
|
+
|
|
104
132
|
/**
|
|
105
133
|
* Text/glyph content block of a comfortable row (everything after the leading
|
|
106
134
|
* avatar/slot). Consumers provide their own leading element (Avatar, checkbox,
|
|
@@ -110,10 +138,13 @@ export function CompactRowBody({ thread }: { thread: ThreadRowData }) {
|
|
|
110
138
|
export function ComfortableRowTextContent({
|
|
111
139
|
thread,
|
|
112
140
|
badge,
|
|
141
|
+
highlightQuery,
|
|
113
142
|
}: {
|
|
114
143
|
thread: ThreadRowData;
|
|
115
144
|
/** Extra chip rendered after the category badge (e.g. an auto-moved indicator). */
|
|
116
145
|
badge?: ReactNode;
|
|
146
|
+
/** Bold this term where it occurs literally in the subject and snippet. */
|
|
147
|
+
highlightQuery?: string;
|
|
117
148
|
}) {
|
|
118
149
|
const unread = !thread.isRead;
|
|
119
150
|
return (
|
|
@@ -146,7 +177,7 @@ export function ComfortableRowTextContent({
|
|
|
146
177
|
unread ? "text-fg" : "text-fg-muted",
|
|
147
178
|
)}
|
|
148
179
|
>
|
|
149
|
-
{thread.subject}
|
|
180
|
+
{highlightMatches(thread.subject, highlightQuery)}
|
|
150
181
|
</span>
|
|
151
182
|
{thread.suspicious && (
|
|
152
183
|
<ShieldAlert className="size-3.5 shrink-0 text-danger" />
|
|
@@ -160,7 +191,7 @@ export function ComfortableRowTextContent({
|
|
|
160
191
|
</span>
|
|
161
192
|
<span className="flex items-center gap-1.5">
|
|
162
193
|
<span className="line-clamp-1 min-w-0 flex-1 text-xs text-fg-subtle">
|
|
163
|
-
{thread.snippet}
|
|
194
|
+
{highlightMatches(thread.snippet, highlightQuery)}
|
|
164
195
|
</span>
|
|
165
196
|
{thread.category && thread.category !== "personal" && (
|
|
166
197
|
<Badge tone={categoryTone[thread.category]} className="shrink-0">
|
|
@@ -264,10 +295,13 @@ export function ComfortableRowBody({
|
|
|
264
295
|
thread,
|
|
265
296
|
selection,
|
|
266
297
|
badge,
|
|
298
|
+
highlightQuery,
|
|
267
299
|
}: {
|
|
268
300
|
thread: ThreadRowData;
|
|
269
301
|
selection?: RowSelection;
|
|
270
302
|
badge?: ReactNode;
|
|
303
|
+
/** Bold this term where it occurs literally in the subject and snippet. */
|
|
304
|
+
highlightQuery?: string;
|
|
271
305
|
}) {
|
|
272
306
|
const unread = !thread.isRead;
|
|
273
307
|
return (
|
|
@@ -276,7 +310,11 @@ export function ComfortableRowBody({
|
|
|
276
310
|
<span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
|
|
277
311
|
)}
|
|
278
312
|
<ComfortableRowLeading thread={thread} selection={selection} />
|
|
279
|
-
<ComfortableRowTextContent
|
|
313
|
+
<ComfortableRowTextContent
|
|
314
|
+
thread={thread}
|
|
315
|
+
badge={badge}
|
|
316
|
+
highlightQuery={highlightQuery}
|
|
317
|
+
/>
|
|
280
318
|
</>
|
|
281
319
|
);
|
|
282
320
|
}
|
|
@@ -37,6 +37,37 @@ const base = {
|
|
|
37
37
|
sections,
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
const filter = {
|
|
41
|
+
categories: [{ id: "all", label: "All" }],
|
|
42
|
+
filters: [{ id: "unread", label: "Unread" }],
|
|
43
|
+
selectedCategory: "all",
|
|
44
|
+
activeFilters: new Set<string>(),
|
|
45
|
+
onSelectCategory: noop,
|
|
46
|
+
onToggleFilter: noop,
|
|
47
|
+
onClear: noop,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe("MobileSearchView filter chrome", () => {
|
|
51
|
+
it("gives the filter row up to the search once a query is typed", () => {
|
|
52
|
+
const html = renderToString(
|
|
53
|
+
createElement(MobileSearchView, { ...base, filter }),
|
|
54
|
+
);
|
|
55
|
+
assert.doesNotMatch(html, /Expand filters/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("keeps the filter row while the field is empty", () => {
|
|
59
|
+
const html = renderToString(
|
|
60
|
+
createElement(MobileSearchView, {
|
|
61
|
+
...base,
|
|
62
|
+
value: "",
|
|
63
|
+
sections: [],
|
|
64
|
+
filter,
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
assert.match(html, /Expand filters/);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
40
71
|
describe("MobileSearchView search scope", () => {
|
|
41
72
|
it("offers held-out spam on the phone tier too", () => {
|
|
42
73
|
const html = renderToString(
|