@ecohouse/ui 0.1.28 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecohouse/ui",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Cross-platform presentation-only UI components for EcoHouse (React Native + React Native Web)",
@@ -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
+ });
@@ -0,0 +1,34 @@
1
+ export function normalizeVerificationCode(value: string, length: number): string {
2
+ return value.replace(/\D/g, "").slice(0, Math.max(1, length));
3
+ }
4
+
5
+ export interface VerificationCodeUpdate {
6
+ code: string;
7
+ nextFocus: number;
8
+ }
9
+
10
+ export function updateVerificationCode(
11
+ currentCode: string,
12
+ index: number,
13
+ input: string,
14
+ length: number,
15
+ ): VerificationCodeUpdate {
16
+ const safeLength = Math.max(1, length);
17
+ const safeIndex = Math.min(Math.max(0, index), safeLength - 1);
18
+ const current = normalizeVerificationCode(currentCode, safeLength);
19
+ const insertedDigits = normalizeVerificationCode(input, safeLength - safeIndex);
20
+
21
+ if (!insertedDigits) {
22
+ return {
23
+ code: `${current.slice(0, safeIndex)}${current.slice(safeIndex + 1)}`,
24
+ nextFocus: safeIndex,
25
+ };
26
+ }
27
+
28
+ return {
29
+ code: `${current.slice(0, safeIndex)}${insertedDigits}${current.slice(
30
+ safeIndex + insertedDigits.length,
31
+ )}`.slice(0, safeLength),
32
+ nextFocus: Math.min(safeIndex + insertedDigits.length, safeLength - 1),
33
+ };
34
+ }
@@ -0,0 +1,5 @@
1
+ export { VerificationCodeInput } from "./VerificationCodeInput";
2
+ export type {
3
+ VerificationCodeInputHandle,
4
+ VerificationCodeInputProps,
5
+ } from "./VerificationCodeInput";
@@ -34,6 +34,12 @@ export type {
34
34
  export { Input } from "./Input";
35
35
  export type { InputIcon, InputProps, InputSize } from "./Input";
36
36
 
37
+ export { VerificationCodeInput } from "./VerificationCodeInput";
38
+ export type {
39
+ VerificationCodeInputHandle,
40
+ VerificationCodeInputProps,
41
+ } from "./VerificationCodeInput";
42
+
37
43
  export { Select } from "./Select";
38
44
  export type {
39
45
  SelectIcon,
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export {
10
10
  Checkbox,
11
11
  DatePicker,
12
12
  Input,
13
+ VerificationCodeInput,
13
14
  Select,
14
15
  Textarea,
15
16
  Toggle,
@@ -41,6 +42,8 @@ export type {
41
42
  InputIcon,
42
43
  InputProps,
43
44
  InputSize,
45
+ VerificationCodeInputHandle,
46
+ VerificationCodeInputProps,
44
47
  SelectIcon,
45
48
  SelectOption,
46
49
  SelectProps,