@remit/ui 0.0.55 → 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/components/swipeable-row.gesture.test.ts +128 -2
- package/src/components/swipeable-row.stories.tsx +54 -0
- package/src/components/swipeable-row.tsx +60 -31
- 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
|
@@ -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
|
+
}
|