@terreno/ui 0.5.0 → 0.7.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.
Files changed (61) hide show
  1. package/README.md +5 -5
  2. package/dist/ActionSheet.js +10 -4
  3. package/dist/ActionSheet.js.map +1 -1
  4. package/dist/Common.d.ts +13 -0
  5. package/dist/Common.js.map +1 -1
  6. package/dist/CommonIconTypes.d.ts +1 -1
  7. package/dist/FlatList.js.map +1 -1
  8. package/dist/GPTChat.d.ts +2 -1
  9. package/dist/GPTChat.js +14 -4
  10. package/dist/GPTChat.js.map +1 -1
  11. package/dist/HeightActionSheet.js +18 -10
  12. package/dist/HeightActionSheet.js.map +1 -1
  13. package/dist/HeightField.d.ts +3 -0
  14. package/dist/HeightField.js +167 -0
  15. package/dist/HeightField.js.map +1 -0
  16. package/dist/Page.js +2 -1
  17. package/dist/Page.js.map +1 -1
  18. package/dist/ScrollView.js.map +1 -1
  19. package/dist/TextField.js +2 -2
  20. package/dist/TextField.js.map +1 -1
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/login/LoginScreen.js +1 -1
  25. package/dist/login/LoginScreen.js.map +1 -1
  26. package/dist/signUp/SignUpScreen.js +1 -1
  27. package/dist/signUp/SignUpScreen.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/ActionSheet.tsx +7 -5
  30. package/src/Common.ts +14 -0
  31. package/src/CommonIconTypes.ts +1 -1
  32. package/src/FlatList.tsx +1 -0
  33. package/src/GPTChat.tsx +40 -2
  34. package/src/HeightActionSheet.tsx +23 -8
  35. package/src/HeightField.test.tsx +178 -0
  36. package/src/HeightField.tsx +360 -0
  37. package/src/HeightFieldDesktop.test.tsx +137 -0
  38. package/src/Page.tsx +20 -9
  39. package/src/ScrollView.tsx +1 -0
  40. package/src/TextField.tsx +2 -0
  41. package/src/__snapshots__/AddressField.test.tsx.snap +4 -0
  42. package/src/__snapshots__/CustomSelectField.test.tsx.snap +1 -0
  43. package/src/__snapshots__/EmailField.test.tsx.snap +2 -0
  44. package/src/__snapshots__/Field.test.tsx.snap +12 -0
  45. package/src/__snapshots__/HeightActionSheet.test.tsx.snap +162 -48
  46. package/src/__snapshots__/HeightField.test.tsx.snap +4011 -0
  47. package/src/__snapshots__/HeightFieldDesktop.test.tsx.snap +613 -0
  48. package/src/__snapshots__/MobileAddressAutoComplete.test.tsx.snap +1 -0
  49. package/src/__snapshots__/NumberField.test.tsx.snap +1 -0
  50. package/src/__snapshots__/Page.test.tsx.snap +15 -20
  51. package/src/__snapshots__/PhoneNumberField.test.tsx.snap +5 -0
  52. package/src/__snapshots__/TapToEdit.test.tsx.snap +1 -0
  53. package/src/__snapshots__/TextArea.test.tsx.snap +5 -0
  54. package/src/__snapshots__/TextField.test.tsx.snap +5 -0
  55. package/src/__snapshots__/UnifiedAddressAutoComplete.test.tsx.snap +4 -0
  56. package/src/__snapshots__/WebAddressAutocomplete.test.tsx.snap +3 -0
  57. package/src/index.tsx +1 -0
  58. package/src/login/LoginScreen.tsx +1 -0
  59. package/src/login/__snapshots__/LoginScreen.test.tsx.snap +2 -0
  60. package/src/signUp/SignUpScreen.tsx +1 -0
  61. package/src/signUp/__snapshots__/SignUpScreen.test.tsx.snap +3 -0
@@ -6,8 +6,9 @@ import {ActionSheet} from "./ActionSheet";
6
6
  import {Box} from "./Box";
7
7
  import {Button} from "./Button";
8
8
  import type {HeightActionSheetProps} from "./Common";
9
+ import {Heading} from "./Heading";
9
10
 
10
- const PICKER_HEIGHT = 104;
11
+ const PICKER_HEIGHT = 180;
11
12
 
12
13
  interface HeightActionSheetState {
13
14
  feet: string;
@@ -27,16 +28,24 @@ export class HeightActionSheet extends React.Component<
27
28
  }
28
29
 
29
30
  render() {
31
+ const minInches = this.props.min ?? 0;
32
+ const maxInches = this.props.max ?? 95;
33
+ const minFeet = Math.floor(minInches / 12);
34
+ const maxFeet = Math.floor(maxInches / 12);
35
+
30
36
  return (
31
37
  <ActionSheet bounceOnOpen gestureEnabled ref={this.props.actionSheetRef}>
32
38
  <Box marginBottom={8} paddingX={4} width="100%">
33
- <Box alignItems="end" display="flex" width="100%">
39
+ <Box alignItems="center" direction="row" justifyContent="between" width="100%">
40
+ <Box flex="grow">
41
+ {this.props.title ? <Heading size="md">{this.props.title}</Heading> : null}
42
+ </Box>
34
43
  <Box width="33%">
35
44
  <Button
36
45
  onClick={() => {
37
46
  this.props.actionSheetRef?.current?.setModalVisible(false);
38
47
  }}
39
- text="Close"
48
+ text="Done"
40
49
  />
41
50
  </Box>
42
51
  </Box>
@@ -44,6 +53,8 @@ export class HeightActionSheet extends React.Component<
44
53
  <Box width="50%">
45
54
  <Picker
46
55
  itemStyle={{
56
+ color: "#1a1a1a",
57
+ fontSize: 20,
47
58
  height: PICKER_HEIGHT,
48
59
  }}
49
60
  onValueChange={(feet) => {
@@ -56,15 +67,18 @@ export class HeightActionSheet extends React.Component<
56
67
  height: PICKER_HEIGHT,
57
68
  }}
58
69
  >
59
- {range(4, 8).map((n) => {
60
- // console.log("FIRST", String(n));
61
- return <Picker.Item key={String(n)} label={`${String(n)}ft`} value={String(n)} />;
70
+ {range(minFeet, maxFeet + 1).map((n) => {
71
+ return (
72
+ <Picker.Item key={String(n)} label={`${String(n)} ft`} value={String(n)} />
73
+ );
62
74
  })}
63
75
  </Picker>
64
76
  </Box>
65
77
  <Box width="50%">
66
78
  <Picker
67
79
  itemStyle={{
80
+ color: "#1a1a1a",
81
+ fontSize: 20,
68
82
  height: PICKER_HEIGHT,
69
83
  }}
70
84
  onValueChange={(inches) => {
@@ -78,8 +92,9 @@ export class HeightActionSheet extends React.Component<
78
92
  }}
79
93
  >
80
94
  {range(0, 12).map((n) => {
81
- // console.log("N", n);
82
- return <Picker.Item key={String(n)} label={`${String(n)}in`} value={String(n)} />;
95
+ return (
96
+ <Picker.Item key={String(n)} label={`${String(n)} in`} value={String(n)} />
97
+ );
83
98
  })}
84
99
  </Picker>
85
100
  </Box>
@@ -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
+ };