@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
package/src/index.ts CHANGED
@@ -71,6 +71,11 @@ export {
71
71
  BottomSheet,
72
72
  type BottomSheetProps,
73
73
  } from "./components/bottom-sheet.js";
74
+ export {
75
+ BriefEmpty,
76
+ type BriefEmptyProps,
77
+ type BriefSyncProgress,
78
+ } from "./components/brief-empty.js";
74
79
  export {
75
80
  BriefSection,
76
81
  type BriefSectionProps,
@@ -143,23 +148,31 @@ export {
143
148
  clauseFieldOrder,
144
149
  commitBlockedReason,
145
150
  commitLabel,
151
+ demoClauseSuggestions,
146
152
  demoFolders,
147
153
  demoLabels,
148
154
  demoRule,
149
155
  demoSenderFallbackRule,
156
+ demoSubjectPrefillRule,
150
157
  demoVocabularyRule,
151
158
  type FilterRule,
152
159
  type FolderOption,
160
+ hasActiveWiden,
153
161
  type LabelOption,
154
162
  type MatchOperator,
163
+ matchesBodyText,
155
164
  matchJoinWord,
165
+ matchModeHint,
166
+ matchModeLabel,
156
167
  matchOperatorLabel,
157
168
  type PreviewCount,
158
169
  previewCountSummary,
159
170
  type RuleClause,
171
+ type RuleMatchMode,
160
172
  type RuleScope,
161
173
  type RuleWiden,
162
174
  scopeLabel,
175
+ unreadableBodyClauses,
163
176
  widenChipLabel,
164
177
  } from "./components/filter-rule.js";
165
178
  export {
@@ -302,6 +315,10 @@ export {
302
315
  type OutboxStatusBadgeProps,
303
316
  outboxStatusConfig,
304
317
  } from "./components/outbox-status-badge.js";
318
+ export {
319
+ PasswordInput,
320
+ type PasswordInputProps,
321
+ } from "./components/password-input.js";
305
322
  export {
306
323
  PopoverMenu,
307
324
  type PopoverMenuItem,
@@ -397,9 +414,11 @@ export {
397
414
  } from "./components/row-actions.js";
398
415
  export { SearchBar, type SearchBarProps } from "./components/search-bar.js";
399
416
  export {
417
+ type SearchCaretRequest,
400
418
  type SearchChip,
401
419
  SearchChipInput,
402
420
  type SearchChipInputProps,
421
+ type SearchFieldSuggest,
403
422
  } from "./components/search-chip-input.js";
404
423
  export {
405
424
  DROPPED_SEMANTIC_COPY,
@@ -419,6 +438,8 @@ export {
419
438
  type SearchResultTone,
420
439
  } from "./components/search-result-row.js";
421
440
  export {
441
+ MakeFilterAction,
442
+ type MakeFilterActionProps,
422
443
  partitionSpamResults,
423
444
  type SearchResultSection,
424
445
  SearchResults,
@@ -523,6 +544,11 @@ export {
523
544
  SpamResultsOffer,
524
545
  type SpamResultsOfferProps,
525
546
  } from "./components/spam-results-offer.js";
547
+ export {
548
+ type Suggestion,
549
+ SuggestList,
550
+ type SuggestListProps,
551
+ } from "./components/suggest-list.js";
526
552
  export {
527
553
  commitPeek,
528
554
  SwipeableRow,
@@ -580,8 +606,19 @@ export {
580
606
  type UseRovingFocusOptions,
581
607
  useRovingFocus,
582
608
  } from "./lib/roving-focus.js";
609
+ export {
610
+ type SuggestAction,
611
+ type SuggestKeyState,
612
+ suggestKeyAction,
613
+ } from "./lib/suggest-keys.js";
583
614
  export {
584
615
  type UseLongPressOptions,
585
616
  type UseLongPressResult,
586
617
  useLongPress,
587
618
  } from "./lib/use-long-press.js";
619
+ export {
620
+ type ComboboxProps,
621
+ type SuggestListState,
622
+ type UseSuggestListInput,
623
+ useSuggestList,
624
+ } from "./lib/use-suggest-list.js";
@@ -0,0 +1,101 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { suggestKeyAction } from "./suggest-keys.js";
4
+
5
+ const state = (overrides: Partial<Parameters<typeof suggestKeyAction>[0]>) => ({
6
+ key: "ArrowDown",
7
+ open: true,
8
+ count: 3,
9
+ activeIndex: -1,
10
+ ...overrides,
11
+ });
12
+
13
+ describe("suggestKeyAction", () => {
14
+ it("consumes nothing while the list is closed", () => {
15
+ for (const key of ["ArrowDown", "ArrowUp", "Enter", "Escape"]) {
16
+ assert.deepEqual(suggestKeyAction(state({ key, open: false })), {
17
+ type: "none",
18
+ });
19
+ }
20
+ });
21
+
22
+ it("consumes nothing when the list is empty", () => {
23
+ assert.deepEqual(suggestKeyAction(state({ count: 0 })), { type: "none" });
24
+ });
25
+
26
+ it("moves down from the typed value into the first option", () => {
27
+ assert.deepEqual(suggestKeyAction(state({ key: "ArrowDown" })), {
28
+ type: "move",
29
+ index: 0,
30
+ });
31
+ });
32
+
33
+ it("wraps down off the end and up off the start", () => {
34
+ assert.deepEqual(
35
+ suggestKeyAction(state({ key: "ArrowDown", activeIndex: 2 })),
36
+ { type: "move", index: 0 },
37
+ );
38
+ assert.deepEqual(
39
+ suggestKeyAction(state({ key: "ArrowUp", activeIndex: 0 })),
40
+ { type: "move", index: 2 },
41
+ );
42
+ });
43
+
44
+ it("moves up from the typed value into the last option", () => {
45
+ assert.deepEqual(suggestKeyAction(state({ key: "ArrowUp" })), {
46
+ type: "move",
47
+ index: 2,
48
+ });
49
+ });
50
+
51
+ it("takes the highlighted suggestion on Enter", () => {
52
+ assert.deepEqual(
53
+ suggestKeyAction(state({ key: "Enter", activeIndex: 1 })),
54
+ { type: "accept", index: 1 },
55
+ );
56
+ });
57
+
58
+ it("leaves Enter alone when nothing is highlighted, so the typed value stands", () => {
59
+ assert.deepEqual(suggestKeyAction(state({ key: "Enter" })), {
60
+ type: "none",
61
+ });
62
+ });
63
+
64
+ it("takes the highlighted suggestion on a caller's extra accept keys", () => {
65
+ assert.deepEqual(
66
+ suggestKeyAction(
67
+ state({ key: "Tab", activeIndex: 0, acceptKeys: ["Tab", ","] }),
68
+ ),
69
+ { type: "accept", index: 0 },
70
+ );
71
+ assert.deepEqual(
72
+ suggestKeyAction(
73
+ state({ key: ",", activeIndex: 0, acceptKeys: ["Tab", ","] }),
74
+ ),
75
+ { type: "accept", index: 0 },
76
+ );
77
+ });
78
+
79
+ it("leaves a key nobody declared alone", () => {
80
+ assert.deepEqual(suggestKeyAction(state({ key: "Tab", activeIndex: 0 })), {
81
+ type: "none",
82
+ });
83
+ });
84
+
85
+ it("dismisses on Escape whatever is highlighted", () => {
86
+ assert.deepEqual(suggestKeyAction(state({ key: "Escape" })), {
87
+ type: "dismiss",
88
+ });
89
+ assert.deepEqual(
90
+ suggestKeyAction(state({ key: "Escape", activeIndex: 2 })),
91
+ { type: "dismiss" },
92
+ );
93
+ });
94
+
95
+ it("ignores a stale highlight past the end of a shortened list", () => {
96
+ assert.deepEqual(
97
+ suggestKeyAction(state({ key: "Enter", count: 1, activeIndex: 2 })),
98
+ { type: "none" },
99
+ );
100
+ });
101
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The keyboard contract for a single-value field with a suggestion list, as pure
3
+ * decisions over the list's state. Kept apart from the component so the rules are
4
+ * testable without a DOM, matching `search-chip-keys.ts`.
5
+ *
6
+ * The list is a shortcut, never a constraint: nothing here can change what the
7
+ * user typed. A key the list has no use for is left alone, so the field, the
8
+ * form, and the surrounding dialog keep their own handling of it.
9
+ *
10
+ * Follows the ARIA combobox pattern's list-with-manual-selection behaviour —
11
+ * arrows move a highlight, an accept key takes the highlighted option, Escape
12
+ * closes the list and leaves the value.
13
+ */
14
+
15
+ export type SuggestAction =
16
+ /** Move the highlight to `index`. */
17
+ | { type: "move"; index: number }
18
+ /** Take the suggestion at `index` as the field's value. */
19
+ | { type: "accept"; index: number }
20
+ /** Close the list, keeping whatever is typed. */
21
+ | { type: "dismiss" }
22
+ /** The list has no use for this key — the caller keeps its own handling. */
23
+ | { type: "none" };
24
+
25
+ export interface SuggestKeyState {
26
+ key: string;
27
+ /** Whether the list is on screen. A closed list consumes nothing. */
28
+ open: boolean;
29
+ count: number;
30
+ /** The highlighted option, `-1` when the typed value is what stands. */
31
+ activeIndex: number;
32
+ /**
33
+ * Keys that take the highlighted suggestion, beyond `Enter` which always
34
+ * does. A chips field adds `Tab` and `,`; a plain field adds nothing.
35
+ */
36
+ acceptKeys?: readonly string[];
37
+ }
38
+
39
+ const NONE: SuggestAction = { type: "none" };
40
+
41
+ export function suggestKeyAction(state: SuggestKeyState): SuggestAction {
42
+ if (!state.open || state.count <= 0) return NONE;
43
+
44
+ if (state.key === "ArrowDown") {
45
+ const next = state.activeIndex + 1;
46
+ return { type: "move", index: next >= state.count ? 0 : next };
47
+ }
48
+
49
+ if (state.key === "ArrowUp") {
50
+ const previous = state.activeIndex - 1;
51
+ return { type: "move", index: previous < 0 ? state.count - 1 : previous };
52
+ }
53
+
54
+ if (state.key === "Escape") return { type: "dismiss" };
55
+
56
+ const accepts =
57
+ state.key === "Enter" || (state.acceptKeys ?? []).includes(state.key);
58
+ if (accepts && state.activeIndex >= 0 && state.activeIndex < state.count) {
59
+ return { type: "accept", index: state.activeIndex };
60
+ }
61
+
62
+ return NONE;
63
+ }
@@ -1,7 +1,59 @@
1
1
  import type { DOMAttributes } from "@react-types/shared";
2
- import { type PointerEvent, useCallback, useRef } from "react";
2
+ import { type PointerEvent, useCallback } from "react";
3
3
  import { mergeProps, useLongPress as useAriaLongPress } from "react-aria";
4
4
 
5
+ /**
6
+ * Suppression lives on the document, not on the row.
7
+ *
8
+ * There is one pointer, and the element under it does not survive the press: a
9
+ * long press on a mailbox row enters selection mode, which swaps the swipeable
10
+ * row for the plain one *while the finger is still down*. A handler on the row
11
+ * that armed the press is torn down with it, and Android Chrome then raises its
12
+ * link menu over the selection the press just made. A capture-phase listener on
13
+ * the document sees the menu whichever node ends up under the finger.
14
+ *
15
+ * Set while a touch or pen press is down. A press that never delivers its
16
+ * `pointerup` — the browser took the gesture, the tab went to the background —
17
+ * would leave suppression armed forever, so arming is bounded by this timer as
18
+ * well as by the release.
19
+ */
20
+ let armedTimer: ReturnType<typeof setTimeout> | undefined;
21
+
22
+ /** Longer than any press a human holds before the browser ends the gesture. */
23
+ const MAX_ARMED_MS = 5_000;
24
+
25
+ /**
26
+ * One press raises at most one menu, so suppression is spent on use. The
27
+ * release disarms too, which is what keeps a keyboard-invoked menu
28
+ * (Context-Menu key / Shift+F10) raised later from inheriting a press that is
29
+ * long over. Deliberately keyed to `pointerup` and not to `pointercancel`: on
30
+ * Android the browser — and react-aria's own long-press timer — can cancel the
31
+ * pointer before the `contextmenu` it raised arrives, which would race the
32
+ * suppression away.
33
+ */
34
+ function suppressContextMenu(event: Event): void {
35
+ event.preventDefault();
36
+ disarm();
37
+ }
38
+
39
+ function disarm(): void {
40
+ if (armedTimer === undefined) return;
41
+ clearTimeout(armedTimer);
42
+ armedTimer = undefined;
43
+ document.removeEventListener("contextmenu", suppressContextMenu, true);
44
+ document.removeEventListener("pointerup", disarm, true);
45
+ }
46
+
47
+ function arm(): void {
48
+ if (armedTimer === undefined) {
49
+ document.addEventListener("contextmenu", suppressContextMenu, true);
50
+ document.addEventListener("pointerup", disarm, true);
51
+ } else {
52
+ clearTimeout(armedTimer);
53
+ }
54
+ armedTimer = setTimeout(disarm, MAX_ARMED_MS);
55
+ }
56
+
5
57
  export interface UseLongPressOptions {
6
58
  /** Called once the threshold elapses while the press stays over the target. */
7
59
  onLongPress: () => void;
@@ -32,14 +84,17 @@ export interface UseLongPressResult {
32
84
  * `-webkit-touch-callout: none` in CSS at the call site, since iOS fires no
33
85
  * cancelable event for it.
34
86
  *
35
- * The `contextmenu` suppression is keyed to the active pointer's type, tracked
36
- * off `pointerdown` on the same element: a touch or pen press suppresses the
37
- * menu Android Chrome and iOS Safari raise on a long press over a link, while a
38
- * mouse right-click is left alone so the desktop context menu keeps working. It
39
- * does not delegate this to react-aria's own suppression that listener is
40
- * transient (added on press start, scoped to the touched node, and torn down
41
- * shortly after pointerup), so a press ended early by the swipe gesture's axis
42
- * arbitration, or a menu raised over a descendant node, slips past it.
87
+ * The `contextmenu` suppression is armed by a touch or pen `pointerdown` and
88
+ * runs on the document in the capture phase: a touch press suppresses the menu
89
+ * Android Chrome and iOS Safari raise on a long press over a link, while a mouse
90
+ * right-click is left alone so the desktop context menu keeps working. Two
91
+ * reasons it is neither react-aria's own suppression nor a handler on the row.
92
+ * react-aria's listener is transient added on press start, scoped to the
93
+ * touched node, torn down shortly after pointerup so a press ended early by
94
+ * the swipe gesture's axis arbitration slips past it. And the row itself does
95
+ * not survive the press: the long press enters selection mode, which replaces
96
+ * the swipeable row with the plain one while the finger is still down, so a
97
+ * handler bound to the pressed node is gone by the time the menu arrives.
43
98
  *
44
99
  * Single source of truth for the app's long-press threshold — both mobile
45
100
  * row consumers (the plain row and the swipeable row) go through this hook
@@ -58,37 +113,15 @@ export function useLongPress({
58
113
  onLongPress,
59
114
  });
60
115
 
61
- const pointerTypeRef = useRef<string>("");
62
-
116
+ // Only a touch or pen press arms it: a mouse right-click, and a keyboard menu
117
+ // (Context-Menu key / Shift+F10) that is preceded by no press at all, keep
118
+ // the native menu.
63
119
  const onPointerDown = useCallback((event: PointerEvent) => {
64
- pointerTypeRef.current = event.pointerType;
65
- }, []);
66
-
67
- // A press that lifts without raising a menu disarms suppression, so a later
68
- // keyboard-invoked menu can't inherit its pointer type. Not cleared on
69
- // pointercancel: on Android the browser (and react-aria's own long-press
70
- // timer) can fire pointercancel before the long-press contextmenu, which
71
- // would race the suppression away.
72
- const onPointerUp = useCallback(() => {
73
- pointerTypeRef.current = "";
74
- }, []);
75
-
76
- const onContextMenu = useCallback((event: { preventDefault: () => void }) => {
77
- // Consume the armed pointer type. A keyboard-invoked menu (Context-Menu
78
- // key / Shift+F10) fires no pointerdown, so without spending the type on
79
- // use it would inherit the last touch press's and be wrongly suppressed.
80
- const pointerType = pointerTypeRef.current;
81
- pointerTypeRef.current = "";
82
- if (pointerType === "touch" || pointerType === "pen") {
83
- event.preventDefault();
84
- }
120
+ if (event.pointerType !== "touch" && event.pointerType !== "pen") return;
121
+ arm();
85
122
  }, []);
86
123
 
87
124
  return {
88
- longPressProps: mergeProps(longPressProps, {
89
- onPointerDown,
90
- onPointerUp,
91
- onContextMenu,
92
- }),
125
+ longPressProps: mergeProps(longPressProps, { onPointerDown }),
93
126
  };
94
127
  }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * use-suggest-list — drives the real hook on a jsdom-mounted input, because what
3
+ * matters is the interaction between its state and the keystrokes the field
4
+ * receives: an accepted suggestion, a dismissal that leaves the typed value
5
+ * alone, a result set that changes under a stale highlight. The key rules
6
+ * themselves are pure and tested in `suggest-keys.test.ts`.
7
+ */
8
+
9
+ import assert from "node:assert/strict";
10
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
11
+ import type { JSDOM } from "jsdom";
12
+ import { act, createElement, useState } from "react";
13
+ import { createRoot, type Root } from "react-dom/client";
14
+ import { useSuggestList } from "./use-suggest-list.js";
15
+
16
+ let dom: JSDOM;
17
+ let container: HTMLElement;
18
+ let root: Root;
19
+
20
+ const accepted: string[] = [];
21
+
22
+ function Field(props: { options: string[] }) {
23
+ const [value, setValue] = useState("typed");
24
+ const suggest = useSuggestList({
25
+ count: props.options.length,
26
+ onAccept: (index) => accepted.push(props.options[index]),
27
+ });
28
+ return createElement("input", {
29
+ id: "field",
30
+ value,
31
+ onChange: (event: { target: { value: string } }) => {
32
+ suggest.reopen();
33
+ setValue(event.target.value);
34
+ },
35
+ onKeyDown: suggest.handleKeyDown,
36
+ "data-open": String(suggest.open),
37
+ "data-active": String(suggest.activeIndex),
38
+ ...suggest.comboboxProps,
39
+ });
40
+ }
41
+
42
+ function mount(options: string[]) {
43
+ act(() => {
44
+ root.render(createElement(Field, { options }));
45
+ });
46
+ const field = dom.window.document.getElementById("field");
47
+ assert.ok(field, "field did not mount");
48
+ // React's change-event polyfill tracks the focused input across keystrokes;
49
+ // keys arriving at an unfocused field are not a state this ever sees.
50
+ act(() => {
51
+ (field as HTMLInputElement).focus();
52
+ });
53
+ return field;
54
+ }
55
+
56
+ function press(field: Element, key: string) {
57
+ const event = new dom.window.KeyboardEvent("keydown", {
58
+ key,
59
+ bubbles: true,
60
+ cancelable: true,
61
+ });
62
+ act(() => {
63
+ field.dispatchEvent(event);
64
+ });
65
+ return event;
66
+ }
67
+
68
+ before(async () => {
69
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
70
+ dom = new JSDOMCtor(
71
+ "<!doctype html><html><body><div id=root></div></body></html>",
72
+ { url: "http://localhost/", pretendToBeVisual: true },
73
+ );
74
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
75
+ globalThis.document = dom.window.document;
76
+ globalThis.HTMLElement = dom.window.HTMLElement;
77
+ globalThis.Element = dom.window.Element;
78
+ globalThis.SVGElement = dom.window.SVGElement;
79
+ globalThis.KeyboardEvent = dom.window.KeyboardEvent;
80
+ (
81
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
82
+ ).IS_REACT_ACT_ENVIRONMENT = true;
83
+ });
84
+
85
+ after(() => {
86
+ dom.window.close();
87
+ });
88
+
89
+ beforeEach(() => {
90
+ accepted.length = 0;
91
+ container = dom.window.document.getElementById(
92
+ "root",
93
+ ) as unknown as HTMLElement;
94
+ container.innerHTML = "";
95
+ root = createRoot(container);
96
+ });
97
+
98
+ afterEach(() => {
99
+ act(() => {
100
+ root.unmount();
101
+ });
102
+ });
103
+
104
+ const OPTIONS = ["one", "two", "three"];
105
+
106
+ describe("useSuggestList", () => {
107
+ it("opens only when there is something to suggest", () => {
108
+ assert.equal(mount([]).getAttribute("data-open"), "false");
109
+ assert.equal(mount(OPTIONS).getAttribute("data-open"), "true");
110
+ });
111
+
112
+ it("wires the combobox for a screen reader", () => {
113
+ const field = mount(OPTIONS);
114
+ assert.equal(field.getAttribute("role"), "combobox");
115
+ assert.equal(field.getAttribute("aria-expanded"), "true");
116
+ assert.equal(field.getAttribute("aria-autocomplete"), "list");
117
+ assert.ok(field.getAttribute("aria-controls"));
118
+ assert.equal(field.getAttribute("aria-activedescendant"), null);
119
+ });
120
+
121
+ it("points at the highlighted option once one is highlighted", () => {
122
+ const field = mount(OPTIONS);
123
+ press(field, "ArrowDown");
124
+ assert.equal(field.getAttribute("data-active"), "0");
125
+ assert.equal(
126
+ field.getAttribute("aria-activedescendant"),
127
+ `${field.getAttribute("aria-controls")}-option-0`,
128
+ );
129
+ });
130
+
131
+ it("takes the highlighted suggestion on Enter and leaves Enter alone otherwise", () => {
132
+ const field = mount(OPTIONS);
133
+ const bare = press(field, "Enter");
134
+ assert.deepEqual(accepted, []);
135
+ assert.equal(bare.defaultPrevented, false, "the field's own Enter stands");
136
+
137
+ press(field, "ArrowDown");
138
+ press(field, "ArrowDown");
139
+ press(field, "Enter");
140
+ assert.deepEqual(accepted, ["two"]);
141
+ });
142
+
143
+ it("closes on Escape without touching what was typed, and owns the key while open", () => {
144
+ const field = mount(OPTIONS);
145
+ assert.equal(field.getAttribute("data-escape-owner"), "");
146
+ press(field, "Escape");
147
+ assert.equal(field.getAttribute("data-open"), "false");
148
+ assert.equal(field.getAttribute("data-escape-owner"), null);
149
+ assert.equal((field as HTMLInputElement).value, "typed");
150
+ });
151
+
152
+ it("leaves Escape to the surrounding surface once the list is closed", () => {
153
+ const field = mount(OPTIONS);
154
+ press(field, "Escape");
155
+ const second = press(field, "Escape");
156
+ assert.equal(second.defaultPrevented, false);
157
+ });
158
+
159
+ it("offers the list again for a changed result set, with no highlight carried over", () => {
160
+ const field = mount(OPTIONS);
161
+ press(field, "ArrowDown");
162
+ press(field, "Escape");
163
+ act(() => {
164
+ root.render(createElement(Field, { options: ["only"] }));
165
+ });
166
+ assert.equal(field.getAttribute("data-open"), "true");
167
+ assert.equal(field.getAttribute("data-active"), "-1");
168
+ });
169
+ });
@@ -0,0 +1,124 @@
1
+ import {
2
+ type KeyboardEvent,
3
+ useCallback,
4
+ useEffect,
5
+ useId,
6
+ useState,
7
+ } from "react";
8
+ import { suggestKeyAction } from "./suggest-keys.js";
9
+
10
+ export interface UseSuggestListInput {
11
+ /** How many suggestions are on offer right now. */
12
+ count: number;
13
+ /** Take the suggestion at this index. */
14
+ onAccept: (index: number) => void;
15
+ /** Extra keys that take the highlighted suggestion — see `suggestKeyAction`. */
16
+ acceptKeys?: readonly string[];
17
+ }
18
+
19
+ /** The ARIA wiring a combobox input spreads onto itself. */
20
+ export interface ComboboxProps {
21
+ role: "combobox";
22
+ "aria-expanded": boolean;
23
+ "aria-controls": string;
24
+ "aria-autocomplete": "list";
25
+ "aria-activedescendant"?: string;
26
+ /**
27
+ * Marks the field as owning Escape while its list is open, so a surrounding
28
+ * dialog closes on Escape only when the list is not the thing to close.
29
+ */
30
+ "data-escape-owner"?: "";
31
+ }
32
+
33
+ export interface SuggestListState {
34
+ /** Whether the list should render. */
35
+ open: boolean;
36
+ /** The highlighted option, `-1` when the typed value is what stands. */
37
+ activeIndex: number;
38
+ setActiveIndex: (index: number) => void;
39
+ /** Close the list, keeping whatever is typed. */
40
+ dismiss: () => void;
41
+ /** Offer the list again — what a fresh keystroke does after a dismissal. */
42
+ reopen: () => void;
43
+ /** Returns true when the list consumed the key and the caller should stop. */
44
+ handleKeyDown: (event: KeyboardEvent) => boolean;
45
+ listId: string;
46
+ optionId: (index: number) => string;
47
+ comboboxProps: ComboboxProps;
48
+ }
49
+
50
+ /**
51
+ * The open/highlight state, ARIA wiring, and keyboard handling behind a
52
+ * suggestion list, so every typeahead in the app behaves the same way and there
53
+ * is one place the behaviour is defined. The caller owns the data, the markup,
54
+ * and what a picked suggestion means; this owns only which option is highlighted
55
+ * and whether the list is showing.
56
+ *
57
+ * Nothing here writes the field's value — a dismissal, a re-render, or an empty
58
+ * result leaves what the user typed exactly as it is.
59
+ */
60
+ export function useSuggestList({
61
+ count,
62
+ onAccept,
63
+ acceptKeys,
64
+ }: UseSuggestListInput): SuggestListState {
65
+ const [dismissed, setDismissed] = useState(false);
66
+ const [activeIndex, setActiveIndex] = useState(-1);
67
+ const base = useId().replace(/[^a-zA-Z0-9_-]/g, "");
68
+ const listId = `suggest-${base}`;
69
+ const optionId = useCallback(
70
+ (index: number) => `suggest-${base}-option-${index}`,
71
+ [base],
72
+ );
73
+
74
+ // A changed result set is a new list: no highlight carries over onto an option
75
+ // the user never saw, and a list dismissed for the previous query is offered
76
+ // again for this one.
77
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `count` is the trigger, not a value the effect reads — a different result set is what resets the list
78
+ useEffect(() => {
79
+ setActiveIndex(-1);
80
+ setDismissed(false);
81
+ }, [count]);
82
+
83
+ const open = count > 0 && !dismissed;
84
+
85
+ const handleKeyDown = useCallback(
86
+ (event: KeyboardEvent): boolean => {
87
+ const action = suggestKeyAction({
88
+ key: event.key,
89
+ open,
90
+ count,
91
+ activeIndex,
92
+ acceptKeys,
93
+ });
94
+ if (action.type === "none") return false;
95
+ event.preventDefault();
96
+ if (action.type === "move") setActiveIndex(action.index);
97
+ if (action.type === "dismiss") setDismissed(true);
98
+ if (action.type === "accept") onAccept(action.index);
99
+ return true;
100
+ },
101
+ [open, count, activeIndex, acceptKeys, onAccept],
102
+ );
103
+
104
+ return {
105
+ open,
106
+ activeIndex,
107
+ setActiveIndex,
108
+ dismiss: useCallback(() => setDismissed(true), []),
109
+ reopen: useCallback(() => setDismissed(false), []),
110
+ handleKeyDown,
111
+ listId,
112
+ optionId,
113
+ comboboxProps: {
114
+ role: "combobox",
115
+ "aria-expanded": open,
116
+ "aria-controls": listId,
117
+ "aria-autocomplete": "list",
118
+ ...(activeIndex >= 0
119
+ ? { "aria-activedescendant": optionId(activeIndex) }
120
+ : {}),
121
+ ...(open ? { "data-escape-owner": "" as const } : {}),
122
+ },
123
+ };
124
+ }