@ecohouse/ui 0.1.60 → 0.1.62
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/dist/index.cjs +345 -115
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -4
- package/dist/index.d.ts +16 -4
- package/dist/index.js +344 -117
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Checkbox/Checkbox.tsx +6 -0
- package/src/components/Counter/Counter.test.ts +38 -0
- package/src/components/Counter/Counter.tsx +7 -10
- package/src/components/Counter/Counter.utils.ts +28 -0
- package/src/components/DatePicker/DatePicker.tsx +5 -14
- package/src/components/Drawer/Drawer.tsx +15 -12
- package/src/components/Modal/Modal.tsx +31 -11
- package/src/components/RadioButton/RadioButton.tsx +14 -0
- package/src/components/Select/Select.tsx +16 -8
- package/src/components/Toggle/Toggle.tsx +6 -0
- package/src/components/VerificationCodeInput/VerificationCodeInput.test.ts +60 -12
- package/src/components/VerificationCodeInput/VerificationCodeInput.tsx +29 -24
- package/src/components/VerificationCodeInput/VerificationCodeInput.utils.ts +53 -13
- package/src/icons/PaymentCard/PaymentCardIcon.tsx +14 -0
- package/src/icons/PaymentCard/PaymentCardIcon.web.tsx +13 -0
- package/src/icons/PaymentCard/index.ts +1 -0
- package/src/icons/PaymentCard/paymentCardPath.ts +3 -0
- package/src/icons/Receipt/ReceiptIcon.tsx +14 -0
- package/src/icons/Receipt/ReceiptIcon.web.tsx +13 -0
- package/src/icons/Receipt/index.ts +1 -0
- package/src/icons/Receipt/receiptPath.ts +3 -0
- package/src/icons/index.ts +2 -0
- package/src/icons/registry.ts +4 -0
- package/src/index.ts +8 -1
- package/src/theme/typography.ts +2 -2
- package/src/utils/dateUtils.test.ts +72 -0
- package/src/utils/dateUtils.ts +30 -2
- package/src/utils/focusTrap.test.ts +26 -0
- package/src/utils/focusTrap.ts +39 -0
- package/src/utils/overlayStack.test.ts +175 -0
- package/src/utils/overlayStack.ts +105 -0
- package/src/utils/useHydrated.test.ts +14 -0
- package/src/utils/useHydrated.ts +14 -0
- package/src/utils/useOverlayLayer.ts +41 -0
- package/src/utils/useWebFocusTrap.ts +68 -0
- package/src/utils/webToggleKey.test.ts +49 -0
- package/src/utils/webToggleKey.ts +23 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createOverlayStack,
|
|
4
|
+
type OverlayEventTarget,
|
|
5
|
+
type OverlayKeyboardEvent,
|
|
6
|
+
} from "./overlayStack";
|
|
7
|
+
|
|
8
|
+
type Listener = (event: OverlayKeyboardEvent) => void;
|
|
9
|
+
|
|
10
|
+
/** Minimal `document` stand-in: capture listeners run before bubble listeners. */
|
|
11
|
+
function createFakeDocument() {
|
|
12
|
+
const listeners: Array<{ listener: Listener; capture: boolean }> = [];
|
|
13
|
+
|
|
14
|
+
const target: OverlayEventTarget = {
|
|
15
|
+
addEventListener: (_type, listener, capture) => {
|
|
16
|
+
listeners.push({ listener, capture });
|
|
17
|
+
},
|
|
18
|
+
removeEventListener: (_type, listener, capture) => {
|
|
19
|
+
const index = listeners.findIndex(
|
|
20
|
+
(entry) => entry.listener === listener && entry.capture === capture,
|
|
21
|
+
);
|
|
22
|
+
if (index !== -1) listeners.splice(index, 1);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `appListener` stands for an app's own `document` keydown listener (bubble
|
|
28
|
+
* phase); `targetListener` for a handler on the focused element, which runs
|
|
29
|
+
* between the capture and bubble phases.
|
|
30
|
+
*/
|
|
31
|
+
const pressKey = (key: string, appListener?: Listener, targetListener?: Listener) => {
|
|
32
|
+
let defaultPrevented = false;
|
|
33
|
+
let stopped = false;
|
|
34
|
+
const event: OverlayKeyboardEvent = {
|
|
35
|
+
key,
|
|
36
|
+
get defaultPrevented() {
|
|
37
|
+
return defaultPrevented;
|
|
38
|
+
},
|
|
39
|
+
preventDefault: () => {
|
|
40
|
+
defaultPrevented = true;
|
|
41
|
+
},
|
|
42
|
+
stopImmediatePropagation: () => {
|
|
43
|
+
stopped = true;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
const ordered = [
|
|
47
|
+
...listeners.filter((entry) => entry.capture).map((entry) => entry.listener),
|
|
48
|
+
...(targetListener ? [targetListener] : []),
|
|
49
|
+
...listeners.filter((entry) => !entry.capture).map((entry) => entry.listener),
|
|
50
|
+
...(appListener ? [appListener] : []),
|
|
51
|
+
];
|
|
52
|
+
for (const listener of ordered) {
|
|
53
|
+
if (stopped) break;
|
|
54
|
+
listener(event);
|
|
55
|
+
}
|
|
56
|
+
return event;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return { target, pressKey, listenerCount: () => listeners.length };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("createOverlayStack", () => {
|
|
63
|
+
it("closes a popover without closing the modal behind it", () => {
|
|
64
|
+
const fakeDocument = createFakeDocument();
|
|
65
|
+
const stack = createOverlayStack(fakeDocument.target);
|
|
66
|
+
const closeModal = vi.fn();
|
|
67
|
+
const closeSelect = vi.fn();
|
|
68
|
+
const appListener = vi.fn();
|
|
69
|
+
|
|
70
|
+
stack.push({ kind: "overlay", onEscape: closeModal });
|
|
71
|
+
stack.push({ kind: "popover", onEscape: closeSelect });
|
|
72
|
+
|
|
73
|
+
const event = fakeDocument.pressKey("Escape", appListener);
|
|
74
|
+
|
|
75
|
+
expect(closeSelect).toHaveBeenCalledTimes(1);
|
|
76
|
+
expect(closeModal).not.toHaveBeenCalled();
|
|
77
|
+
// An app's own dialog listening on `document` does not see the event either.
|
|
78
|
+
expect(appListener).not.toHaveBeenCalled();
|
|
79
|
+
expect(event.defaultPrevented).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("closes a popover first even when it registered before its overlay", () => {
|
|
83
|
+
// React runs a child's effects before its parent's: a Select that is open on
|
|
84
|
+
// mount inside a Modal registers before the Modal does.
|
|
85
|
+
const fakeDocument = createFakeDocument();
|
|
86
|
+
const stack = createOverlayStack(fakeDocument.target);
|
|
87
|
+
const closeModal = vi.fn();
|
|
88
|
+
const closeSelect = vi.fn();
|
|
89
|
+
const select = { kind: "popover" as const, onEscape: closeSelect };
|
|
90
|
+
const modal = { kind: "overlay" as const, onEscape: closeModal };
|
|
91
|
+
|
|
92
|
+
stack.push(select);
|
|
93
|
+
stack.push(modal);
|
|
94
|
+
fakeDocument.pressKey("Escape");
|
|
95
|
+
|
|
96
|
+
expect(closeSelect).toHaveBeenCalledTimes(1);
|
|
97
|
+
expect(closeModal).not.toHaveBeenCalled();
|
|
98
|
+
expect(stack.isTop(select)).toBe(true);
|
|
99
|
+
expect(stack.isTop(modal)).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("closes the overlay once the popover above it is gone", () => {
|
|
103
|
+
const fakeDocument = createFakeDocument();
|
|
104
|
+
const stack = createOverlayStack(fakeDocument.target);
|
|
105
|
+
const closeModal = vi.fn();
|
|
106
|
+
const closeSelect = vi.fn();
|
|
107
|
+
|
|
108
|
+
stack.push({ kind: "overlay", onEscape: closeModal });
|
|
109
|
+
const removeSelect = stack.push({ kind: "popover", onEscape: closeSelect });
|
|
110
|
+
removeSelect();
|
|
111
|
+
|
|
112
|
+
fakeDocument.pressKey("Escape");
|
|
113
|
+
|
|
114
|
+
expect(closeSelect).not.toHaveBeenCalled();
|
|
115
|
+
expect(closeModal).toHaveBeenCalledTimes(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("closes only the topmost of two stacked overlays", () => {
|
|
119
|
+
const fakeDocument = createFakeDocument();
|
|
120
|
+
const stack = createOverlayStack(fakeDocument.target);
|
|
121
|
+
const closeDrawer = vi.fn();
|
|
122
|
+
const closeModal = vi.fn();
|
|
123
|
+
|
|
124
|
+
stack.push({ kind: "overlay", onEscape: closeDrawer });
|
|
125
|
+
const modal = { kind: "overlay" as const, onEscape: closeModal };
|
|
126
|
+
stack.push(modal);
|
|
127
|
+
|
|
128
|
+
fakeDocument.pressKey("Escape");
|
|
129
|
+
|
|
130
|
+
expect(closeModal).toHaveBeenCalledTimes(1);
|
|
131
|
+
expect(closeDrawer).not.toHaveBeenCalled();
|
|
132
|
+
expect(stack.isTop(modal)).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("leaves an overlay open when content inside it already handled Escape", () => {
|
|
136
|
+
const fakeDocument = createFakeDocument();
|
|
137
|
+
const stack = createOverlayStack(fakeDocument.target);
|
|
138
|
+
const closeModal = vi.fn();
|
|
139
|
+
stack.push({ kind: "overlay", onEscape: closeModal });
|
|
140
|
+
|
|
141
|
+
fakeDocument.pressKey("Escape", undefined, (event) => event.preventDefault());
|
|
142
|
+
|
|
143
|
+
expect(closeModal).not.toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("ignores other keys", () => {
|
|
147
|
+
const fakeDocument = createFakeDocument();
|
|
148
|
+
const stack = createOverlayStack(fakeDocument.target);
|
|
149
|
+
const closeSelect = vi.fn();
|
|
150
|
+
const appListener = vi.fn();
|
|
151
|
+
stack.push({ kind: "popover", onEscape: closeSelect });
|
|
152
|
+
|
|
153
|
+
const event = fakeDocument.pressKey("Enter", appListener);
|
|
154
|
+
|
|
155
|
+
expect(closeSelect).not.toHaveBeenCalled();
|
|
156
|
+
expect(appListener).toHaveBeenCalledTimes(1);
|
|
157
|
+
expect(event.defaultPrevented).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("only listens on the document while a layer is open", () => {
|
|
161
|
+
const fakeDocument = createFakeDocument();
|
|
162
|
+
const stack = createOverlayStack(fakeDocument.target);
|
|
163
|
+
|
|
164
|
+
const removeFirst = stack.push({ kind: "overlay", onEscape: vi.fn() });
|
|
165
|
+
const removeSecond = stack.push({ kind: "popover", onEscape: vi.fn() });
|
|
166
|
+
expect(fakeDocument.listenerCount()).toBe(2);
|
|
167
|
+
|
|
168
|
+
removeFirst();
|
|
169
|
+
removeFirst();
|
|
170
|
+
expect(fakeDocument.listenerCount()).toBe(2);
|
|
171
|
+
|
|
172
|
+
removeSecond();
|
|
173
|
+
expect(fakeDocument.listenerCount()).toBe(0);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escape handling for stacked web overlays. One Escape closes one layer: an
|
|
3
|
+
* open popover first, otherwise the most recently opened overlay. So closing a
|
|
4
|
+
* Select inside a Modal leaves the Modal open, and a Modal opened over a
|
|
5
|
+
* Drawer closes before the Drawer.
|
|
6
|
+
*
|
|
7
|
+
* - `popover` layers (Select, DatePicker menus) claim Escape in the capture
|
|
8
|
+
* phase and stop it, so neither our overlays nor an app's own `document`
|
|
9
|
+
* listeners close the dialog behind the popover. Popovers always win over
|
|
10
|
+
* overlays: React registers a child popover that is open on mount before
|
|
11
|
+
* its parent Modal, so push order alone is not reliable between the kinds.
|
|
12
|
+
* - `overlay` layers (Modal, Drawer) react once the event bubbled to
|
|
13
|
+
* `document`, and skip events already handled (`defaultPrevented`), so
|
|
14
|
+
* content inside them can keep them open by calling `preventDefault()`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type OverlayLayerKind = "overlay" | "popover";
|
|
18
|
+
|
|
19
|
+
export interface OverlayLayer {
|
|
20
|
+
readonly kind: OverlayLayerKind;
|
|
21
|
+
onEscape: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface OverlayKeyboardEvent {
|
|
25
|
+
readonly key: string;
|
|
26
|
+
readonly defaultPrevented: boolean;
|
|
27
|
+
preventDefault: () => void;
|
|
28
|
+
stopImmediatePropagation: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface OverlayEventTarget {
|
|
32
|
+
addEventListener: (
|
|
33
|
+
type: "keydown",
|
|
34
|
+
listener: (event: OverlayKeyboardEvent) => void,
|
|
35
|
+
capture: boolean,
|
|
36
|
+
) => void;
|
|
37
|
+
removeEventListener: (
|
|
38
|
+
type: "keydown",
|
|
39
|
+
listener: (event: OverlayKeyboardEvent) => void,
|
|
40
|
+
capture: boolean,
|
|
41
|
+
) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface OverlayStack {
|
|
45
|
+
/** Adds `layer` on top; returns a function that removes it again. */
|
|
46
|
+
push: (layer: OverlayLayer) => () => void;
|
|
47
|
+
/** Whether `layer` is the most recently opened layer of its kind. */
|
|
48
|
+
isTop: (layer: OverlayLayer) => boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createOverlayStack(target: OverlayEventTarget): OverlayStack {
|
|
52
|
+
const layers: OverlayLayer[] = [];
|
|
53
|
+
const top = (kind: OverlayLayerKind) => {
|
|
54
|
+
for (let index = layers.length - 1; index >= 0; index -= 1) {
|
|
55
|
+
if (layers[index].kind === kind) return layers[index];
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const handleCapture = (event: OverlayKeyboardEvent) => {
|
|
61
|
+
const layer = top("popover");
|
|
62
|
+
if (event.key !== "Escape" || event.defaultPrevented || !layer) return;
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
event.stopImmediatePropagation();
|
|
65
|
+
layer.onEscape();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const handleBubble = (event: OverlayKeyboardEvent) => {
|
|
69
|
+
const layer = top("overlay");
|
|
70
|
+
if (event.key !== "Escape" || event.defaultPrevented || !layer) return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
layer.onEscape();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
push(layer) {
|
|
77
|
+
layers.push(layer);
|
|
78
|
+
if (layers.length === 1) {
|
|
79
|
+
target.addEventListener("keydown", handleCapture, true);
|
|
80
|
+
target.addEventListener("keydown", handleBubble, false);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return () => {
|
|
84
|
+
const index = layers.indexOf(layer);
|
|
85
|
+
if (index === -1) return;
|
|
86
|
+
layers.splice(index, 1);
|
|
87
|
+
if (layers.length === 0) {
|
|
88
|
+
target.removeEventListener("keydown", handleCapture, true);
|
|
89
|
+
target.removeEventListener("keydown", handleBubble, false);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
isTop: (layer) => top(layer.kind) === layer,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let documentStack: OverlayStack | null = null;
|
|
98
|
+
|
|
99
|
+
/** The shared stack bound to `document`; web only. */
|
|
100
|
+
export function getDocumentOverlayStack(): OverlayStack {
|
|
101
|
+
if (!documentStack) {
|
|
102
|
+
documentStack = createOverlayStack(document as unknown as OverlayEventTarget);
|
|
103
|
+
}
|
|
104
|
+
return documentStack;
|
|
105
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
import { renderToString } from "react-dom/server";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { useHydrated } from "./useHydrated";
|
|
5
|
+
|
|
6
|
+
function Probe() {
|
|
7
|
+
return createElement("span", null, useHydrated() ? "client" : "server");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("useHydrated", () => {
|
|
11
|
+
it("is false while rendering on the server, so portals are left out of the SSR HTML", () => {
|
|
12
|
+
expect(renderToString(createElement(Probe))).toBe("<span>server</span>");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
const subscribe = () => () => {};
|
|
4
|
+
const getClientSnapshot = () => true;
|
|
5
|
+
const getServerSnapshot = () => false;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `false` during server rendering and hydration, `true` afterwards (and on
|
|
9
|
+
* client-only mounts). Gate browser-only output such as portals on it so the
|
|
10
|
+
* first client render matches the server HTML.
|
|
11
|
+
*/
|
|
12
|
+
export function useHydrated(): boolean {
|
|
13
|
+
return useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot);
|
|
14
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
import { getDocumentOverlayStack, type OverlayLayer, type OverlayLayerKind } from "./overlayStack";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Registers an open overlay on the shared web overlay stack (see
|
|
7
|
+
* `overlayStack.ts`) so Escape closes only the topmost one. No-op on native.
|
|
8
|
+
*
|
|
9
|
+
* The layer is registered once per `active` period; `onEscape` is read from a
|
|
10
|
+
* ref so a new callback identity does not move the layer to the top again.
|
|
11
|
+
* Returns `isTopLayer()` for behaviour that only the topmost layer of this kind
|
|
12
|
+
* should run (e.g. the focus trap of the topmost Modal).
|
|
13
|
+
*/
|
|
14
|
+
export function useOverlayLayer(
|
|
15
|
+
kind: OverlayLayerKind,
|
|
16
|
+
active: boolean,
|
|
17
|
+
onEscape: () => void,
|
|
18
|
+
): () => boolean {
|
|
19
|
+
const onEscapeRef = useRef(onEscape);
|
|
20
|
+
onEscapeRef.current = onEscape;
|
|
21
|
+
const layerRef = useRef<OverlayLayer | null>(null);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!active || Platform.OS !== "web" || typeof document === "undefined") return undefined;
|
|
25
|
+
|
|
26
|
+
const layer: OverlayLayer = { kind, onEscape: () => onEscapeRef.current() };
|
|
27
|
+
const stack = getDocumentOverlayStack();
|
|
28
|
+
const remove = stack.push(layer);
|
|
29
|
+
layerRef.current = layer;
|
|
30
|
+
|
|
31
|
+
return () => {
|
|
32
|
+
remove();
|
|
33
|
+
if (layerRef.current === layer) layerRef.current = null;
|
|
34
|
+
};
|
|
35
|
+
}, [active, kind]);
|
|
36
|
+
|
|
37
|
+
return useCallback(() => {
|
|
38
|
+
const layer = layerRef.current;
|
|
39
|
+
return layer !== null && getDocumentOverlayStack().isTop(layer);
|
|
40
|
+
}, []);
|
|
41
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useEffect, type RefObject } from "react";
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
import { getTabbableElements, getWrappedFocusIndex } from "./focusTrap";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Web dialog focus management: moves focus into `containerRef` when it opens
|
|
7
|
+
* (unless content already took it, e.g. via `autoFocus`), keeps Tab and
|
|
8
|
+
* Shift+Tab inside while `isTopLayer()` is true, and returns focus to the
|
|
9
|
+
* previously focused element on close. The container needs `tabIndex={-1}`.
|
|
10
|
+
*/
|
|
11
|
+
export function useWebFocusTrap(
|
|
12
|
+
containerRef: RefObject<HTMLElement | null>,
|
|
13
|
+
active: boolean,
|
|
14
|
+
isTopLayer: () => boolean,
|
|
15
|
+
): void {
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!active || Platform.OS !== "web" || typeof document === "undefined") return undefined;
|
|
18
|
+
const container = containerRef.current;
|
|
19
|
+
if (!container) return undefined;
|
|
20
|
+
|
|
21
|
+
const previouslyFocused =
|
|
22
|
+
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
23
|
+
if (!container.contains(document.activeElement)) container.focus({ preventScroll: true });
|
|
24
|
+
|
|
25
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
26
|
+
if (event.key !== "Tab" || event.defaultPrevented || !isTopLayer()) return;
|
|
27
|
+
|
|
28
|
+
const activeElement = document.activeElement;
|
|
29
|
+
// Focus inside another portal (e.g. an app popover) is managed there.
|
|
30
|
+
if (
|
|
31
|
+
activeElement !== null &&
|
|
32
|
+
activeElement !== document.body &&
|
|
33
|
+
!container.contains(activeElement)
|
|
34
|
+
) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const tabbables = getTabbableElements(container);
|
|
39
|
+
if (tabbables.length === 0) {
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
container.focus({ preventScroll: true });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const nextIndex = getWrappedFocusIndex(
|
|
46
|
+
tabbables.length,
|
|
47
|
+
tabbables.indexOf(activeElement as HTMLElement),
|
|
48
|
+
event.shiftKey,
|
|
49
|
+
);
|
|
50
|
+
if (nextIndex === null) return;
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
tabbables[nextIndex].focus();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
56
|
+
return () => {
|
|
57
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
58
|
+
// Restore focus unless the app already moved it somewhere else on purpose.
|
|
59
|
+
const current = document.activeElement;
|
|
60
|
+
if (
|
|
61
|
+
previouslyFocused?.isConnected &&
|
|
62
|
+
(current === null || current === document.body || container.contains(current))
|
|
63
|
+
) {
|
|
64
|
+
previouslyFocused.focus({ preventScroll: true });
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}, [active, containerRef, isTopLayer]);
|
|
68
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { handleWebToggleKeyDown, type WebToggleKeyEvent } from "./webToggleKey";
|
|
3
|
+
|
|
4
|
+
const host = {};
|
|
5
|
+
|
|
6
|
+
function keyEvent(overrides: Partial<WebToggleKeyEvent>): WebToggleKeyEvent {
|
|
7
|
+
return { key: " ", target: host, currentTarget: host, preventDefault: vi.fn(), ...overrides };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("handleWebToggleKeyDown", () => {
|
|
11
|
+
it("toggles on Space and stops the page from scrolling", () => {
|
|
12
|
+
const onToggle = vi.fn();
|
|
13
|
+
const event = keyEvent({});
|
|
14
|
+
|
|
15
|
+
expect(handleWebToggleKeyDown(event, onToggle)).toBe(true);
|
|
16
|
+
expect(onToggle).toHaveBeenCalledTimes(1);
|
|
17
|
+
expect(event.preventDefault).toHaveBeenCalled();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("supports the legacy Spacebar key name", () => {
|
|
21
|
+
const onToggle = vi.fn();
|
|
22
|
+
handleWebToggleKeyDown(keyEvent({ key: "Spacebar" }), onToggle);
|
|
23
|
+
expect(onToggle).toHaveBeenCalledTimes(1);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("does not toggle repeatedly while Space is held down", () => {
|
|
27
|
+
const onToggle = vi.fn();
|
|
28
|
+
const event = keyEvent({ repeat: true });
|
|
29
|
+
|
|
30
|
+
expect(handleWebToggleKeyDown(event, onToggle)).toBe(true);
|
|
31
|
+
expect(onToggle).not.toHaveBeenCalled();
|
|
32
|
+
expect(event.preventDefault).toHaveBeenCalled();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("ignores Space typed into a focusable child", () => {
|
|
36
|
+
const onToggle = vi.fn();
|
|
37
|
+
const event = keyEvent({ target: {} });
|
|
38
|
+
|
|
39
|
+
expect(handleWebToggleKeyDown(event, onToggle)).toBe(false);
|
|
40
|
+
expect(onToggle).not.toHaveBeenCalled();
|
|
41
|
+
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("leaves other keys alone (Enter is already handled by react-native-web)", () => {
|
|
45
|
+
const onToggle = vi.fn();
|
|
46
|
+
expect(handleWebToggleKeyDown(keyEvent({ key: "Enter" }), onToggle)).toBe(false);
|
|
47
|
+
expect(onToggle).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface WebToggleKeyEvent {
|
|
2
|
+
key: string;
|
|
3
|
+
repeat?: boolean;
|
|
4
|
+
target?: unknown;
|
|
5
|
+
currentTarget?: unknown;
|
|
6
|
+
preventDefault: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Space activates switches, checkboxes and radios on web. react-native-web only
|
|
11
|
+
* maps Space to `onPress` for `button` roles, so these controls handle it
|
|
12
|
+
* themselves. Ignores key repeat and keys from nested focusable children.
|
|
13
|
+
* Returns whether the event was handled.
|
|
14
|
+
*/
|
|
15
|
+
export function handleWebToggleKeyDown(event: WebToggleKeyEvent, onToggle: () => void): boolean {
|
|
16
|
+
const isSpace = event.key === " " || event.key === "Spacebar";
|
|
17
|
+
if (!isSpace || event.target !== event.currentTarget) return false;
|
|
18
|
+
|
|
19
|
+
// Stop the page from scrolling even for a held-down key.
|
|
20
|
+
event.preventDefault();
|
|
21
|
+
if (!event.repeat) onToggle();
|
|
22
|
+
return true;
|
|
23
|
+
}
|