@ecohouse/ui 0.1.28 → 0.1.30

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.
@@ -0,0 +1,305 @@
1
+ import {
2
+ forwardRef,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type ComponentRef,
8
+ type ReactNode,
9
+ } from "react";
10
+ import {
11
+ Animated,
12
+ Easing,
13
+ Pressable,
14
+ StyleSheet,
15
+ View,
16
+ type StyleProp,
17
+ type ViewProps,
18
+ type ViewStyle,
19
+ } from "react-native";
20
+ import type { IconComponent } from "../../icons";
21
+ import { SidebarIcon } from "../../icons";
22
+ import { BrandLogo, MenuItem } from "../../primitives";
23
+ import { colors, fonts } from "../../theme";
24
+ import { mergeRefs } from "../../utils/mergeRefs";
25
+ import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
26
+ import { AvatarSimple } from "../AvatarSimple";
27
+
28
+ export interface SidebarItem {
29
+ /** Stable identity for React keys. */
30
+ key: string;
31
+ label: string;
32
+ /** Icon component — colored by the sidebar based on active state. */
33
+ icon: IconComponent;
34
+ /** Marks the current route / selection. */
35
+ active?: boolean;
36
+ onPress?: () => void;
37
+ }
38
+
39
+ export interface SidebarUser {
40
+ name: string;
41
+ email: string;
42
+ imageUrl?: string | null;
43
+ }
44
+
45
+ export interface SidebarProps extends Omit<ViewProps, "style" | "children"> {
46
+ items: readonly SidebarItem[];
47
+ user: SidebarUser;
48
+ onLogOut?: () => void;
49
+ /** Accessible name for the brand mark. Defaults to `"Cortel Booking"`. */
50
+ logoAccessibilityLabel?: string;
51
+ /** Accessible name for the nav landmark. Defaults to `"Account navigation"`. */
52
+ navigationAccessibilityLabel?: string;
53
+ /** Accessible label when the sidebar is expanded. Defaults to `"Collapse sidebar"`. */
54
+ collapseAccessibilityLabel?: string;
55
+ /** Accessible label when the sidebar is collapsed. Defaults to `"Expand sidebar"`. */
56
+ expandAccessibilityLabel?: string;
57
+ /** Accessible label for the logout control. Defaults to `"Log out"`. */
58
+ logOutAccessibilityLabel?: string;
59
+ collapsed?: boolean;
60
+ defaultCollapsed?: boolean;
61
+ onCollapsedChange?: (collapsed: boolean) => void;
62
+ style?: StyleProp<ViewStyle>;
63
+ className?: string;
64
+ }
65
+
66
+ const SIDEBAR_WIDTH = 300;
67
+ const SIDEBAR_COLLAPSED_WIDTH = 72;
68
+ const HEADER_HEIGHT = 64;
69
+ const LOGO_WIDTH = 31;
70
+ const LOGO_HEIGHT = 44;
71
+ const TOGGLE_SIZE = 20;
72
+ const MENU_ICON_SIZE = 16;
73
+ const PROFILE_SECTION_HEIGHT = 80;
74
+ const SIDEBAR_TRANSITION_MS = 300;
75
+
76
+ export const Sidebar = forwardRef<ComponentRef<typeof View>, SidebarProps>(function Sidebar(
77
+ {
78
+ items,
79
+ user,
80
+ onLogOut,
81
+ logoAccessibilityLabel = "Cortel Booking",
82
+ navigationAccessibilityLabel = "Account navigation",
83
+ collapseAccessibilityLabel = "Collapse sidebar",
84
+ expandAccessibilityLabel = "Expand sidebar",
85
+ logOutAccessibilityLabel = "Log out",
86
+ collapsed: collapsedProp,
87
+ defaultCollapsed = false,
88
+ onCollapsedChange,
89
+ style,
90
+ className,
91
+ ...rest
92
+ },
93
+ forwardedRef,
94
+ ) {
95
+ const containerRef = useRef<ComponentRef<typeof View>>(null);
96
+ const setContainerRef = useMemo(() => mergeRefs(containerRef, forwardedRef), [forwardedRef]);
97
+ const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState(defaultCollapsed);
98
+ const collapsed = collapsedProp ?? uncontrolledCollapsed;
99
+ const widthAnim = useRef(
100
+ new Animated.Value(collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH),
101
+ ).current;
102
+ const labelOpacity = useRef(new Animated.Value(collapsed ? 0 : 1)).current;
103
+
104
+ useApplyWebClassName(containerRef, className);
105
+
106
+ useEffect(() => {
107
+ const nextWidth = collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH;
108
+ const nextOpacity = collapsed ? 0 : 1;
109
+ const timing = {
110
+ duration: SIDEBAR_TRANSITION_MS,
111
+ easing: Easing.inOut(Easing.ease),
112
+ useNativeDriver: false,
113
+ };
114
+
115
+ const widthAnimation = Animated.timing(widthAnim, { ...timing, toValue: nextWidth });
116
+ const opacityAnimation = Animated.timing(labelOpacity, { ...timing, toValue: nextOpacity });
117
+
118
+ Animated.parallel([widthAnimation, opacityAnimation]).start();
119
+ return () => {
120
+ widthAnimation.stop();
121
+ opacityAnimation.stop();
122
+ };
123
+ }, [collapsed, labelOpacity, widthAnim]);
124
+
125
+ const setCollapsed = (next: boolean) => {
126
+ onCollapsedChange?.(next);
127
+ if (collapsedProp === undefined) {
128
+ setUncontrolledCollapsed(next);
129
+ }
130
+ };
131
+
132
+ return (
133
+ <Animated.View
134
+ {...rest}
135
+ ref={setContainerRef}
136
+ style={[styles.root, { width: widthAnim }, style]}
137
+ accessibilityRole="menu"
138
+ >
139
+ <View style={[styles.header, collapsed ? styles.headerCollapsed : styles.headerExpanded]}>
140
+ <Animated.View
141
+ pointerEvents={collapsed ? "none" : "auto"}
142
+ style={[
143
+ styles.logoSlot,
144
+ {
145
+ opacity: labelOpacity,
146
+ maxWidth: collapsed ? 0 : LOGO_WIDTH,
147
+ },
148
+ ]}
149
+ accessibilityElementsHidden={collapsed}
150
+ importantForAccessibility={collapsed ? "no-hide-descendants" : "auto"}
151
+ >
152
+ <BrandLogo
153
+ accessibilityLabel={logoAccessibilityLabel}
154
+ height={LOGO_HEIGHT}
155
+ variant="brand"
156
+ width={LOGO_WIDTH}
157
+ />
158
+ </Animated.View>
159
+
160
+ <Pressable
161
+ accessibilityRole="button"
162
+ accessibilityLabel={collapsed ? expandAccessibilityLabel : collapseAccessibilityLabel}
163
+ accessibilityState={{ expanded: !collapsed }}
164
+ hitSlop={8}
165
+ onPress={() => setCollapsed(!collapsed)}
166
+ style={styles.toggleButton}
167
+ >
168
+ <SidebarIcon color={colors.grey100} size={TOGGLE_SIZE} strokeWidth={2} />
169
+ </Pressable>
170
+ </View>
171
+
172
+ <View
173
+ accessibilityLabel={navigationAccessibilityLabel}
174
+ accessibilityRole="menu"
175
+ style={styles.nav}
176
+ >
177
+ {items.map((item) => {
178
+ const isActive = Boolean(item.active);
179
+ const iconColor = isActive ? colors.primary : colors.grey300;
180
+ const Icon = item.icon;
181
+ const iconNode: ReactNode = (
182
+ <Icon color={iconColor} size={MENU_ICON_SIZE} strokeWidth={1.5} />
183
+ );
184
+
185
+ return (
186
+ <MenuItem
187
+ key={item.key}
188
+ icon={iconNode}
189
+ label={item.label}
190
+ accessibilityLabel={item.label}
191
+ backgroundColor={isActive ? colors.grey700 : "transparent"}
192
+ hoverColor={colors.grey700}
193
+ onPress={item.onPress}
194
+ labelStyle={[
195
+ styles.menuLabel,
196
+ { color: isActive ? colors.white : colors.grey200 },
197
+ collapsed ? styles.menuLabelCollapsed : styles.menuLabelExpanded,
198
+ ]}
199
+ style={[styles.menuItem, collapsed ? styles.menuItemCollapsed : null]}
200
+ />
201
+ );
202
+ })}
203
+ </View>
204
+
205
+ <View style={[styles.footer, collapsed ? styles.footerCollapsed : styles.footerExpanded]}>
206
+ <AvatarSimple
207
+ name={user.name}
208
+ email={user.email}
209
+ imageUrl={user.imageUrl}
210
+ onLogOut={onLogOut}
211
+ logOutAccessibilityLabel={logOutAccessibilityLabel}
212
+ compact={collapsed}
213
+ />
214
+ </View>
215
+ </Animated.View>
216
+ );
217
+ });
218
+
219
+ const styles = StyleSheet.create({
220
+ root: {
221
+ height: "100%",
222
+ flexShrink: 0,
223
+ backgroundColor: colors.dark,
224
+ borderRightWidth: 1,
225
+ borderRightColor: colors.grey700,
226
+ overflow: "hidden",
227
+ },
228
+ header: {
229
+ zIndex: 1,
230
+ height: HEADER_HEIGHT,
231
+ width: "100%",
232
+ flexDirection: "row",
233
+ alignItems: "center",
234
+ borderBottomWidth: 1,
235
+ borderBottomColor: colors.grey700,
236
+ backgroundColor: colors.dark,
237
+ },
238
+ headerExpanded: {
239
+ justifyContent: "space-between",
240
+ paddingHorizontal: 24,
241
+ },
242
+ headerCollapsed: {
243
+ justifyContent: "center",
244
+ paddingHorizontal: 0,
245
+ },
246
+ logoSlot: {
247
+ minWidth: 0,
248
+ height: LOGO_HEIGHT,
249
+ flexShrink: 0,
250
+ overflow: "hidden",
251
+ },
252
+ toggleButton: {
253
+ width: TOGGLE_SIZE,
254
+ height: TOGGLE_SIZE,
255
+ alignItems: "center",
256
+ justifyContent: "center",
257
+ flexShrink: 0,
258
+ },
259
+ nav: {
260
+ flex: 1,
261
+ width: "100%",
262
+ padding: 16,
263
+ gap: 10,
264
+ },
265
+ menuItem: {
266
+ gap: 8,
267
+ },
268
+ menuItemCollapsed: {
269
+ gap: 0,
270
+ },
271
+ menuLabel: {
272
+ fontFamily: fonts.sans,
273
+ fontSize: 12,
274
+ fontWeight: "600",
275
+ lineHeight: 16,
276
+ letterSpacing: 0,
277
+ },
278
+ menuLabelExpanded: {
279
+ flexGrow: 1,
280
+ flexShrink: 1,
281
+ opacity: 1,
282
+ },
283
+ menuLabelCollapsed: {
284
+ flexGrow: 0,
285
+ flexShrink: 0,
286
+ width: 0,
287
+ maxWidth: 0,
288
+ opacity: 0,
289
+ overflow: "hidden",
290
+ },
291
+ footer: {
292
+ height: PROFILE_SECTION_HEIGHT,
293
+ width: "100%",
294
+ flexShrink: 0,
295
+ alignItems: "center",
296
+ justifyContent: "center",
297
+ paddingVertical: 12,
298
+ },
299
+ footerExpanded: {
300
+ paddingHorizontal: 16,
301
+ },
302
+ footerCollapsed: {
303
+ paddingHorizontal: 0,
304
+ },
305
+ });
@@ -0,0 +1,2 @@
1
+ export { Sidebar } from "./Sidebar";
2
+ export type { SidebarProps, SidebarItem, SidebarUser } from "./Sidebar";
@@ -0,0 +1,112 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { StyleSheet, Text, View } from "react-native";
4
+ import { fn } from "storybook/test";
5
+ import { colors } from "../../theme";
6
+ import { VerificationCodeInput } from "./VerificationCodeInput";
7
+
8
+ const meta = {
9
+ title: "Components/VerificationCodeInput",
10
+ component: VerificationCodeInput,
11
+ args: {
12
+ length: 6,
13
+ defaultValue: "",
14
+ error: false,
15
+ disabled: false,
16
+ onValueChange: fn(),
17
+ onComplete: fn(),
18
+ getDigitAccessibilityLabel: (index: number) => `Verification code digit ${index + 1}`,
19
+ },
20
+ argTypes: {
21
+ length: { control: { type: "number", min: 1, max: 8 } },
22
+ error: { control: "boolean" },
23
+ disabled: { control: "boolean" },
24
+ },
25
+ } satisfies Meta<typeof VerificationCodeInput>;
26
+
27
+ export default meta;
28
+ type Story = StoryObj<typeof meta>;
29
+
30
+ export const Default: Story = {};
31
+
32
+ export const Filled: Story = {
33
+ args: { value: "222222" },
34
+ };
35
+
36
+ export const Error: Story = {
37
+ args: { value: "222222", error: true },
38
+ };
39
+
40
+ export const Disabled: Story = {
41
+ args: { value: "123456", disabled: true },
42
+ };
43
+
44
+ function InteractiveExample() {
45
+ const [value, setValue] = useState("");
46
+
47
+ return (
48
+ <View style={storyStyles.column}>
49
+ <VerificationCodeInput value={value} onValueChange={setValue} />
50
+ <Text style={storyStyles.caption}>{value || "Enter or paste a six-digit code"}</Text>
51
+ </View>
52
+ );
53
+ }
54
+
55
+ export const Interactive: Story = {
56
+ render: () => <InteractiveExample />,
57
+ };
58
+
59
+ export const Responsive: Story = {
60
+ render: () => (
61
+ <View style={storyStyles.column}>
62
+ <View style={storyStyles.desktopWidth}>
63
+ <VerificationCodeInput value="123456" />
64
+ </View>
65
+ <View style={storyStyles.mobileWidth}>
66
+ <VerificationCodeInput
67
+ inputStyle={storyStyles.mobileCell}
68
+ style={storyStyles.mobileCode}
69
+ value="123456"
70
+ />
71
+ </View>
72
+ </View>
73
+ ),
74
+ };
75
+
76
+ export const AllStates: Story = {
77
+ render: () => (
78
+ <View style={storyStyles.column}>
79
+ <VerificationCodeInput />
80
+ <VerificationCodeInput value="222222" />
81
+ <VerificationCodeInput value="222222" error />
82
+ <VerificationCodeInput value="123456" disabled />
83
+ </View>
84
+ ),
85
+ };
86
+
87
+ const storyStyles = StyleSheet.create({
88
+ column: {
89
+ width: "100%",
90
+ maxWidth: 480,
91
+ gap: 24,
92
+ alignItems: "center",
93
+ },
94
+ desktopWidth: {
95
+ width: 420,
96
+ },
97
+ mobileWidth: {
98
+ width: 326,
99
+ },
100
+ mobileCode: {
101
+ width: 320,
102
+ gap: 4,
103
+ },
104
+ mobileCell: {
105
+ width: 50,
106
+ height: 68,
107
+ },
108
+ caption: {
109
+ color: colors.grey100,
110
+ fontSize: 12,
111
+ },
112
+ });
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeVerificationCode, updateVerificationCode } from "./VerificationCodeInput.utils";
3
+
4
+ describe("normalizeVerificationCode", () => {
5
+ it("keeps digits only and respects the configured length", () => {
6
+ expect(normalizeVerificationCode("12a 34-567", 6)).toBe("123456");
7
+ });
8
+
9
+ it("supports non-default code lengths", () => {
10
+ expect(normalizeVerificationCode("123456", 4)).toBe("1234");
11
+ });
12
+ });
13
+
14
+ describe("updateVerificationCode", () => {
15
+ it("distributes a pasted code from the selected cell", () => {
16
+ expect(updateVerificationCode("", 0, "12 34-56", 6)).toEqual({
17
+ code: "123456",
18
+ nextFocus: 5,
19
+ });
20
+ });
21
+
22
+ it("replaces a selected digit without growing the code", () => {
23
+ expect(updateVerificationCode("123456", 2, "9", 6)).toEqual({
24
+ code: "129456",
25
+ nextFocus: 3,
26
+ });
27
+ });
28
+
29
+ it("removes a cleared digit", () => {
30
+ expect(updateVerificationCode("123456", 2, "", 6)).toEqual({
31
+ code: "12456",
32
+ nextFocus: 2,
33
+ });
34
+ });
35
+
36
+ it("ignores non-numeric input", () => {
37
+ expect(updateVerificationCode("123", 2, "abc", 6)).toEqual({
38
+ code: "12",
39
+ nextFocus: 2,
40
+ });
41
+ });
42
+ });
@@ -0,0 +1,255 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useImperativeHandle,
5
+ useRef,
6
+ useState,
7
+ type ComponentRef,
8
+ } from "react";
9
+ import {
10
+ Platform,
11
+ StyleSheet,
12
+ TextInput,
13
+ useWindowDimensions,
14
+ View,
15
+ type NativeSyntheticEvent,
16
+ type StyleProp,
17
+ type TextInputKeyPressEventData,
18
+ type TextStyle,
19
+ type ViewProps,
20
+ type ViewStyle,
21
+ } from "react-native";
22
+ import { colors, fonts } from "../../theme";
23
+ import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
24
+ import { normalizeVerificationCode, updateVerificationCode } from "./VerificationCodeInput.utils";
25
+
26
+ const DEFAULT_LENGTH = 6;
27
+ const DESKTOP_CELL_WIDTH = 54;
28
+ const MOBILE_CELL_WIDTH = 50;
29
+ const CELL_HEIGHT = 68;
30
+ const DESKTOP_GAP = 12;
31
+ const MOBILE_GAP = 4;
32
+ const MOBILE_BREAKPOINT = 640;
33
+
34
+ export interface VerificationCodeInputHandle {
35
+ clear: () => void;
36
+ focus: (index?: number) => void;
37
+ }
38
+
39
+ export interface VerificationCodeInputProps extends Omit<ViewProps, "children" | "style"> {
40
+ /** Number of numeric cells. */
41
+ length?: number;
42
+ value?: string;
43
+ defaultValue?: string;
44
+ onValueChange?: (value: string) => void;
45
+ /** Called whenever every cell contains a digit. */
46
+ onComplete?: (value: string) => void;
47
+ /** Called when Enter/submit is pressed from a cell. */
48
+ onSubmit?: (value: string) => void;
49
+ error?: boolean;
50
+ disabled?: boolean;
51
+ autoFocus?: boolean;
52
+ /** Localized accessible name for each zero-based cell index. */
53
+ getDigitAccessibilityLabel?: (index: number) => string;
54
+ style?: StyleProp<ViewStyle>;
55
+ inputStyle?: StyleProp<TextStyle>;
56
+ className?: string;
57
+ }
58
+
59
+ export const VerificationCodeInput = forwardRef<
60
+ VerificationCodeInputHandle,
61
+ VerificationCodeInputProps
62
+ >(function VerificationCodeInput(
63
+ {
64
+ length = DEFAULT_LENGTH,
65
+ value,
66
+ defaultValue = "",
67
+ onValueChange,
68
+ onComplete,
69
+ onSubmit,
70
+ error = false,
71
+ disabled = false,
72
+ autoFocus = false,
73
+ getDigitAccessibilityLabel,
74
+ style,
75
+ inputStyle,
76
+ className,
77
+ ...viewProps
78
+ },
79
+ forwardedRef,
80
+ ) {
81
+ const safeLength = Math.max(1, Math.floor(length));
82
+ const isControlled = typeof value === "string";
83
+ const [uncontrolledValue, setUncontrolledValue] = useState(() =>
84
+ normalizeVerificationCode(defaultValue, safeLength),
85
+ );
86
+ const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
87
+ const { width: viewportWidth } = useWindowDimensions();
88
+ const rootRef = useRef<ComponentRef<typeof View>>(null);
89
+ const inputRefs = useRef<Array<ComponentRef<typeof TextInput> | null>>([]);
90
+ const currentCode = normalizeVerificationCode(
91
+ isControlled ? value : uncontrolledValue,
92
+ safeLength,
93
+ );
94
+
95
+ useApplyWebClassName(rootRef, className);
96
+
97
+ const focus = useCallback(
98
+ (index = 0) => {
99
+ const safeIndex = Math.min(Math.max(0, index), safeLength - 1);
100
+ inputRefs.current[safeIndex]?.focus();
101
+ },
102
+ [safeLength],
103
+ );
104
+
105
+ const commit = useCallback(
106
+ (nextValue: string) => {
107
+ const normalized = normalizeVerificationCode(nextValue, safeLength);
108
+
109
+ if (!isControlled) {
110
+ setUncontrolledValue(normalized);
111
+ }
112
+
113
+ onValueChange?.(normalized);
114
+
115
+ if (normalized.length === safeLength) {
116
+ onComplete?.(normalized);
117
+ }
118
+ },
119
+ [isControlled, onComplete, onValueChange, safeLength],
120
+ );
121
+
122
+ useImperativeHandle(
123
+ forwardedRef,
124
+ () => ({
125
+ clear: () => {
126
+ commit("");
127
+ focus(0);
128
+ },
129
+ focus,
130
+ }),
131
+ [commit, focus],
132
+ );
133
+
134
+ const handleChange = (index: number, input: string) => {
135
+ const update = updateVerificationCode(currentCode, index, input, safeLength);
136
+ commit(update.code);
137
+ focus(update.nextFocus);
138
+ };
139
+
140
+ const handleKeyPress = (
141
+ index: number,
142
+ event: NativeSyntheticEvent<TextInputKeyPressEventData>,
143
+ ) => {
144
+ const key = event.nativeEvent.key;
145
+
146
+ if (key === "Backspace" && !currentCode[index] && index > 0) {
147
+ const update = updateVerificationCode(currentCode, index - 1, "", safeLength);
148
+ commit(update.code);
149
+ focus(index - 1);
150
+ return;
151
+ }
152
+
153
+ if (key === "ArrowLeft" && index > 0) {
154
+ focus(index - 1);
155
+ }
156
+
157
+ if (key === "ArrowRight" && index < safeLength - 1) {
158
+ focus(index + 1);
159
+ }
160
+ };
161
+
162
+ const mobile = viewportWidth < MOBILE_BREAKPOINT;
163
+ const cellWidth = mobile ? MOBILE_CELL_WIDTH : DESKTOP_CELL_WIDTH;
164
+ const cellGap = mobile ? MOBILE_GAP : DESKTOP_GAP;
165
+ const rootWidth = cellWidth * safeLength + cellGap * Math.max(0, safeLength - 1);
166
+
167
+ return (
168
+ <View
169
+ {...viewProps}
170
+ ref={rootRef}
171
+ style={[
172
+ styles.root,
173
+ { width: rootWidth, maxWidth: "100%", gap: cellGap },
174
+ disabled && styles.disabled,
175
+ style,
176
+ ]}
177
+ >
178
+ {Array.from({ length: safeLength }, (_, index) => {
179
+ const active = focusedIndex === index;
180
+ const borderColor = error ? colors.redHover : active ? colors.primary : colors.grey600;
181
+
182
+ return (
183
+ <TextInput
184
+ ref={(element) => {
185
+ inputRefs.current[index] = element;
186
+ }}
187
+ accessibilityLabel={
188
+ getDigitAccessibilityLabel?.(index) ?? `Verification code digit ${index + 1}`
189
+ }
190
+ accessibilityState={{ disabled }}
191
+ autoComplete={index === 0 ? "one-time-code" : "off"}
192
+ autoFocus={autoFocus && index === 0}
193
+ caretHidden={Platform.OS !== "web"}
194
+ editable={!disabled}
195
+ inputMode="numeric"
196
+ key={index}
197
+ keyboardType="number-pad"
198
+ maxLength={safeLength}
199
+ onBlur={() => setFocusedIndex((current) => (current === index ? null : current))}
200
+ onChangeText={(text) => handleChange(index, text)}
201
+ onFocus={() => setFocusedIndex(index)}
202
+ onKeyPress={(event) => handleKeyPress(index, event)}
203
+ onSubmitEditing={() => onSubmit?.(currentCode)}
204
+ selectTextOnFocus
205
+ style={[
206
+ styles.cell,
207
+ {
208
+ width: cellWidth,
209
+ height: CELL_HEIGHT,
210
+ borderColor,
211
+ color: error ? colors.red : colors.white,
212
+ },
213
+ Platform.OS === "web" ? styles.cellWeb : null,
214
+ Platform.OS === "android" ? styles.cellAndroid : null,
215
+ inputStyle,
216
+ ]}
217
+ value={currentCode[index] ?? ""}
218
+ />
219
+ );
220
+ })}
221
+ </View>
222
+ );
223
+ });
224
+
225
+ const styles = StyleSheet.create({
226
+ root: {
227
+ minWidth: 0,
228
+ alignSelf: "center",
229
+ flexDirection: "row",
230
+ justifyContent: "center",
231
+ },
232
+ disabled: {
233
+ opacity: 0.6,
234
+ },
235
+ cell: {
236
+ minWidth: 0,
237
+ flexGrow: 0,
238
+ flexShrink: 0,
239
+ borderWidth: 1,
240
+ borderRadius: 16,
241
+ padding: 0,
242
+ margin: 0,
243
+ textAlign: "center",
244
+ fontFamily: fonts.sans,
245
+ fontSize: 22,
246
+ fontWeight: "600",
247
+ lineHeight: 22,
248
+ },
249
+ cellWeb: {
250
+ outlineStyle: "none",
251
+ } as TextStyle,
252
+ cellAndroid: {
253
+ includeFontPadding: false,
254
+ },
255
+ });