@remit/ui 0.0.2 → 0.0.4

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.
@@ -0,0 +1,364 @@
1
+ import { Search, X } from "lucide-react";
2
+ import { useCallback, useEffect, useId, useRef, useState } from "react";
3
+ import { cn } from "../lib/cn.js";
4
+ import {
5
+ type ChipFocusTarget,
6
+ focusAfterRemoval,
7
+ resolveChipInputKey,
8
+ resolveChipKey,
9
+ } from "./search-chip-keys.js";
10
+ import { SearchChipRow, type SearchChipTone } from "./search-token-chip.js";
11
+
12
+ /**
13
+ * One narrowing term of the search expression, pinned ahead of the free text.
14
+ * `id` is the removal handle — stable across renders, and what the host keys
15
+ * its own token state by.
16
+ */
17
+ export interface SearchChip {
18
+ id: string;
19
+ label: string;
20
+ /** `scope` marks the view the user navigated into. See `SearchChipTone`. */
21
+ tone?: SearchChipTone;
22
+ }
23
+
24
+ export interface SearchChipInputProps {
25
+ /**
26
+ * The narrowing terms, in expression order. Chips are host-owned: this
27
+ * component never turns typed text into a chip (see the module note on chip
28
+ * creation).
29
+ */
30
+ chips?: readonly SearchChip[];
31
+ onRemoveChip?: (id: string) => void;
32
+ /** Opens a chip's own value editor, where the host offers one. */
33
+ onActivateChip?: (id: string) => void;
34
+ /** The free text alongside the chips. */
35
+ value: string;
36
+ onChange: (value: string) => void;
37
+ /**
38
+ * Full clear (X button): the consumer resets the query AND any open thread
39
+ * so the view returns to the plain list with nothing pre-opened (#538).
40
+ */
41
+ onClear: () => void;
42
+ /**
43
+ * Query-only clear (Esc key): resets just the query and leaves any open
44
+ * thread untouched — one keypress, one effect (#489). Falls back to
45
+ * `onClear` when omitted.
46
+ */
47
+ onClearQuery?: () => void;
48
+ placeholder?: string;
49
+ /**
50
+ * Bind the global "/" shortcut that focuses the field from anywhere on the
51
+ * page. Defaults to true; set false where a page hosts more than one bar.
52
+ */
53
+ globalFocusKey?: boolean;
54
+ /**
55
+ * Show the inline clear (X) at the end of the field. Defaults to true. Set
56
+ * false in the mobile takeover, where a single outer X owns clear-and-close
57
+ * so there is exactly one X (Esc still clears the query).
58
+ */
59
+ showClearButton?: boolean;
60
+ /**
61
+ * `sm` (default) is the compact field used in list headers and the mobile
62
+ * takeover. `lg` is the taller, rounder field the global top bar wants.
63
+ */
64
+ size?: "sm" | "lg";
65
+ /**
66
+ * DOM id of the text input. Defaults to a generated, per-instance id — a
67
+ * fixed default would collide wherever two fields are mounted at once, and
68
+ * the enclosing <label for> would then aim at the wrong one. Pass this only
69
+ * when something outside needs to address the field by a stable id.
70
+ */
71
+ inputId?: string;
72
+ /** Accessible name for the chip grid. */
73
+ chipsLabel?: string;
74
+ className?: string;
75
+ }
76
+
77
+ const isEditableTarget = (target: EventTarget | null): boolean => {
78
+ if (!(target instanceof HTMLElement)) return false;
79
+ return (
80
+ target.tagName === "INPUT" ||
81
+ target.tagName === "TEXTAREA" ||
82
+ target.isContentEditable
83
+ );
84
+ };
85
+
86
+ /**
87
+ * The search field as one editable expression: removable chips inline ahead of
88
+ * the free text, a single focus ring around the whole thing, one Tab stop.
89
+ *
90
+ * **Chip creation.** This component does not create chips. Typing `in:spam`
91
+ * leaves plain text; chips arrive from structured intent the host commits — the
92
+ * view being navigated to, a filter menu, a suggestion. Keeping the parse on
93
+ * the host side means the field never has to guess whether text was meant as an
94
+ * operator, and the host owns the one requirement that matters: chips plus the
95
+ * remaining text must serialise back to exactly the query the user meant.
96
+ *
97
+ * **Keyboard.** Focus roves between the text input and the chips, and the
98
+ * meaning of a key follows it:
99
+ * - Backspace (or ArrowLeft) with the caret at the start of the text moves
100
+ * focus onto the preceding chip. Backspace again removes it — two presses,
101
+ * so a term is never lost to a stray keystroke.
102
+ * - On a focused chip: Backspace/Delete removes, Left/Right walk the chips,
103
+ * Right past the last one returns to the text, Escape returns to the text.
104
+ * - Shift+Tab from the text steps back into the chips rather than leaving.
105
+ * - After a removal, focus lands on the chip that took its place, else the
106
+ * one before it, else the text input.
107
+ *
108
+ * Removal is never keyboard-only: every chip carries a remove button, which is
109
+ * what touch and soft-keyboard users need (a soft keyboard gives no reliable
110
+ * Backspace-into-chip signal).
111
+ *
112
+ * **Semantics.** The chips form a `grid` of one-`row`-per-chip, each with a
113
+ * label `gridcell` and a remove `gridcell`, and the text input is a sibling of
114
+ * that grid. ARIA has no chips/tokens pattern, so this follows Material Design
115
+ * 3's chip accessibility guidance and Angular Material's `mat-chip-grid` — an
116
+ * adaptation, to be settled by screen-reader testing rather than role names.
117
+ */
118
+ export const SearchChipInput = ({
119
+ chips = [],
120
+ onRemoveChip,
121
+ onActivateChip,
122
+ value,
123
+ onChange,
124
+ onClear,
125
+ onClearQuery,
126
+ placeholder = "Search mail...",
127
+ globalFocusKey = true,
128
+ showClearButton = true,
129
+ size = "sm",
130
+ inputId,
131
+ chipsLabel = "Search filters",
132
+ className,
133
+ }: SearchChipInputProps) => {
134
+ // The field wraps itself in a <label for>, and `for` binds to the FIRST
135
+ // matching id in tree order. A shared default would therefore aim every
136
+ // mounted field's label at whichever one rendered first — the desktop layout
137
+ // mounts two at once. A generated id per instance makes that unrepresentable.
138
+ const generatedId = useId();
139
+ const resolvedInputId = inputId ?? generatedId;
140
+ const inputRef = useRef<HTMLInputElement>(null);
141
+ const chipRefs = useRef<(HTMLButtonElement | null)[]>([]);
142
+ const clearQuery = onClearQuery ?? onClear;
143
+ /** Which chip holds focus; null means the text input does. */
144
+ const [focusedChip, setFocusedChip] = useState<ChipFocusTarget>(null);
145
+ /** Where focus must be moved after the next render, if anywhere. */
146
+ const pendingFocus = useRef<ChipFocusTarget | undefined>(undefined);
147
+ const [announcement, setAnnouncement] = useState("");
148
+
149
+ const hasChips = chips.length > 0;
150
+
151
+ // A chip list that shrinks from under the focused index (the host removed one,
152
+ // or the route changed) must not strand the roving tab order out of bounds.
153
+ useEffect(() => {
154
+ setFocusedChip((current) =>
155
+ current !== null && current >= chips.length ? null : current,
156
+ );
157
+ }, [chips.length]);
158
+
159
+ // Focus moves are queued during the keydown and applied once the new chip set
160
+ // has rendered, so the element being focused actually exists.
161
+ useEffect(() => {
162
+ const target = pendingFocus.current;
163
+ if (target === undefined) return;
164
+ pendingFocus.current = undefined;
165
+ if (target === null) {
166
+ inputRef.current?.focus();
167
+ return;
168
+ }
169
+ chipRefs.current[target]?.focus();
170
+ });
171
+
172
+ const moveFocus = useCallback((target: ChipFocusTarget) => {
173
+ setFocusedChip(target);
174
+ pendingFocus.current = target;
175
+ }, []);
176
+
177
+ const removeChipAt = useCallback(
178
+ (index: number) => {
179
+ const chip = chips[index];
180
+ if (!chip) return;
181
+ // Chips are host-owned, so with no removal handler the chip stays put.
182
+ // Announcing it gone and moving focus as though it had would describe a
183
+ // removal that never happened.
184
+ if (!onRemoveChip) return;
185
+ moveFocus(focusAfterRemoval(index, chips.length));
186
+ setAnnouncement(`${chip.label} removed`);
187
+ onRemoveChip(chip.id);
188
+ },
189
+ [chips, onRemoveChip, moveFocus],
190
+ );
191
+
192
+ const handleClear = useCallback(() => {
193
+ moveFocus(null);
194
+ onClear();
195
+ }, [onClear, moveFocus]);
196
+
197
+ const handleInputKeyDown = useCallback(
198
+ (event: React.KeyboardEvent<HTMLInputElement>) => {
199
+ const input = event.currentTarget;
200
+ const action = resolveChipInputKey({
201
+ key: event.key,
202
+ shiftKey: event.shiftKey,
203
+ repeat: event.repeat,
204
+ caretAtStart: input.selectionStart === 0 && input.selectionEnd === 0,
205
+ hasValue: value.length > 0,
206
+ chipCount: chips.length,
207
+ });
208
+
209
+ switch (action.type) {
210
+ case "focusChip":
211
+ event.preventDefault();
212
+ moveFocus(action.index);
213
+ return;
214
+ case "clearQuery":
215
+ clearQuery();
216
+ return;
217
+ case "blur":
218
+ input.blur();
219
+ return;
220
+ case "none":
221
+ return;
222
+ }
223
+ },
224
+ [chips.length, value, clearQuery, moveFocus],
225
+ );
226
+
227
+ const handleChipKeyDown = useCallback(
228
+ (index: number) => (event: React.KeyboardEvent) => {
229
+ const action = resolveChipKey({
230
+ key: event.key,
231
+ repeat: event.repeat,
232
+ index,
233
+ chipCount: chips.length,
234
+ });
235
+ if (action.type !== "none") event.preventDefault();
236
+
237
+ switch (action.type) {
238
+ case "removeChip":
239
+ removeChipAt(action.index);
240
+ return;
241
+ case "focusChip":
242
+ moveFocus(action.index);
243
+ return;
244
+ case "focusInput":
245
+ moveFocus(null);
246
+ return;
247
+ case "activateChip": {
248
+ const chip = chips[action.index];
249
+ if (chip && onActivateChip) onActivateChip(chip.id);
250
+ return;
251
+ }
252
+ case "none":
253
+ return;
254
+ }
255
+ },
256
+ [chips, removeChipAt, moveFocus, onActivateChip],
257
+ );
258
+
259
+ useEffect(() => {
260
+ if (!globalFocusKey) return;
261
+ const handleGlobalSlash = (event: KeyboardEvent) => {
262
+ if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) {
263
+ return;
264
+ }
265
+ if (isEditableTarget(event.target)) return;
266
+ event.preventDefault();
267
+ inputRef.current?.focus();
268
+ };
269
+ window.addEventListener("keydown", handleGlobalSlash);
270
+ return () => window.removeEventListener("keydown", handleGlobalSlash);
271
+ }, [globalFocusKey]);
272
+
273
+ return (
274
+ // A <label> so pressing the field's own padding puts the caret in the text,
275
+ // natively and without a handler. Presses on the chips' buttons are
276
+ // interactive descendants, so they act on the chip instead. The input's
277
+ // `aria-label` still wins the accessible name, so chip text never leaks
278
+ // into it.
279
+ <label
280
+ htmlFor={resolvedInputId}
281
+ className={cn(
282
+ "flex w-full items-center gap-1.5 text-sm",
283
+ "bg-surface-sunken/50 border border-transparent",
284
+ "focus-within:bg-canvas focus-within:border-line focus-within:ring-2 focus-within:ring-ring",
285
+ "transition-colors",
286
+ size === "lg" ? "rounded-xl px-4 py-2" : "rounded-md px-2.5 py-1",
287
+ className,
288
+ )}
289
+ >
290
+ <Search
291
+ className={cn(
292
+ "shrink-0 text-fg-muted pointer-events-none",
293
+ size === "lg" ? "size-5" : "size-4",
294
+ )}
295
+ />
296
+ {/* Chips wrap onto further lines rather than stacking or clipping, and
297
+ the text input is a sibling of the grid, not a cell inside it. */}
298
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
299
+ {hasChips && (
300
+ // Not tabular data, and <table> cannot live inside a text field. See
301
+ // SearchChipRow for why grid is the pattern being adapted here.
302
+ // biome-ignore lint/a11y/useSemanticElements: see above
303
+ <div
304
+ role="grid"
305
+ aria-label={chipsLabel}
306
+ className="flex min-w-0 flex-wrap items-center gap-1"
307
+ >
308
+ {chips.map((chip, index) => (
309
+ <SearchChipRow
310
+ key={chip.id}
311
+ ref={(node) => {
312
+ chipRefs.current[index] = node;
313
+ }}
314
+ label={chip.label}
315
+ tone={chip.tone}
316
+ focused={focusedChip === index}
317
+ onFocusLabel={() => setFocusedChip(index)}
318
+ onKeyDown={handleChipKeyDown(index)}
319
+ onRemove={() => removeChipAt(index)}
320
+ onActivate={
321
+ onActivateChip ? () => onActivateChip(chip.id) : undefined
322
+ }
323
+ />
324
+ ))}
325
+ </div>
326
+ )}
327
+ <input
328
+ ref={inputRef}
329
+ id={resolvedInputId}
330
+ name="q"
331
+ type="text"
332
+ aria-label="Search mail"
333
+ autoComplete="off"
334
+ value={value}
335
+ tabIndex={focusedChip === null ? 0 : -1}
336
+ onChange={(e) => onChange(e.target.value)}
337
+ onKeyDown={handleInputKeyDown}
338
+ onFocus={() => setFocusedChip(null)}
339
+ // Once the expression carries chips the field is self-describing; a
340
+ // placeholder there would read as another term.
341
+ placeholder={hasChips ? undefined : placeholder}
342
+ className="min-w-24 flex-1 bg-transparent text-fg outline-none placeholder:text-fg-muted"
343
+ />
344
+ </div>
345
+ {(value || hasChips) && showClearButton && (
346
+ <button
347
+ type="button"
348
+ onClick={handleClear}
349
+ className="shrink-0 rounded p-0.5 hover:bg-surface-raised transition-colors"
350
+ aria-label="Clear search"
351
+ >
352
+ <X
353
+ className={cn("text-fg-muted", size === "lg" ? "size-5" : "size-4")}
354
+ />
355
+ </button>
356
+ )}
357
+ {/* A removal that says nothing is the most common failure of this
358
+ pattern, so every one is announced. */}
359
+ <span role="status" aria-live="polite" className="sr-only">
360
+ {announcement}
361
+ </span>
362
+ </label>
363
+ );
364
+ };
@@ -0,0 +1,206 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ type ChipInputKeyState,
5
+ type ChipKeyState,
6
+ focusAfterRemoval,
7
+ resolveChipInputKey,
8
+ resolveChipKey,
9
+ } from "./search-chip-keys.js";
10
+
11
+ const inText = (
12
+ overrides: Partial<ChipInputKeyState> = {},
13
+ ): ChipInputKeyState => ({
14
+ key: "a",
15
+ caretAtStart: true,
16
+ hasValue: false,
17
+ chipCount: 1,
18
+ ...overrides,
19
+ });
20
+
21
+ const onChip = (overrides: Partial<ChipKeyState> = {}): ChipKeyState => ({
22
+ key: "Backspace",
23
+ index: 0,
24
+ chipCount: 1,
25
+ ...overrides,
26
+ });
27
+
28
+ describe("Removing a chip by keyboard takes two presses, never one", () => {
29
+ it("first Backspace at the start of the text moves focus onto the last chip", () => {
30
+ assert.deepEqual(resolveChipInputKey(inText({ key: "Backspace" })), {
31
+ type: "focusChip",
32
+ index: 0,
33
+ });
34
+ });
35
+
36
+ it("second Backspace — now on the chip — removes it", () => {
37
+ assert.deepEqual(resolveChipKey(onChip({ key: "Backspace" })), {
38
+ type: "removeChip",
39
+ index: 0,
40
+ });
41
+ });
42
+
43
+ it("Delete on a focused chip removes it too", () => {
44
+ assert.deepEqual(resolveChipKey(onChip({ key: "Delete" })), {
45
+ type: "removeChip",
46
+ index: 0,
47
+ });
48
+ });
49
+
50
+ it("leaves ordinary text editing alone when the caret is not at the start", () => {
51
+ assert.deepEqual(
52
+ resolveChipInputKey(
53
+ inText({ key: "Backspace", caretAtStart: false, hasValue: true }),
54
+ ),
55
+ { type: "none" },
56
+ );
57
+ });
58
+
59
+ it("does nothing at the start of the text when there are no chips", () => {
60
+ assert.deepEqual(
61
+ resolveChipInputKey(inText({ key: "Backspace", chipCount: 0 })),
62
+ { type: "none" },
63
+ );
64
+ });
65
+ });
66
+
67
+ describe("Focus walks between the text and the chips", () => {
68
+ it("ArrowLeft at the start of the text steps onto the last chip", () => {
69
+ assert.deepEqual(
70
+ resolveChipInputKey(inText({ key: "ArrowLeft", chipCount: 3 })),
71
+ { type: "focusChip", index: 2 },
72
+ );
73
+ });
74
+
75
+ it("Shift+Tab steps back into the chips rather than leaving the field", () => {
76
+ assert.deepEqual(
77
+ resolveChipInputKey(
78
+ inText({
79
+ key: "Tab",
80
+ shiftKey: true,
81
+ chipCount: 3,
82
+ caretAtStart: false,
83
+ }),
84
+ ),
85
+ { type: "focusChip", index: 2 },
86
+ );
87
+ });
88
+
89
+ it("Shift+Tab leaves the field when there are no chips to step into", () => {
90
+ assert.deepEqual(
91
+ resolveChipInputKey(inText({ key: "Tab", shiftKey: true, chipCount: 0 })),
92
+ { type: "none" },
93
+ );
94
+ });
95
+
96
+ it("ArrowLeft walks to the previous chip", () => {
97
+ assert.deepEqual(
98
+ resolveChipKey(onChip({ key: "ArrowLeft", index: 2, chipCount: 3 })),
99
+ { type: "focusChip", index: 1 },
100
+ );
101
+ });
102
+
103
+ it("ArrowLeft stops at the first chip", () => {
104
+ assert.deepEqual(
105
+ resolveChipKey(onChip({ key: "ArrowLeft", index: 0, chipCount: 3 })),
106
+ { type: "none" },
107
+ );
108
+ });
109
+
110
+ it("ArrowRight walks to the next chip", () => {
111
+ assert.deepEqual(
112
+ resolveChipKey(onChip({ key: "ArrowRight", index: 0, chipCount: 3 })),
113
+ { type: "focusChip", index: 1 },
114
+ );
115
+ });
116
+
117
+ it("ArrowRight past the last chip returns to the text", () => {
118
+ assert.deepEqual(
119
+ resolveChipKey(onChip({ key: "ArrowRight", index: 2, chipCount: 3 })),
120
+ { type: "focusInput" },
121
+ );
122
+ });
123
+
124
+ it("Escape on a chip returns to the text", () => {
125
+ assert.deepEqual(resolveChipKey(onChip({ key: "Escape" })), {
126
+ type: "focusInput",
127
+ });
128
+ });
129
+
130
+ it("Enter and Space activate the focused chip", () => {
131
+ for (const key of ["Enter", " "]) {
132
+ assert.deepEqual(
133
+ resolveChipKey(onChip({ key, index: 1, chipCount: 3 })),
134
+ {
135
+ type: "activateChip",
136
+ index: 1,
137
+ },
138
+ );
139
+ }
140
+ });
141
+ });
142
+
143
+ describe("Escape in the text unwinds the query before the field", () => {
144
+ it("clears the typed text first", () => {
145
+ assert.deepEqual(
146
+ resolveChipInputKey(inText({ key: "Escape", hasValue: true })),
147
+ { type: "clearQuery" },
148
+ );
149
+ });
150
+
151
+ it("blurs once the text is already empty", () => {
152
+ assert.deepEqual(resolveChipInputKey(inText({ key: "Escape" })), {
153
+ type: "blur",
154
+ });
155
+ });
156
+ });
157
+
158
+ describe("Focus after a removal never falls off the field", () => {
159
+ it("lands on the chip that took the removed one's place", () => {
160
+ assert.equal(focusAfterRemoval(0, 3), 0);
161
+ });
162
+
163
+ it("falls back to the preceding chip when the last one goes", () => {
164
+ assert.equal(focusAfterRemoval(2, 3), 1);
165
+ });
166
+
167
+ it("returns to the text input once the last chip is gone", () => {
168
+ assert.equal(focusAfterRemoval(0, 1), null);
169
+ });
170
+ });
171
+
172
+ describe("Auto-repeat cannot collapse the two-press rule into one held key", () => {
173
+ it("stops at the chips when a held Backspace has just emptied the text", () => {
174
+ assert.deepEqual(
175
+ resolveChipInputKey(inText({ key: "Backspace", repeat: true })),
176
+ { type: "none" },
177
+ );
178
+ });
179
+
180
+ it("still crosses into the chips on a deliberate press", () => {
181
+ assert.deepEqual(
182
+ resolveChipInputKey(inText({ key: "Backspace", repeat: false })),
183
+ { type: "focusChip", index: 0 },
184
+ );
185
+ });
186
+
187
+ it("removes one chip per press, never a strip on a held key", () => {
188
+ assert.deepEqual(
189
+ resolveChipKey(onChip({ key: "Backspace", repeat: true, chipCount: 3 })),
190
+ { type: "none" },
191
+ );
192
+ assert.deepEqual(
193
+ resolveChipKey(onChip({ key: "Delete", repeat: true, chipCount: 3 })),
194
+ { type: "none" },
195
+ );
196
+ });
197
+
198
+ it("leaves arrow-key repeat alone — walking the chips is not destructive", () => {
199
+ assert.deepEqual(
200
+ resolveChipKey(
201
+ onChip({ key: "ArrowLeft", repeat: true, index: 2, chipCount: 3 }),
202
+ ),
203
+ { type: "focusChip", index: 1 },
204
+ );
205
+ });
206
+ });