@terreno/ui 0.4.2 → 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 +9 -9
- 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
|
@@ -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 =
|
|
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="
|
|
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="
|
|
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(
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
+
};
|