@terreno/ui 0.5.0 → 0.6.0
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/ActionSheet.js +10 -4
- package/dist/ActionSheet.js.map +1 -1
- package/dist/Common.d.ts +12 -0
- package/dist/Common.js.map +1 -1
- package/dist/HeightActionSheet.js +18 -10
- package/dist/HeightActionSheet.js.map +1 -1
- package/dist/HeightField.d.ts +3 -0
- package/dist/HeightField.js +167 -0
- package/dist/HeightField.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/ActionSheet.tsx +7 -5
- package/src/Common.ts +13 -0
- package/src/HeightActionSheet.tsx +23 -8
- package/src/HeightField.test.tsx +178 -0
- package/src/HeightField.tsx +360 -0
- package/src/HeightFieldDesktop.test.tsx +137 -0
- package/src/__snapshots__/HeightActionSheet.test.tsx.snap +162 -48
- package/src/__snapshots__/HeightField.test.tsx.snap +4011 -0
- package/src/__snapshots__/HeightFieldDesktop.test.tsx.snap +613 -0
- package/src/index.tsx +1 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test";
|
|
2
|
+
import {HeightField} from "./HeightField";
|
|
3
|
+
import {renderWithTheme} from "./test-utils";
|
|
4
|
+
|
|
5
|
+
describe("HeightField", () => {
|
|
6
|
+
let mockOnChange: ReturnType<typeof mock>;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mockOnChange = mock(() => {});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
// Reset mocks after each test
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("basic rendering", () => {
|
|
17
|
+
it("should render with default props", () => {
|
|
18
|
+
const {root} = renderWithTheme(<HeightField onChange={mockOnChange} value="" />);
|
|
19
|
+
expect(root).toBeTruthy();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should render with title", () => {
|
|
23
|
+
const {getAllByText} = renderWithTheme(
|
|
24
|
+
<HeightField onChange={mockOnChange} title="Height" value="" />
|
|
25
|
+
);
|
|
26
|
+
// Title appears in both the field title and the HeightActionSheet
|
|
27
|
+
const heightElements = getAllByText("Height");
|
|
28
|
+
expect(heightElements.length).toBeGreaterThanOrEqual(1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should render helper text", () => {
|
|
32
|
+
const {getByText} = renderWithTheme(
|
|
33
|
+
<HeightField helperText="Enter your height" onChange={mockOnChange} value="" />
|
|
34
|
+
);
|
|
35
|
+
expect(getByText("Enter your height")).toBeTruthy();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should render error text", () => {
|
|
39
|
+
const {getByText} = renderWithTheme(
|
|
40
|
+
<HeightField errorText="Height is required" onChange={mockOnChange} value="" />
|
|
41
|
+
);
|
|
42
|
+
expect(getByText("Height is required")).toBeTruthy();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should render placeholder text when no value", () => {
|
|
46
|
+
const {getByText} = renderWithTheme(<HeightField onChange={mockOnChange} value="" />);
|
|
47
|
+
expect(getByText("Select height")).toBeTruthy();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("value display (mobile mode)", () => {
|
|
52
|
+
it("should display formatted height for 70 inches (5ft 10in)", () => {
|
|
53
|
+
const {getByText} = renderWithTheme(<HeightField onChange={mockOnChange} value="70" />);
|
|
54
|
+
expect(getByText("5ft 10in")).toBeTruthy();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should display formatted height for 72 inches (6ft 0in)", () => {
|
|
58
|
+
const {getByText} = renderWithTheme(<HeightField onChange={mockOnChange} value="72" />);
|
|
59
|
+
expect(getByText("6ft 0in")).toBeTruthy();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("should display formatted height for 60 inches (5ft 0in)", () => {
|
|
63
|
+
const {getByText} = renderWithTheme(<HeightField onChange={mockOnChange} value="60" />);
|
|
64
|
+
expect(getByText("5ft 0in")).toBeTruthy();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should handle empty value", () => {
|
|
68
|
+
const {getByText} = renderWithTheme(<HeightField onChange={mockOnChange} value="" />);
|
|
69
|
+
expect(getByText("Select height")).toBeTruthy();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should handle undefined value", () => {
|
|
73
|
+
const {getByText} = renderWithTheme(
|
|
74
|
+
<HeightField onChange={mockOnChange} value={undefined} />
|
|
75
|
+
);
|
|
76
|
+
expect(getByText("Select height")).toBeTruthy();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should display formatted height for 0 inches (0ft 0in)", () => {
|
|
80
|
+
const {getByText} = renderWithTheme(<HeightField onChange={mockOnChange} value="0" />);
|
|
81
|
+
expect(getByText("0ft 0in")).toBeTruthy();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should display formatted height for 95 inches (7ft 11in)", () => {
|
|
85
|
+
const {getByText} = renderWithTheme(<HeightField onChange={mockOnChange} value="95" />);
|
|
86
|
+
expect(getByText("7ft 11in")).toBeTruthy();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("accessibility", () => {
|
|
91
|
+
it("should have correct accessibility properties on pressable", () => {
|
|
92
|
+
const {getByLabelText} = renderWithTheme(<HeightField onChange={mockOnChange} value="" />);
|
|
93
|
+
const pressable = getByLabelText("Height selector");
|
|
94
|
+
expect(pressable).toBeTruthy();
|
|
95
|
+
expect(pressable.props.accessibilityHint).toBe("Tap to select height");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("disabled state", () => {
|
|
100
|
+
it("should render in disabled state", () => {
|
|
101
|
+
const {root} = renderWithTheme(<HeightField disabled onChange={mockOnChange} value="70" />);
|
|
102
|
+
expect(root).toBeTruthy();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("should display value when disabled", () => {
|
|
106
|
+
const {getByText} = renderWithTheme(
|
|
107
|
+
<HeightField disabled onChange={mockOnChange} value="70" />
|
|
108
|
+
);
|
|
109
|
+
expect(getByText("5ft 10in")).toBeTruthy();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should have disabled prop on pressable when disabled", () => {
|
|
113
|
+
const {getByLabelText} = renderWithTheme(
|
|
114
|
+
<HeightField disabled onChange={mockOnChange} value="70" />
|
|
115
|
+
);
|
|
116
|
+
const pressable = getByLabelText("Height selector");
|
|
117
|
+
expect(pressable.props.disabled).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("edge cases", () => {
|
|
122
|
+
it("should handle non-numeric value gracefully", () => {
|
|
123
|
+
const {root} = renderWithTheme(<HeightField onChange={mockOnChange} value="abc" />);
|
|
124
|
+
expect(root).toBeTruthy();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should render without crashing for invalid value", () => {
|
|
128
|
+
const {getByLabelText} = renderWithTheme(<HeightField onChange={mockOnChange} value="abc" />);
|
|
129
|
+
const pressable = getByLabelText("Height selector");
|
|
130
|
+
expect(pressable).toBeTruthy();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("snapshots", () => {
|
|
135
|
+
it("should match snapshot with default props", () => {
|
|
136
|
+
const component = renderWithTheme(<HeightField onChange={mockOnChange} value="" />);
|
|
137
|
+
expect(component.toJSON()).toMatchSnapshot();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("should match snapshot with value", () => {
|
|
141
|
+
const component = renderWithTheme(<HeightField onChange={mockOnChange} value="70" />);
|
|
142
|
+
expect(component.toJSON()).toMatchSnapshot();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("should match snapshot with all props", () => {
|
|
146
|
+
const component = renderWithTheme(
|
|
147
|
+
<HeightField
|
|
148
|
+
disabled={false}
|
|
149
|
+
errorText="Error text"
|
|
150
|
+
helperText="Helper text"
|
|
151
|
+
onChange={mockOnChange}
|
|
152
|
+
title="Height"
|
|
153
|
+
value="70"
|
|
154
|
+
/>
|
|
155
|
+
);
|
|
156
|
+
expect(component.toJSON()).toMatchSnapshot();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("should match snapshot when disabled", () => {
|
|
160
|
+
const component = renderWithTheme(
|
|
161
|
+
<HeightField disabled onChange={mockOnChange} title="Height" value="70" />
|
|
162
|
+
);
|
|
163
|
+
expect(component.toJSON()).toMatchSnapshot();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("should match snapshot with error state", () => {
|
|
167
|
+
const component = renderWithTheme(
|
|
168
|
+
<HeightField
|
|
169
|
+
errorText="Height is required"
|
|
170
|
+
onChange={mockOnChange}
|
|
171
|
+
title="Height"
|
|
172
|
+
value=""
|
|
173
|
+
/>
|
|
174
|
+
);
|
|
175
|
+
expect(component.toJSON()).toMatchSnapshot();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import {type FC, useCallback, useEffect, useMemo, useRef, useState} from "react";
|
|
2
|
+
import {Platform, Pressable, type StyleProp, TextInput, View} from "react-native";
|
|
3
|
+
|
|
4
|
+
import {Box} from "./Box";
|
|
5
|
+
import type {HeightFieldProps, TextStyleWithOutline} from "./Common";
|
|
6
|
+
import {FieldError, FieldHelperText, FieldTitle} from "./fieldElements";
|
|
7
|
+
import {HeightActionSheet} from "./HeightActionSheet";
|
|
8
|
+
import {isMobileDevice} from "./MediaQuery";
|
|
9
|
+
import {SelectField} from "./SelectField";
|
|
10
|
+
import {Text} from "./Text";
|
|
11
|
+
import {useTheme} from "./Theme";
|
|
12
|
+
import {isNative} from "./Utilities";
|
|
13
|
+
|
|
14
|
+
// Height bounds in inches. Default range covers typical human heights (0–7ft 11in).
|
|
15
|
+
const DEFAULT_MIN_INCHES = 0;
|
|
16
|
+
const DEFAULT_MAX_INCHES = 95; // 7ft 11in
|
|
17
|
+
|
|
18
|
+
const inchesToFeetAndInches = (totalInches: string | undefined): {feet: string; inches: string} => {
|
|
19
|
+
if (!totalInches) {
|
|
20
|
+
return {feet: "", inches: ""};
|
|
21
|
+
}
|
|
22
|
+
const total = parseInt(totalInches, 10);
|
|
23
|
+
if (Number.isNaN(total)) {
|
|
24
|
+
return {feet: "", inches: ""};
|
|
25
|
+
}
|
|
26
|
+
const feet = Math.floor(total / 12);
|
|
27
|
+
const inches = total % 12;
|
|
28
|
+
return {feet: String(feet), inches: String(inches)};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const feetAndInchesToInches = (feet: string, inches: string): string => {
|
|
32
|
+
const feetNum = parseInt(feet, 10) || 0;
|
|
33
|
+
const inchesNum = parseInt(inches, 10) || 0;
|
|
34
|
+
return String(feetNum * 12 + inchesNum);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const formatHeightDisplay = (totalInches: string | undefined): string => {
|
|
38
|
+
if (!totalInches) {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
const {feet, inches} = inchesToFeetAndInches(totalInches);
|
|
42
|
+
if (!feet && !inches) {
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
return `${feet}ft ${inches}in`;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
interface HeightSegmentProps {
|
|
49
|
+
value: string;
|
|
50
|
+
onChange: (value: string) => void;
|
|
51
|
+
onBlur: () => void;
|
|
52
|
+
onFocus: () => void;
|
|
53
|
+
placeholder: string;
|
|
54
|
+
label: string;
|
|
55
|
+
disabled?: boolean;
|
|
56
|
+
maxValue: number;
|
|
57
|
+
inputRef?: (ref: TextInput | null) => void;
|
|
58
|
+
error?: boolean;
|
|
59
|
+
focused?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const HeightSegment: FC<HeightSegmentProps> = ({
|
|
63
|
+
value,
|
|
64
|
+
onChange,
|
|
65
|
+
onBlur,
|
|
66
|
+
onFocus,
|
|
67
|
+
placeholder,
|
|
68
|
+
label,
|
|
69
|
+
disabled,
|
|
70
|
+
maxValue,
|
|
71
|
+
inputRef,
|
|
72
|
+
error,
|
|
73
|
+
focused,
|
|
74
|
+
}) => {
|
|
75
|
+
const {theme} = useTheme();
|
|
76
|
+
|
|
77
|
+
const handleChange = useCallback(
|
|
78
|
+
(text: string) => {
|
|
79
|
+
const numericValue = text.replace(/[^0-9]/g, "");
|
|
80
|
+
if (numericValue === "") {
|
|
81
|
+
onChange("");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const num = parseInt(numericValue, 10);
|
|
85
|
+
if (num <= maxValue) {
|
|
86
|
+
onChange(numericValue);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
[onChange, maxValue]
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
let borderColor = focused ? theme.border.focus : theme.border.dark;
|
|
93
|
+
if (disabled) {
|
|
94
|
+
borderColor = theme.border.activeNeutral;
|
|
95
|
+
} else if (error) {
|
|
96
|
+
borderColor = theme.border.error;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<View style={{alignItems: "center", flexDirection: "row", gap: 4}}>
|
|
101
|
+
<View
|
|
102
|
+
style={{
|
|
103
|
+
alignItems: "center",
|
|
104
|
+
backgroundColor: disabled ? theme.surface.neutralLight : theme.surface.base,
|
|
105
|
+
borderColor,
|
|
106
|
+
borderRadius: 4,
|
|
107
|
+
borderWidth: focused ? 3 : 1,
|
|
108
|
+
flexDirection: "row",
|
|
109
|
+
height: 40,
|
|
110
|
+
justifyContent: "center",
|
|
111
|
+
paddingHorizontal: focused ? 6 : 8,
|
|
112
|
+
width: 50,
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
<TextInput
|
|
116
|
+
accessibilityHint={`Enter ${label}`}
|
|
117
|
+
aria-label={`${label} input`}
|
|
118
|
+
editable={!disabled}
|
|
119
|
+
inputMode="numeric"
|
|
120
|
+
onBlur={onBlur}
|
|
121
|
+
onChangeText={handleChange}
|
|
122
|
+
onFocus={onFocus}
|
|
123
|
+
placeholder={placeholder}
|
|
124
|
+
placeholderTextColor={theme.text.secondaryLight}
|
|
125
|
+
ref={inputRef}
|
|
126
|
+
selectTextOnFocus
|
|
127
|
+
style={
|
|
128
|
+
{
|
|
129
|
+
color: error ? theme.text.error : theme.text.primary,
|
|
130
|
+
fontFamily: "text",
|
|
131
|
+
fontSize: 16,
|
|
132
|
+
textAlign: "center",
|
|
133
|
+
width: "100%",
|
|
134
|
+
...(Platform.OS === "web" ? {outline: "none"} : {}),
|
|
135
|
+
} as StyleProp<TextStyleWithOutline>
|
|
136
|
+
}
|
|
137
|
+
value={value}
|
|
138
|
+
/>
|
|
139
|
+
</View>
|
|
140
|
+
<Text>{label}</Text>
|
|
141
|
+
</View>
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export const HeightField: FC<HeightFieldProps> = ({
|
|
146
|
+
title,
|
|
147
|
+
disabled,
|
|
148
|
+
helperText,
|
|
149
|
+
errorText,
|
|
150
|
+
value,
|
|
151
|
+
onChange,
|
|
152
|
+
testID,
|
|
153
|
+
min,
|
|
154
|
+
max,
|
|
155
|
+
}) => {
|
|
156
|
+
const {theme} = useTheme();
|
|
157
|
+
const actionSheetRef: React.RefObject<any> = useRef(null);
|
|
158
|
+
const isMobileOrNative = isMobileDevice() || isNative();
|
|
159
|
+
|
|
160
|
+
const minInches = min ?? DEFAULT_MIN_INCHES;
|
|
161
|
+
const maxInches = max ?? DEFAULT_MAX_INCHES;
|
|
162
|
+
const minFeet = Math.floor(minInches / 12);
|
|
163
|
+
const maxFeet = Math.floor(maxInches / 12);
|
|
164
|
+
const isAndroid = Platform.OS === "android";
|
|
165
|
+
|
|
166
|
+
const {feet: initialFeet, inches: initialInches} = inchesToFeetAndInches(value);
|
|
167
|
+
const [feet, setFeet] = useState(initialFeet);
|
|
168
|
+
const [inches, setInches] = useState(initialInches);
|
|
169
|
+
const [focusedSegment, setFocusedSegment] = useState<"feet" | "inches" | null>(null);
|
|
170
|
+
|
|
171
|
+
// Sync local state when value prop changes
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
const {feet: newFeet, inches: newInches} = inchesToFeetAndInches(value);
|
|
174
|
+
setFeet(newFeet);
|
|
175
|
+
setInches(newInches);
|
|
176
|
+
}, [value]);
|
|
177
|
+
|
|
178
|
+
const handleFeetChange = useCallback(
|
|
179
|
+
(newFeet: string) => {
|
|
180
|
+
setFeet(newFeet);
|
|
181
|
+
if (newFeet || inches) {
|
|
182
|
+
const totalInches = feetAndInchesToInches(newFeet, inches);
|
|
183
|
+
onChange(totalInches);
|
|
184
|
+
} else {
|
|
185
|
+
onChange("");
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
[inches, onChange]
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const handleInchesChange = useCallback(
|
|
192
|
+
(newInches: string) => {
|
|
193
|
+
setInches(newInches);
|
|
194
|
+
if (feet || newInches) {
|
|
195
|
+
const totalInches = feetAndInchesToInches(feet, newInches);
|
|
196
|
+
onChange(totalInches);
|
|
197
|
+
} else {
|
|
198
|
+
onChange("");
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
[feet, onChange]
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const handleBlur = useCallback(() => {
|
|
205
|
+
setFocusedSegment(null);
|
|
206
|
+
if (feet || inches) {
|
|
207
|
+
const totalInches = feetAndInchesToInches(feet, inches);
|
|
208
|
+
onChange(totalInches);
|
|
209
|
+
}
|
|
210
|
+
}, [feet, inches, onChange]);
|
|
211
|
+
|
|
212
|
+
const handleActionSheetChange = useCallback(
|
|
213
|
+
(newValue: string) => {
|
|
214
|
+
onChange(newValue);
|
|
215
|
+
},
|
|
216
|
+
[onChange]
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const openActionSheet = useCallback(() => {
|
|
220
|
+
if (disabled) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
actionSheetRef.current?.setModalVisible(true);
|
|
224
|
+
}, [disabled]);
|
|
225
|
+
|
|
226
|
+
// Generate select options for Android picker
|
|
227
|
+
const feetOptions = useMemo(
|
|
228
|
+
() =>
|
|
229
|
+
Array.from({length: maxFeet - minFeet + 1}, (_, i) => ({
|
|
230
|
+
label: `${minFeet + i} ft`,
|
|
231
|
+
value: String(minFeet + i),
|
|
232
|
+
})),
|
|
233
|
+
[minFeet, maxFeet]
|
|
234
|
+
);
|
|
235
|
+
const inchesOptions = useMemo(
|
|
236
|
+
() =>
|
|
237
|
+
Array.from({length: 12}, (_, i) => ({
|
|
238
|
+
label: `${i} in`,
|
|
239
|
+
value: String(i),
|
|
240
|
+
})),
|
|
241
|
+
[]
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
let borderColor = theme.border.dark;
|
|
245
|
+
if (disabled) {
|
|
246
|
+
borderColor = theme.border.activeNeutral;
|
|
247
|
+
} else if (errorText) {
|
|
248
|
+
borderColor = theme.border.error;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (isAndroid) {
|
|
252
|
+
return (
|
|
253
|
+
<View style={{flexDirection: "column", width: "100%"}} testID={testID}>
|
|
254
|
+
{Boolean(title) && <FieldTitle text={title!} />}
|
|
255
|
+
{Boolean(errorText) && <FieldError text={errorText!} />}
|
|
256
|
+
<Box direction="row" gap={2}>
|
|
257
|
+
<Box flex="grow">
|
|
258
|
+
<SelectField
|
|
259
|
+
disabled={disabled}
|
|
260
|
+
onChange={handleFeetChange}
|
|
261
|
+
options={feetOptions}
|
|
262
|
+
placeholder="ft"
|
|
263
|
+
value={feet}
|
|
264
|
+
/>
|
|
265
|
+
</Box>
|
|
266
|
+
<Box flex="grow">
|
|
267
|
+
<SelectField
|
|
268
|
+
disabled={disabled}
|
|
269
|
+
onChange={handleInchesChange}
|
|
270
|
+
options={inchesOptions}
|
|
271
|
+
placeholder="in"
|
|
272
|
+
value={inches}
|
|
273
|
+
/>
|
|
274
|
+
</Box>
|
|
275
|
+
</Box>
|
|
276
|
+
{Boolean(helperText) && <FieldHelperText text={helperText!} />}
|
|
277
|
+
</View>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (isMobileOrNative) {
|
|
282
|
+
const formattedHeight = formatHeightDisplay(value);
|
|
283
|
+
const hasValidHeight = Boolean(formattedHeight);
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
<View style={{flexDirection: "column", width: "100%"}} testID={testID}>
|
|
287
|
+
{Boolean(title) && <FieldTitle text={title!} />}
|
|
288
|
+
{Boolean(errorText) && <FieldError text={errorText!} />}
|
|
289
|
+
<Pressable
|
|
290
|
+
accessibilityHint="Tap to select height"
|
|
291
|
+
accessibilityLabel="Height selector"
|
|
292
|
+
accessibilityRole="button"
|
|
293
|
+
disabled={disabled}
|
|
294
|
+
onPress={openActionSheet}
|
|
295
|
+
>
|
|
296
|
+
<View
|
|
297
|
+
style={{
|
|
298
|
+
alignItems: "center",
|
|
299
|
+
backgroundColor: disabled ? theme.surface.neutralLight : theme.surface.base,
|
|
300
|
+
borderColor,
|
|
301
|
+
borderRadius: 4,
|
|
302
|
+
borderWidth: 1,
|
|
303
|
+
flexDirection: "row",
|
|
304
|
+
minHeight: 40,
|
|
305
|
+
paddingHorizontal: 12,
|
|
306
|
+
paddingVertical: 8,
|
|
307
|
+
}}
|
|
308
|
+
>
|
|
309
|
+
<Text color={hasValidHeight ? "primary" : "secondaryLight"}>
|
|
310
|
+
{hasValidHeight ? formattedHeight : "Select height"}
|
|
311
|
+
</Text>
|
|
312
|
+
</View>
|
|
313
|
+
</Pressable>
|
|
314
|
+
{Boolean(helperText) && <FieldHelperText text={helperText!} />}
|
|
315
|
+
<HeightActionSheet
|
|
316
|
+
actionSheetRef={actionSheetRef}
|
|
317
|
+
max={maxInches}
|
|
318
|
+
min={minInches}
|
|
319
|
+
onChange={handleActionSheetChange}
|
|
320
|
+
title={title}
|
|
321
|
+
value={value || "60"}
|
|
322
|
+
/>
|
|
323
|
+
</View>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<View style={{flexDirection: "column", width: "100%"}} testID={testID}>
|
|
329
|
+
{Boolean(title) && <FieldTitle text={title!} />}
|
|
330
|
+
{Boolean(errorText) && <FieldError text={errorText!} />}
|
|
331
|
+
<Box direction="row" gap={4}>
|
|
332
|
+
<HeightSegment
|
|
333
|
+
disabled={disabled}
|
|
334
|
+
error={Boolean(errorText)}
|
|
335
|
+
focused={focusedSegment === "feet"}
|
|
336
|
+
label="ft"
|
|
337
|
+
maxValue={maxFeet}
|
|
338
|
+
onBlur={handleBlur}
|
|
339
|
+
onChange={handleFeetChange}
|
|
340
|
+
onFocus={() => setFocusedSegment("feet")}
|
|
341
|
+
placeholder="0"
|
|
342
|
+
value={feet}
|
|
343
|
+
/>
|
|
344
|
+
<HeightSegment
|
|
345
|
+
disabled={disabled}
|
|
346
|
+
error={Boolean(errorText)}
|
|
347
|
+
focused={focusedSegment === "inches"}
|
|
348
|
+
label="in"
|
|
349
|
+
maxValue={11}
|
|
350
|
+
onBlur={handleBlur}
|
|
351
|
+
onChange={handleInchesChange}
|
|
352
|
+
onFocus={() => setFocusedSegment("inches")}
|
|
353
|
+
placeholder="0"
|
|
354
|
+
value={inches}
|
|
355
|
+
/>
|
|
356
|
+
</Box>
|
|
357
|
+
{Boolean(helperText) && <FieldHelperText text={helperText!} />}
|
|
358
|
+
</View>
|
|
359
|
+
);
|
|
360
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {afterAll, afterEach, beforeEach, describe, expect, it, mock} from "bun:test";
|
|
2
|
+
import {fireEvent} from "@testing-library/react-native";
|
|
3
|
+
|
|
4
|
+
// Mock isNative to return false so the desktop/web path is rendered
|
|
5
|
+
mock.module("./Utilities", () => ({
|
|
6
|
+
isNative: () => false,
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import {HeightField} from "./HeightField";
|
|
10
|
+
import {renderWithTheme} from "./test-utils";
|
|
11
|
+
|
|
12
|
+
describe("HeightField (desktop/web path)", () => {
|
|
13
|
+
let mockOnChange: ReturnType<typeof mock>;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
mockOnChange = mock(() => {});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {});
|
|
20
|
+
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
// Restore isNative to default native behavior so the mock doesn't leak to other test files
|
|
23
|
+
mock.module("./Utilities", () => ({
|
|
24
|
+
isNative: () => true,
|
|
25
|
+
}));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("rendering", () => {
|
|
29
|
+
it("renders two text inputs for feet and inches", () => {
|
|
30
|
+
const {getAllByLabelText} = renderWithTheme(<HeightField onChange={mockOnChange} value="" />);
|
|
31
|
+
expect(getAllByLabelText("ft input").length).toBe(1);
|
|
32
|
+
expect(getAllByLabelText("in input").length).toBe(1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("renders the correct initial feet and inches from value", () => {
|
|
36
|
+
const {getByDisplayValue} = renderWithTheme(
|
|
37
|
+
<HeightField onChange={mockOnChange} value="70" />
|
|
38
|
+
);
|
|
39
|
+
// 70 inches = 5ft 10in
|
|
40
|
+
expect(getByDisplayValue("5")).toBeTruthy();
|
|
41
|
+
expect(getByDisplayValue("10")).toBeTruthy();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("renders title when provided", () => {
|
|
45
|
+
const {getByText} = renderWithTheme(
|
|
46
|
+
<HeightField onChange={mockOnChange} title="Height" value="" />
|
|
47
|
+
);
|
|
48
|
+
expect(getByText("Height")).toBeTruthy();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("renders helper text", () => {
|
|
52
|
+
const {getByText} = renderWithTheme(
|
|
53
|
+
<HeightField helperText="Enter your height" onChange={mockOnChange} value="" />
|
|
54
|
+
);
|
|
55
|
+
expect(getByText("Enter your height")).toBeTruthy();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("renders error text", () => {
|
|
59
|
+
const {getByText} = renderWithTheme(
|
|
60
|
+
<HeightField errorText="Invalid height" onChange={mockOnChange} value="" />
|
|
61
|
+
);
|
|
62
|
+
expect(getByText("Invalid height")).toBeTruthy();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("onChange behavior", () => {
|
|
67
|
+
it("calls onChange with correct total inches when feet changes", () => {
|
|
68
|
+
const {getAllByLabelText} = renderWithTheme(
|
|
69
|
+
<HeightField onChange={mockOnChange} value="70" />
|
|
70
|
+
);
|
|
71
|
+
const feetInput = getAllByLabelText("ft input")[0];
|
|
72
|
+
fireEvent.changeText(feetInput, "6");
|
|
73
|
+
// 6 feet + 10 inches (from value "70" = 5ft 10in, inches = 10) = 82 inches
|
|
74
|
+
expect(mockOnChange).toHaveBeenCalledWith("82");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("calls onChange with correct total inches when inches changes", () => {
|
|
78
|
+
const {getAllByLabelText} = renderWithTheme(
|
|
79
|
+
<HeightField onChange={mockOnChange} value="70" />
|
|
80
|
+
);
|
|
81
|
+
const inchesInput = getAllByLabelText("in input")[0];
|
|
82
|
+
fireEvent.changeText(inchesInput, "0");
|
|
83
|
+
// 5 feet (from value "70" = 5ft 10in, feet = 5) + 0 inches = 60 inches
|
|
84
|
+
expect(mockOnChange).toHaveBeenCalledWith("60");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("calls onChange with empty string when both inputs are cleared", () => {
|
|
88
|
+
const {getAllByLabelText} = renderWithTheme(
|
|
89
|
+
<HeightField onChange={mockOnChange} value="70" />
|
|
90
|
+
);
|
|
91
|
+
const feetInput = getAllByLabelText("ft input")[0];
|
|
92
|
+
const inchesInput = getAllByLabelText("in input")[0];
|
|
93
|
+
fireEvent.changeText(feetInput, "");
|
|
94
|
+
fireEvent.changeText(inchesInput, "");
|
|
95
|
+
expect(mockOnChange).toHaveBeenCalledWith("");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("does not call onChange with values exceeding max feet", () => {
|
|
99
|
+
const {getAllByLabelText} = renderWithTheme(
|
|
100
|
+
<HeightField max={95} onChange={mockOnChange} value="" />
|
|
101
|
+
);
|
|
102
|
+
const feetInput = getAllByLabelText("ft input")[0];
|
|
103
|
+
// max is 95 inches = 7ft 11in, so maxFeet = 7
|
|
104
|
+
fireEvent.changeText(feetInput, "8");
|
|
105
|
+
expect(mockOnChange).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("disabled state", () => {
|
|
110
|
+
it("renders text inputs as non-editable when disabled", () => {
|
|
111
|
+
const {getAllByLabelText} = renderWithTheme(
|
|
112
|
+
<HeightField disabled onChange={mockOnChange} value="70" />
|
|
113
|
+
);
|
|
114
|
+
const feetInput = getAllByLabelText("ft input")[0];
|
|
115
|
+
expect(feetInput.props.editable).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("snapshots", () => {
|
|
120
|
+
it("matches snapshot with default props", () => {
|
|
121
|
+
const component = renderWithTheme(<HeightField onChange={mockOnChange} value="" />);
|
|
122
|
+
expect(component.toJSON()).toMatchSnapshot();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("matches snapshot with value", () => {
|
|
126
|
+
const component = renderWithTheme(<HeightField onChange={mockOnChange} value="70" />);
|
|
127
|
+
expect(component.toJSON()).toMatchSnapshot();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("matches snapshot when disabled", () => {
|
|
131
|
+
const component = renderWithTheme(
|
|
132
|
+
<HeightField disabled onChange={mockOnChange} value="70" />
|
|
133
|
+
);
|
|
134
|
+
expect(component.toJSON()).toMatchSnapshot();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|