@ecohouse/ui 0.1.61 → 0.1.63
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 +331 -115
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -2
- package/dist/index.d.ts +8 -2
- package/dist/index.js +332 -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 +74 -12
- package/src/components/VerificationCodeInput/VerificationCodeInput.tsx +29 -24
- package/src/components/VerificationCodeInput/VerificationCodeInput.utils.ts +59 -14
- package/src/index.ts +6 -1
- 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
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { forwardRef, useMemo, useRef, useState, type ComponentRef, type ReactNode } from "react";
|
|
2
2
|
import {
|
|
3
|
+
Platform,
|
|
3
4
|
Pressable,
|
|
4
5
|
StyleSheet,
|
|
5
6
|
Text,
|
|
@@ -13,6 +14,7 @@ import { CheckIcon } from "../../icons";
|
|
|
13
14
|
import { colors, inputTypography } from "../../theme";
|
|
14
15
|
import { mergeRefs } from "../../utils/mergeRefs";
|
|
15
16
|
import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
|
|
17
|
+
import { handleWebToggleKeyDown, type WebToggleKeyEvent } from "../../utils/webToggleKey";
|
|
16
18
|
|
|
17
19
|
export type CheckboxVariant = "default" | "light";
|
|
18
20
|
|
|
@@ -97,6 +99,10 @@ export const Checkbox = forwardRef<ComponentRef<typeof Pressable>, CheckboxProps
|
|
|
97
99
|
accessibilityRole="checkbox"
|
|
98
100
|
accessibilityLabel={resolvedAccessibilityLabel}
|
|
99
101
|
accessibilityState={{ checked: isActive, disabled }}
|
|
102
|
+
aria-checked={isActive}
|
|
103
|
+
{...(Platform.OS === "web" && !disabled
|
|
104
|
+
? { onKeyDown: (event: WebToggleKeyEvent) => handleWebToggleKeyDown(event, handlePress) }
|
|
105
|
+
: null)}
|
|
100
106
|
style={[styles.root, disabled && styles.disabled, style]}
|
|
101
107
|
>
|
|
102
108
|
<View
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { clampCounterValue, resolveCounterValue, stepCounterValue } from "./Counter.utils";
|
|
3
|
+
|
|
4
|
+
describe("resolveCounterValue", () => {
|
|
5
|
+
it("shows a stored value below min instead of hiding it", () => {
|
|
6
|
+
expect(resolveCounterValue(0, 1, 1, 10)).toBe(0);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("shows a stored value above max instead of hiding it", () => {
|
|
10
|
+
expect(resolveCounterValue(12, 1, 1, 10)).toBe(12);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("clamps the uncontrolled value", () => {
|
|
14
|
+
expect(resolveCounterValue(undefined, 0, 1, 10)).toBe(1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("falls back to min for a non-finite controlled value", () => {
|
|
18
|
+
expect(resolveCounterValue(Number.NaN, 5, 1, 10)).toBe(1);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("stepCounterValue", () => {
|
|
23
|
+
it("steps from an out-of-range value into range instead of skipping min", () => {
|
|
24
|
+
expect(stepCounterValue(0, 1, 1, 10)).toBe(1);
|
|
25
|
+
expect(stepCounterValue(12, -1, 1, 10)).toBe(10);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("clamps steps to the bounds", () => {
|
|
29
|
+
expect(stepCounterValue(9, 5, 1, 10)).toBe(10);
|
|
30
|
+
expect(stepCounterValue(2, -5, 1)).toBe(1);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("clampCounterValue", () => {
|
|
35
|
+
it("allows an open upper bound", () => {
|
|
36
|
+
expect(clampCounterValue(1_000, 0)).toBe(1_000);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -14,6 +14,7 @@ import { MinusIcon, PlusIcon } from "../../icons";
|
|
|
14
14
|
import { colors, counterTypography } from "../../theme";
|
|
15
15
|
import { mergeRefs } from "../../utils/mergeRefs";
|
|
16
16
|
import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
|
|
17
|
+
import { clampCounterValue, resolveCounterValue, stepCounterValue } from "./Counter.utils";
|
|
17
18
|
|
|
18
19
|
export interface CounterProps extends Omit<ViewProps, "style" | "children"> {
|
|
19
20
|
value?: number;
|
|
@@ -40,10 +41,6 @@ const webBlurStyle: ViewStyle =
|
|
|
40
41
|
} as ViewStyle)
|
|
41
42
|
: {};
|
|
42
43
|
|
|
43
|
-
function clamp(value: number, min: number, max?: number) {
|
|
44
|
-
return Math.max(min, max === undefined ? value : Math.min(value, max));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
44
|
export const Counter = forwardRef<ComponentRef<typeof View>, CounterProps>(function Counter(
|
|
48
45
|
{
|
|
49
46
|
value,
|
|
@@ -64,9 +61,9 @@ export const Counter = forwardRef<ComponentRef<typeof View>, CounterProps>(funct
|
|
|
64
61
|
const containerRef = useRef<ComponentRef<typeof View>>(null);
|
|
65
62
|
const isControlled = value !== undefined;
|
|
66
63
|
const [uncontrolledValue, setUncontrolledValue] = useState(() =>
|
|
67
|
-
|
|
64
|
+
clampCounterValue(defaultValue ?? min, min, max),
|
|
68
65
|
);
|
|
69
|
-
const resolvedValue =
|
|
66
|
+
const resolvedValue = resolveCounterValue(value, uncontrolledValue, min, max);
|
|
70
67
|
const resolvedClassName = className?.trim() || undefined;
|
|
71
68
|
const setContainerRef = useMemo(() => mergeRefs(containerRef, forwardedRef), [forwardedRef]);
|
|
72
69
|
const decrementDisabled = disabled || resolvedValue <= min;
|
|
@@ -74,8 +71,8 @@ export const Counter = forwardRef<ComponentRef<typeof View>, CounterProps>(funct
|
|
|
74
71
|
|
|
75
72
|
useApplyWebClassName(containerRef, resolvedClassName);
|
|
76
73
|
|
|
77
|
-
const updateValue = (
|
|
78
|
-
const next =
|
|
74
|
+
const updateValue = (delta: number) => {
|
|
75
|
+
const next = stepCounterValue(resolvedValue, delta, min, max);
|
|
79
76
|
if (next === resolvedValue) return;
|
|
80
77
|
if (!isControlled) setUncontrolledValue(next);
|
|
81
78
|
onValueChange?.(next);
|
|
@@ -85,7 +82,7 @@ export const Counter = forwardRef<ComponentRef<typeof View>, CounterProps>(funct
|
|
|
85
82
|
<Pressable
|
|
86
83
|
disabled={decrementDisabled}
|
|
87
84
|
hitSlop={hitSlop}
|
|
88
|
-
onPress={() => updateValue(
|
|
85
|
+
onPress={() => updateValue(-step)}
|
|
89
86
|
accessibilityRole="button"
|
|
90
87
|
accessibilityLabel="Decrease value"
|
|
91
88
|
accessibilityState={{ disabled: decrementDisabled }}
|
|
@@ -98,7 +95,7 @@ export const Counter = forwardRef<ComponentRef<typeof View>, CounterProps>(funct
|
|
|
98
95
|
<Pressable
|
|
99
96
|
disabled={incrementDisabled}
|
|
100
97
|
hitSlop={hitSlop}
|
|
101
|
-
onPress={() => updateValue(
|
|
98
|
+
onPress={() => updateValue(step)}
|
|
102
99
|
accessibilityRole="button"
|
|
103
100
|
accessibilityLabel="Increase value"
|
|
104
101
|
accessibilityState={{ disabled: incrementDisabled }}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function clampCounterValue(value: number, min: number, max?: number): number {
|
|
2
|
+
return Math.max(min, max === undefined ? value : Math.min(value, max));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Value the counter displays and steps from. A controlled value is shown as-is,
|
|
7
|
+
* even outside `min`/`max`, so a stored 0 under `min = 1` is not displayed as 1
|
|
8
|
+
* while the form still holds 0.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveCounterValue(
|
|
11
|
+
controlledValue: number | undefined,
|
|
12
|
+
uncontrolledValue: number,
|
|
13
|
+
min: number,
|
|
14
|
+
max?: number,
|
|
15
|
+
): number {
|
|
16
|
+
if (controlledValue !== undefined && Number.isFinite(controlledValue)) return controlledValue;
|
|
17
|
+
return clampCounterValue(controlledValue === undefined ? uncontrolledValue : min, min, max);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Next value after a step, clamped into range: from 0 with `min = 1`, "+" gives 1, not 2. */
|
|
21
|
+
export function stepCounterValue(
|
|
22
|
+
current: number,
|
|
23
|
+
delta: number,
|
|
24
|
+
min: number,
|
|
25
|
+
max?: number,
|
|
26
|
+
): number {
|
|
27
|
+
return clampCounterValue(current + delta, min, max);
|
|
28
|
+
}
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from "../../utils/dateUtils";
|
|
35
35
|
import { mergeRefs } from "../../utils/mergeRefs";
|
|
36
36
|
import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
|
|
37
|
+
import { useOverlayLayer } from "../../utils/useOverlayLayer";
|
|
37
38
|
|
|
38
39
|
/** Monday-first — override via `monthNames` for other locales. */
|
|
39
40
|
const DEFAULT_MONTH_NAMES = [
|
|
@@ -365,20 +366,9 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
|
|
|
365
366
|
focusTrigger();
|
|
366
367
|
}, [focusTrigger, setOpen]);
|
|
367
368
|
|
|
368
|
-
// Escape closes the calendar and returns focus to the trigger (web only).
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const handleKeyDown = (event: KeyboardEvent) => {
|
|
373
|
-
if (event.key === "Escape") {
|
|
374
|
-
event.preventDefault();
|
|
375
|
-
closeAndFocusTrigger();
|
|
376
|
-
}
|
|
377
|
-
};
|
|
378
|
-
|
|
379
|
-
document.addEventListener("keydown", handleKeyDown);
|
|
380
|
-
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
381
|
-
}, [isOpen, closeAndFocusTrigger]);
|
|
369
|
+
// Escape closes the calendar and returns focus to the trigger (web only). It is
|
|
370
|
+
// claimed before a surrounding Modal/Drawer can react, so they stay open.
|
|
371
|
+
useOverlayLayer("popover", isOpen, closeAndFocusTrigger);
|
|
382
372
|
|
|
383
373
|
const handleTriggerPress = () => {
|
|
384
374
|
if (disabled) return;
|
|
@@ -503,6 +493,7 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
|
|
|
503
493
|
accessibilityRole="button"
|
|
504
494
|
accessibilityLabel={resolvedAccessibilityLabel}
|
|
505
495
|
accessibilityState={{ disabled, expanded: isOpen }}
|
|
496
|
+
aria-expanded={isOpen}
|
|
506
497
|
style={[
|
|
507
498
|
styles.trigger,
|
|
508
499
|
usesRangeTrigger && styles.rangeTrigger,
|
|
@@ -26,6 +26,8 @@ import { CloseIcon } from "../../icons";
|
|
|
26
26
|
import { colors, fonts } from "../../theme";
|
|
27
27
|
import { mergeRefs } from "../../utils/mergeRefs";
|
|
28
28
|
import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
|
|
29
|
+
import { useHydrated } from "../../utils/useHydrated";
|
|
30
|
+
import { useOverlayLayer } from "../../utils/useOverlayLayer";
|
|
29
31
|
|
|
30
32
|
export interface DrawerProps {
|
|
31
33
|
/** Controls visibility. */
|
|
@@ -84,24 +86,25 @@ export const Drawer = forwardRef<ComponentRef<typeof View>, DrawerProps>(functio
|
|
|
84
86
|
const animatedWidth = useRef(new Animated.Value(open ? width : 0)).current;
|
|
85
87
|
const animatedBackdrop = useRef(new Animated.Value(open ? 1 : 0)).current;
|
|
86
88
|
|
|
87
|
-
|
|
89
|
+
// The portal is client-only; render it after hydration so SSR and the first client render match.
|
|
90
|
+
const hydrated = useHydrated();
|
|
91
|
+
const webMounted = mounted && hydrated && Platform.OS === "web";
|
|
92
|
+
|
|
93
|
+
useApplyWebClassName(panelRef, resolvedClassName, webMounted);
|
|
88
94
|
|
|
89
95
|
useEffect(() => {
|
|
90
|
-
if (!
|
|
96
|
+
if (!webMounted) return undefined;
|
|
91
97
|
|
|
92
98
|
const previousOverflow = document.body.style.overflow;
|
|
93
99
|
document.body.style.overflow = "hidden";
|
|
94
100
|
|
|
95
|
-
const onKeyDown = (event: KeyboardEvent) => {
|
|
96
|
-
if (event.key === "Escape") onClose();
|
|
97
|
-
};
|
|
98
|
-
document.addEventListener("keydown", onKeyDown);
|
|
99
|
-
|
|
100
101
|
return () => {
|
|
101
102
|
document.body.style.overflow = previousOverflow;
|
|
102
|
-
document.removeEventListener("keydown", onKeyDown);
|
|
103
103
|
};
|
|
104
|
-
}, [
|
|
104
|
+
}, [webMounted]);
|
|
105
|
+
|
|
106
|
+
// Escape closes only the topmost overlay, never the Drawer behind an open Select/DatePicker.
|
|
107
|
+
useOverlayLayer("overlay", webMounted && open, onClose);
|
|
105
108
|
|
|
106
109
|
const targetWidth = Math.min(width, typeof window !== "undefined" ? window.innerWidth : width);
|
|
107
110
|
|
|
@@ -156,7 +159,7 @@ export const Drawer = forwardRef<ComponentRef<typeof View>, DrawerProps>(functio
|
|
|
156
159
|
|
|
157
160
|
// Portal exists only after mounted flips. Animate from 0 on the next frame.
|
|
158
161
|
useEffect(() => {
|
|
159
|
-
if (
|
|
162
|
+
if (!webMounted || !open) return undefined;
|
|
160
163
|
|
|
161
164
|
const shell = shellRef.current;
|
|
162
165
|
if (shell) {
|
|
@@ -172,7 +175,7 @@ export const Drawer = forwardRef<ComponentRef<typeof View>, DrawerProps>(functio
|
|
|
172
175
|
});
|
|
173
176
|
|
|
174
177
|
return () => cancelAnimationFrame(frame);
|
|
175
|
-
}, [
|
|
178
|
+
}, [webMounted, open, targetWidth, playWidth]);
|
|
176
179
|
|
|
177
180
|
// Native: animate width + backdrop opacity.
|
|
178
181
|
useEffect(() => {
|
|
@@ -262,7 +265,7 @@ export const Drawer = forwardRef<ComponentRef<typeof View>, DrawerProps>(functio
|
|
|
262
265
|
);
|
|
263
266
|
|
|
264
267
|
if (Platform.OS === "web") {
|
|
265
|
-
if (!
|
|
268
|
+
if (!webMounted) return null;
|
|
266
269
|
|
|
267
270
|
return createPortal(
|
|
268
271
|
<div
|
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
} from "react-native";
|
|
12
12
|
import { mergeRefs } from "../../utils/mergeRefs";
|
|
13
13
|
import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
|
|
14
|
+
import { useHydrated } from "../../utils/useHydrated";
|
|
15
|
+
import { useOverlayLayer } from "../../utils/useOverlayLayer";
|
|
16
|
+
import { useWebFocusTrap } from "../../utils/useWebFocusTrap";
|
|
14
17
|
|
|
15
18
|
export interface ModalProps {
|
|
16
19
|
/** Controls visibility. */
|
|
@@ -59,6 +62,15 @@ const webRootDomStyle = {
|
|
|
59
62
|
justifyContent: "center",
|
|
60
63
|
} as const;
|
|
61
64
|
|
|
65
|
+
/** Focus target on open; the content provides its own focus styles. */
|
|
66
|
+
const webDialogDomStyle = {
|
|
67
|
+
position: "relative",
|
|
68
|
+
zIndex: 1,
|
|
69
|
+
maxWidth: "100%",
|
|
70
|
+
maxHeight: "100%",
|
|
71
|
+
outline: "none",
|
|
72
|
+
} as const;
|
|
73
|
+
|
|
62
74
|
export const Modal = forwardRef<ComponentRef<typeof View>, ModalProps>(function Modal(
|
|
63
75
|
{
|
|
64
76
|
open,
|
|
@@ -72,27 +84,29 @@ export const Modal = forwardRef<ComponentRef<typeof View>, ModalProps>(function
|
|
|
72
84
|
forwardedRef,
|
|
73
85
|
) {
|
|
74
86
|
const contentRef = useRef<ComponentRef<typeof View>>(null);
|
|
87
|
+
const dialogRef = useRef<HTMLDivElement | null>(null);
|
|
75
88
|
const setContentRef = useMemo(() => mergeRefs(contentRef, forwardedRef), [forwardedRef]);
|
|
76
89
|
const resolvedClassName = className?.trim() || undefined;
|
|
90
|
+
// The portal is client-only; render it after hydration so SSR and the first client render match.
|
|
91
|
+
const hydrated = useHydrated();
|
|
92
|
+
const webOpen = open && hydrated && Platform.OS === "web";
|
|
77
93
|
|
|
78
|
-
useApplyWebClassName(contentRef, resolvedClassName,
|
|
94
|
+
useApplyWebClassName(contentRef, resolvedClassName, webOpen);
|
|
79
95
|
|
|
80
96
|
useEffect(() => {
|
|
81
|
-
if (!
|
|
97
|
+
if (!webOpen) return undefined;
|
|
82
98
|
|
|
83
99
|
const previousOverflow = document.body.style.overflow;
|
|
84
100
|
document.body.style.overflow = "hidden";
|
|
85
101
|
|
|
86
|
-
const onKeyDown = (event: KeyboardEvent) => {
|
|
87
|
-
if (event.key === "Escape") onClose();
|
|
88
|
-
};
|
|
89
|
-
document.addEventListener("keydown", onKeyDown);
|
|
90
|
-
|
|
91
102
|
return () => {
|
|
92
103
|
document.body.style.overflow = previousOverflow;
|
|
93
|
-
document.removeEventListener("keydown", onKeyDown);
|
|
94
104
|
};
|
|
95
|
-
}, [
|
|
105
|
+
}, [webOpen]);
|
|
106
|
+
|
|
107
|
+
// Escape closes only the topmost overlay, never the Modal behind an open Select/DatePicker.
|
|
108
|
+
const isTopLayer = useOverlayLayer("overlay", webOpen, onClose);
|
|
109
|
+
useWebFocusTrap(dialogRef, webOpen, isTopLayer);
|
|
96
110
|
|
|
97
111
|
const content = (
|
|
98
112
|
<View
|
|
@@ -106,7 +120,7 @@ export const Modal = forwardRef<ComponentRef<typeof View>, ModalProps>(function
|
|
|
106
120
|
|
|
107
121
|
// Web: portal + real DOM backdrop so `backdrop-filter` blurs the page behind.
|
|
108
122
|
if (Platform.OS === "web") {
|
|
109
|
-
if (!
|
|
123
|
+
if (!webOpen) return null;
|
|
110
124
|
|
|
111
125
|
return createPortal(
|
|
112
126
|
<div style={webRootDomStyle} role="presentation">
|
|
@@ -117,7 +131,13 @@ export const Modal = forwardRef<ComponentRef<typeof View>, ModalProps>(function
|
|
|
117
131
|
onClick={closeOnBackdropPress ? onClose : undefined}
|
|
118
132
|
style={webBackdropDomStyle}
|
|
119
133
|
/>
|
|
120
|
-
<div
|
|
134
|
+
<div
|
|
135
|
+
ref={dialogRef}
|
|
136
|
+
// No aria-modal: Select/DatePicker menus are portaled outside and must stay reachable.
|
|
137
|
+
role="dialog"
|
|
138
|
+
tabIndex={-1}
|
|
139
|
+
style={webDialogDomStyle}
|
|
140
|
+
>
|
|
121
141
|
{content}
|
|
122
142
|
</div>
|
|
123
143
|
</div>,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { forwardRef, useMemo, useRef, useState, type ComponentRef, type ReactNode } from "react";
|
|
2
2
|
import {
|
|
3
|
+
Platform,
|
|
3
4
|
Pressable,
|
|
4
5
|
StyleSheet,
|
|
5
6
|
Text,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
import { colors, fonts } from "../../theme";
|
|
13
14
|
import { mergeRefs } from "../../utils/mergeRefs";
|
|
14
15
|
import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
|
|
16
|
+
import { handleWebToggleKeyDown, type WebToggleKeyEvent } from "../../utils/webToggleKey";
|
|
15
17
|
|
|
16
18
|
export type RadioButtonVariant = "default";
|
|
17
19
|
|
|
@@ -81,6 +83,18 @@ export const RadioButton = forwardRef<ComponentRef<typeof Pressable>, RadioButto
|
|
|
81
83
|
accessibilityRole="radio"
|
|
82
84
|
accessibilityLabel={resolvedAccessibilityLabel}
|
|
83
85
|
accessibilityState={{ selected: isActive, disabled }}
|
|
86
|
+
// ARIA radios expose their state as aria-checked; react-native-web ignores accessibilityState.
|
|
87
|
+
{...(Platform.OS === "web"
|
|
88
|
+
? {
|
|
89
|
+
"aria-checked": isActive,
|
|
90
|
+
...(disabled
|
|
91
|
+
? null
|
|
92
|
+
: {
|
|
93
|
+
onKeyDown: (event: WebToggleKeyEvent) =>
|
|
94
|
+
handleWebToggleKeyDown(event, handlePress),
|
|
95
|
+
}),
|
|
96
|
+
}
|
|
97
|
+
: null)}
|
|
84
98
|
style={[styles.root, disabled && styles.disabled, style]}
|
|
85
99
|
>
|
|
86
100
|
<View style={styles.control}>{isActive ? <View style={styles.dot} /> : null}</View>
|
|
@@ -30,6 +30,7 @@ import { colors, selectTypography } from "../../theme";
|
|
|
30
30
|
import { mergeRefs } from "../../utils/mergeRefs";
|
|
31
31
|
import { renderStatefulIcon, type StatefulIcon } from "../../utils/renderStatefulIcon";
|
|
32
32
|
import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
|
|
33
|
+
import { useOverlayLayer } from "../../utils/useOverlayLayer";
|
|
33
34
|
|
|
34
35
|
export type SelectOption = {
|
|
35
36
|
label: string;
|
|
@@ -111,6 +112,11 @@ type WebMenuPosition = {
|
|
|
111
112
|
|
|
112
113
|
type ItemVisualState = "default" | "hover" | "selected";
|
|
113
114
|
|
|
115
|
+
/** Web-only listbox semantics for the option list (`listbox` is missing from RN's `Role` type). */
|
|
116
|
+
function webListboxProps(multiple: boolean): ViewProps {
|
|
117
|
+
return { role: "listbox", "aria-multiselectable": multiple } as unknown as ViewProps;
|
|
118
|
+
}
|
|
119
|
+
|
|
114
120
|
function itemBackground(state: ItemVisualState): string {
|
|
115
121
|
switch (state) {
|
|
116
122
|
case "hover":
|
|
@@ -525,8 +531,12 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
525
531
|
focusTrigger();
|
|
526
532
|
}, [focusTrigger, setOpen]);
|
|
527
533
|
|
|
534
|
+
// Escape closes the menu and returns focus to the trigger (web only). It is
|
|
535
|
+
// claimed before a surrounding Modal/Drawer can react, so they stay open.
|
|
536
|
+
useOverlayLayer("popover", isOpen, closeAndFocusTrigger);
|
|
537
|
+
|
|
528
538
|
// ARIA combobox keyboard pattern (web only): Arrow keys move the highlighted
|
|
529
|
-
// option, Enter/Space selects it
|
|
539
|
+
// option, Enter/Space selects it.
|
|
530
540
|
useEffect(() => {
|
|
531
541
|
if (Platform.OS !== "web") return;
|
|
532
542
|
|
|
@@ -543,12 +553,6 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
543
553
|
return;
|
|
544
554
|
}
|
|
545
555
|
|
|
546
|
-
if (event.key === "Escape") {
|
|
547
|
-
event.preventDefault();
|
|
548
|
-
closeAndFocusTrigger();
|
|
549
|
-
return;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
556
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
553
557
|
event.preventDefault();
|
|
554
558
|
setHoveredValue((current) => {
|
|
@@ -602,7 +606,6 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
602
606
|
filteredOptions,
|
|
603
607
|
hoveredValue,
|
|
604
608
|
multiple,
|
|
605
|
-
closeAndFocusTrigger,
|
|
606
609
|
focusTrigger,
|
|
607
610
|
handleSelect,
|
|
608
611
|
setOpen,
|
|
@@ -691,6 +694,7 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
691
694
|
contentContainerStyle={styles.optionListContent}
|
|
692
695
|
keyboardShouldPersistTaps="handled"
|
|
693
696
|
showsVerticalScrollIndicator={false}
|
|
697
|
+
{...(Platform.OS === "web" ? webListboxProps(multiple) : null)}
|
|
694
698
|
>
|
|
695
699
|
{loading ? (
|
|
696
700
|
<View style={styles.statusRow}>
|
|
@@ -727,6 +731,9 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
727
731
|
}
|
|
728
732
|
accessibilityRole={multiple ? "checkbox" : "button"}
|
|
729
733
|
accessibilityState={{ selected: isSelected, checked: isSelected, disabled }}
|
|
734
|
+
// react-native-web ignores accessibilityState apart from `disabled`.
|
|
735
|
+
role={Platform.OS === "web" ? "option" : undefined}
|
|
736
|
+
aria-selected={isSelected}
|
|
730
737
|
style={[
|
|
731
738
|
styles.item,
|
|
732
739
|
{
|
|
@@ -773,6 +780,7 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
773
780
|
accessibilityRole="combobox"
|
|
774
781
|
accessibilityLabel={resolvedAccessibilityLabel}
|
|
775
782
|
accessibilityState={{ disabled, expanded: isOpen }}
|
|
783
|
+
aria-expanded={isOpen}
|
|
776
784
|
style={[
|
|
777
785
|
styles.trigger,
|
|
778
786
|
{
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import {
|
|
11
11
|
Animated,
|
|
12
12
|
Easing,
|
|
13
|
+
Platform,
|
|
13
14
|
Pressable,
|
|
14
15
|
StyleSheet,
|
|
15
16
|
Text,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
import { colors, inputTypography } from "../../theme";
|
|
22
23
|
import { mergeRefs } from "../../utils/mergeRefs";
|
|
23
24
|
import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
|
|
25
|
+
import { handleWebToggleKeyDown, type WebToggleKeyEvent } from "../../utils/webToggleKey";
|
|
24
26
|
|
|
25
27
|
export interface ToggleProps extends Omit<
|
|
26
28
|
PressableProps,
|
|
@@ -115,6 +117,10 @@ export const Toggle = forwardRef<ComponentRef<typeof Pressable>, ToggleProps>(fu
|
|
|
115
117
|
accessibilityRole="switch"
|
|
116
118
|
accessibilityLabel={resolvedAccessibilityLabel}
|
|
117
119
|
accessibilityState={{ checked: isActive, disabled }}
|
|
120
|
+
aria-checked={isActive}
|
|
121
|
+
{...(Platform.OS === "web" && !disabled
|
|
122
|
+
? { onKeyDown: (event: WebToggleKeyEvent) => handleWebToggleKeyDown(event, handlePress) }
|
|
123
|
+
: null)}
|
|
118
124
|
style={[styles.pressable, disabled && styles.disabled, style]}
|
|
119
125
|
>
|
|
120
126
|
<Animated.View
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
isVerificationCodeComplete,
|
|
4
|
+
normalizeVerificationCode,
|
|
5
|
+
reconcileVerificationCells,
|
|
6
|
+
toVerificationCells,
|
|
7
|
+
updateVerificationCells,
|
|
8
|
+
verificationCellsToCode,
|
|
9
|
+
} from "./VerificationCodeInput.utils";
|
|
3
10
|
|
|
4
11
|
describe("normalizeVerificationCode", () => {
|
|
5
12
|
it("keeps digits only and respects the configured length", () => {
|
|
@@ -11,32 +18,87 @@ describe("normalizeVerificationCode", () => {
|
|
|
11
18
|
});
|
|
12
19
|
});
|
|
13
20
|
|
|
14
|
-
describe("
|
|
21
|
+
describe("toVerificationCells", () => {
|
|
22
|
+
it("lays a code out over a fixed number of cells", () => {
|
|
23
|
+
expect(toVerificationCells("12", 4)).toEqual(["1", "2", "", ""]);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("updateVerificationCells", () => {
|
|
15
28
|
it("distributes a pasted code from the selected cell", () => {
|
|
16
|
-
expect(
|
|
17
|
-
|
|
29
|
+
expect(updateVerificationCells(toVerificationCells("", 6), 0, "12 34-56", 6)).toEqual({
|
|
30
|
+
cells: ["1", "2", "3", "4", "5", "6"],
|
|
31
|
+
nextFocus: 5,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("lays a whole pasted code out from the start when a later cell has focus", () => {
|
|
36
|
+
expect(updateVerificationCells(toVerificationCells("", 6), 4, "123456", 6)).toEqual({
|
|
37
|
+
cells: ["1", "2", "3", "4", "5", "6"],
|
|
38
|
+
nextFocus: 5,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("still fills forward when the pasted digits fit from the focused cell", () => {
|
|
43
|
+
expect(updateVerificationCells(toVerificationCells("12", 6), 2, "3456", 6)).toEqual({
|
|
44
|
+
cells: ["1", "2", "3", "4", "5", "6"],
|
|
18
45
|
nextFocus: 5,
|
|
19
46
|
});
|
|
20
47
|
});
|
|
21
48
|
|
|
22
49
|
it("replaces a selected digit without growing the code", () => {
|
|
23
|
-
expect(
|
|
24
|
-
|
|
50
|
+
expect(updateVerificationCells(toVerificationCells("123456", 6), 2, "9", 6)).toEqual({
|
|
51
|
+
cells: ["1", "2", "9", "4", "5", "6"],
|
|
25
52
|
nextFocus: 3,
|
|
26
53
|
});
|
|
27
54
|
});
|
|
28
55
|
|
|
29
|
-
it("
|
|
30
|
-
expect(
|
|
31
|
-
|
|
56
|
+
it("leaves a gap when a middle cell is cleared instead of shifting later digits", () => {
|
|
57
|
+
expect(updateVerificationCells(toVerificationCells("129456", 6), 2, "", 6)).toEqual({
|
|
58
|
+
cells: ["1", "2", "", "4", "5", "6"],
|
|
32
59
|
nextFocus: 2,
|
|
33
60
|
});
|
|
34
61
|
});
|
|
35
62
|
|
|
36
|
-
it("
|
|
37
|
-
|
|
38
|
-
|
|
63
|
+
it("fills a gap in place", () => {
|
|
64
|
+
const update = updateVerificationCells(["1", "2", "", "4", "5", "6"], 2, "3", 6);
|
|
65
|
+
expect(update.cells).toEqual(["1", "2", "3", "4", "5", "6"]);
|
|
66
|
+
expect(isVerificationCodeComplete(update.cells)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("treats non-numeric input as clearing the cell", () => {
|
|
70
|
+
expect(updateVerificationCells(toVerificationCells("123", 6), 2, "abc", 6)).toEqual({
|
|
71
|
+
cells: ["1", "2", "", "", "", ""],
|
|
39
72
|
nextFocus: 2,
|
|
40
73
|
});
|
|
41
74
|
});
|
|
42
75
|
});
|
|
76
|
+
|
|
77
|
+
describe("isVerificationCodeComplete", () => {
|
|
78
|
+
it("requires every cell to hold a digit", () => {
|
|
79
|
+
expect(isVerificationCodeComplete(["1", "2", "", "4", "5", "6"])).toBe(false);
|
|
80
|
+
expect(verificationCellsToCode(["1", "2", "", "4", "5", "6"])).toBe("12456");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("reconcileVerificationCells", () => {
|
|
85
|
+
it("keeps gaps while the parent echoes the reported code back", () => {
|
|
86
|
+
const cells = ["1", "2", "", "4", "5", "6"];
|
|
87
|
+
expect(reconcileVerificationCells(cells, "12456", 6)).toEqual(cells);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("lays out a new value set by the parent", () => {
|
|
91
|
+
expect(reconcileVerificationCells(["1", "2", "", "4", "5", "6"], "", 6)).toEqual([
|
|
92
|
+
"",
|
|
93
|
+
"",
|
|
94
|
+
"",
|
|
95
|
+
"",
|
|
96
|
+
"",
|
|
97
|
+
"",
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("re-lays the code out when the length changes", () => {
|
|
102
|
+
expect(reconcileVerificationCells(["1", "2", "3"], "123", 4)).toEqual(["1", "2", "3", ""]);
|
|
103
|
+
});
|
|
104
|
+
});
|