@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.
Files changed (40) hide show
  1. package/package.json +2 -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 +55 -0
  30. package/src/lib/property-prefill.test.ts +173 -0
  31. package/src/lib/property-prefill.ts +151 -0
  32. package/src/lib/search-rule.test.ts +73 -0
  33. package/src/lib/search-rule.ts +96 -0
  34. package/src/lib/sender-derivation.test.ts +106 -0
  35. package/src/lib/sender-derivation.ts +85 -0
  36. package/src/lib/suggest-keys.test.ts +101 -0
  37. package/src/lib/suggest-keys.ts +63 -0
  38. package/src/lib/use-long-press.ts +70 -37
  39. package/src/lib/use-suggest-list.test.ts +169 -0
  40. package/src/lib/use-suggest-list.ts +124 -0
@@ -0,0 +1,106 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ collapsibleDomain,
5
+ deriveSenderClauses,
6
+ distinctSenders,
7
+ } from "./sender-derivation.js";
8
+
9
+ describe("distinctSenders", () => {
10
+ it("drops empties and blanks, trimming what remains", () => {
11
+ assert.deepEqual(
12
+ distinctSenders([" npm@github.com ", "", " ", "a@x.com"]),
13
+ ["npm@github.com", "a@x.com"],
14
+ );
15
+ });
16
+
17
+ it("de-duplicates case-insensitively, keeping first-seen casing and order", () => {
18
+ assert.deepEqual(
19
+ distinctSenders([
20
+ "NPM@github.com",
21
+ "a@x.com",
22
+ "npm@GITHUB.com",
23
+ "a@x.com",
24
+ ]),
25
+ ["NPM@github.com", "a@x.com"],
26
+ );
27
+ });
28
+ });
29
+
30
+ describe("collapsibleDomain", () => {
31
+ it("returns the shared registrable domain when every sender matches it", () => {
32
+ assert.equal(
33
+ collapsibleDomain([
34
+ "npm@github.com",
35
+ "notifications@github.com",
36
+ "ci@sub.github.com",
37
+ ]),
38
+ "github.com",
39
+ );
40
+ });
41
+
42
+ it("does not collapse a single sender to its whole domain", () => {
43
+ assert.equal(collapsibleDomain(["npm@github.com"]), null);
44
+ });
45
+
46
+ it("does not collapse when a sender's domain differs", () => {
47
+ assert.equal(collapsibleDomain(["npm@github.com", "a@x.com"]), null);
48
+ });
49
+
50
+ it("does not collapse when any sender's domain cannot be resolved", () => {
51
+ assert.equal(
52
+ collapsibleDomain(["npm@github.com", "malformed-no-at-sign"]),
53
+ null,
54
+ );
55
+ });
56
+ });
57
+
58
+ describe("deriveSenderClauses", () => {
59
+ it("emits one From clause per distinct sender when domains differ", () => {
60
+ assert.deepEqual(
61
+ deriveSenderClauses(["npm@github.com", "npm@github.com", "a@x.com"]),
62
+ [
63
+ { field: "From", value: "npm@github.com" },
64
+ { field: "From", value: "a@x.com" },
65
+ ],
66
+ );
67
+ });
68
+
69
+ it("collapses to a single FromDomain clause when every sender shares a domain", () => {
70
+ assert.deepEqual(
71
+ deriveSenderClauses([
72
+ "npm@github.com",
73
+ "notifications@github.com",
74
+ "ci@sub.github.com",
75
+ ]),
76
+ [{ field: "FromDomain", value: "github.com" }],
77
+ );
78
+ });
79
+
80
+ it("keeps per-address From clauses for the mixed case", () => {
81
+ assert.deepEqual(
82
+ deriveSenderClauses([
83
+ "npm@github.com",
84
+ "ci@github.com",
85
+ "newsletter@example.org",
86
+ ]),
87
+ [
88
+ { field: "From", value: "npm@github.com" },
89
+ { field: "From", value: "ci@github.com" },
90
+ { field: "From", value: "newsletter@example.org" },
91
+ ],
92
+ );
93
+ });
94
+
95
+ it("is empty when no sender survives", () => {
96
+ assert.deepEqual(deriveSenderClauses(["", " "]), []);
97
+ });
98
+
99
+ it("collapses a multi-label public suffix to the registrable domain", () => {
100
+ // The public-suffix list is what makes this foo.co.uk; the trailing two
101
+ // labels of the host are co.uk, which matches every British domain.
102
+ assert.deepEqual(deriveSenderClauses(["a@foo.co.uk", "b@foo.co.uk"]), [
103
+ { field: "FromDomain", value: "foo.co.uk" },
104
+ ]);
105
+ });
106
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Senders in a selection turned into clauses (RFC 038 D2).
3
+ *
4
+ * The widen fallback for a deployment that ships no vector pipeline reaches for
5
+ * this: the semantic anchor matches nothing there, so a widen degrades to the
6
+ * literal vocabulary RFC 031 already matches vector-free — one `From` clause per
7
+ * distinct sender, or a single `FromDomain` clause when they all sit on one
8
+ * registrable domain. The same predicate matches at index time (RFC 034), so a
9
+ * standing filter built from it keeps working on future mail.
10
+ */
11
+
12
+ import { getDomain } from "tldts";
13
+ import type { RuleClause } from "../components/filter-rule.js";
14
+
15
+ /**
16
+ * Distinct sender addresses from the selection, trimmed, empties dropped, and
17
+ * de-duplicated case-insensitively while preserving first-seen casing and order.
18
+ */
19
+ export const distinctSenders = (senders: readonly string[]): string[] => {
20
+ const seen = new Set<string>();
21
+ const out: string[] = [];
22
+ for (const raw of senders) {
23
+ const value = raw.trim();
24
+ if (value === "") continue;
25
+ const key = value.toLowerCase();
26
+ if (seen.has(key)) continue;
27
+ seen.add(key);
28
+ out.push(value);
29
+ }
30
+ return out;
31
+ };
32
+
33
+ const hostOf = (address: string): string => {
34
+ const at = address.lastIndexOf("@");
35
+ return at >= 0 ? address.slice(at + 1) : address;
36
+ };
37
+
38
+ /**
39
+ * The registrable domain behind a sender address, public-suffix aware (tldts
40
+ * `getDomain`), or `null` when the address carries none. The one place an
41
+ * address is turned into a `FromDomain` value — a clause the prefill derives and
42
+ * a domain the value field suggests must be the same string, or the suggestion
43
+ * would offer a domain the matcher never produces.
44
+ */
45
+ export const senderDomain = (address: string): string | null =>
46
+ getDomain(hostOf(address.trim()));
47
+
48
+ /**
49
+ * The single registrable domain the whole selection collapses to, or `null` when
50
+ * it does not collapse. A collapse needs at least two distinct senders that all
51
+ * resolve to one registrable domain (public-suffix aware, via tldts `getDomain`)
52
+ * — the "anyone at this domain" signal (RFC 038 D2). One sender stays a precise
53
+ * `From` clause rather than widening a single address to its whole domain, and a
54
+ * sender whose domain can't be resolved blocks the collapse.
55
+ */
56
+ export const collapsibleDomain = (
57
+ senders: readonly string[],
58
+ ): string | null => {
59
+ const distinct = distinctSenders(senders);
60
+ if (distinct.length < 2) return null;
61
+ let shared: string | null = null;
62
+ for (const sender of distinct) {
63
+ const domain = senderDomain(sender);
64
+ if (domain === null) return null;
65
+ if (shared === null) shared = domain;
66
+ else if (shared !== domain) return null;
67
+ }
68
+ return shared;
69
+ };
70
+
71
+ /**
72
+ * The literal clauses standing in for the selection. When every sender shares one
73
+ * registrable domain, a single `FromDomain` clause replaces the per-address `From`
74
+ * chips (RFC 038 D2); otherwise one `From` clause per distinct sender, each
75
+ * matching the sender address or display name (match.ts `clauseMatches`).
76
+ */
77
+ export const deriveSenderClauses = (
78
+ senders: readonly string[],
79
+ ): Omit<RuleClause, "id">[] => {
80
+ const domain = collapsibleDomain(senders);
81
+ if (domain !== null) return [{ field: "FromDomain", value: domain }];
82
+ return distinctSenders(senders).map(
83
+ (value): Omit<RuleClause, "id"> => ({ field: "From", value }),
84
+ );
85
+ };
@@ -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
+ });