@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
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
|
|
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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* transient
|
|
41
|
-
* shortly after pointerup
|
|
42
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|