@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.
Files changed (33) hide show
  1. package/dist/index.cjs +331 -115
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +8 -2
  4. package/dist/index.d.ts +8 -2
  5. package/dist/index.js +332 -117
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/components/Checkbox/Checkbox.tsx +6 -0
  9. package/src/components/Counter/Counter.test.ts +38 -0
  10. package/src/components/Counter/Counter.tsx +7 -10
  11. package/src/components/Counter/Counter.utils.ts +28 -0
  12. package/src/components/DatePicker/DatePicker.tsx +5 -14
  13. package/src/components/Drawer/Drawer.tsx +15 -12
  14. package/src/components/Modal/Modal.tsx +31 -11
  15. package/src/components/RadioButton/RadioButton.tsx +14 -0
  16. package/src/components/Select/Select.tsx +16 -8
  17. package/src/components/Toggle/Toggle.tsx +6 -0
  18. package/src/components/VerificationCodeInput/VerificationCodeInput.test.ts +74 -12
  19. package/src/components/VerificationCodeInput/VerificationCodeInput.tsx +29 -24
  20. package/src/components/VerificationCodeInput/VerificationCodeInput.utils.ts +59 -14
  21. package/src/index.ts +6 -1
  22. package/src/utils/dateUtils.test.ts +72 -0
  23. package/src/utils/dateUtils.ts +30 -2
  24. package/src/utils/focusTrap.test.ts +26 -0
  25. package/src/utils/focusTrap.ts +39 -0
  26. package/src/utils/overlayStack.test.ts +175 -0
  27. package/src/utils/overlayStack.ts +105 -0
  28. package/src/utils/useHydrated.test.ts +14 -0
  29. package/src/utils/useHydrated.ts +14 -0
  30. package/src/utils/useOverlayLayer.ts +41 -0
  31. package/src/utils/useWebFocusTrap.ts +68 -0
  32. package/src/utils/webToggleKey.test.ts +49 -0
  33. package/src/utils/webToggleKey.ts +23 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecohouse/ui",
3
- "version": "0.1.61",
3
+ "version": "0.1.63",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Cross-platform presentation-only UI components for EcoHouse (React Native + React Native Web)",
@@ -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
- clamp(defaultValue ?? min, min, max),
64
+ clampCounterValue(defaultValue ?? min, min, max),
68
65
  );
69
- const resolvedValue = clamp(isControlled ? value : uncontrolledValue, min, max);
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 = (nextValue: number) => {
78
- const next = clamp(nextValue, min, max);
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(resolvedValue - step)}
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(resolvedValue + step)}
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
- useEffect(() => {
370
- if (Platform.OS !== "web" || !isOpen) return;
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
- useApplyWebClassName(panelRef, resolvedClassName, mounted && Platform.OS === "web");
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 (!mounted || Platform.OS !== "web") return undefined;
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
- }, [mounted, onClose]);
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 (Platform.OS !== "web" || !mounted || !open) return undefined;
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
- }, [mounted, open, targetWidth, playWidth]);
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 (!mounted || typeof document === "undefined") return null;
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, open && Platform.OS === "web");
94
+ useApplyWebClassName(contentRef, resolvedClassName, webOpen);
79
95
 
80
96
  useEffect(() => {
81
- if (!open || Platform.OS !== "web") return undefined;
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
- }, [open, onClose]);
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 (!open || typeof document === "undefined") return null;
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 style={{ position: "relative", zIndex: 1, maxWidth: "100%", maxHeight: "100%" }}>
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, Escape closes and returns focus to the trigger.
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 { normalizeVerificationCode, updateVerificationCode } from "./VerificationCodeInput.utils";
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("updateVerificationCode", () => {
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(updateVerificationCode("", 0, "12 34-56", 6)).toEqual({
17
- code: "123456",
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(updateVerificationCode("123456", 2, "9", 6)).toEqual({
24
- code: "129456",
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("removes a cleared digit", () => {
30
- expect(updateVerificationCode("123456", 2, "", 6)).toEqual({
31
- code: "12456",
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("ignores non-numeric input", () => {
37
- expect(updateVerificationCode("123", 2, "abc", 6)).toEqual({
38
- code: "12",
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
+ });