@boostdev/design-system-react-native 0.2.0 → 0.2.2
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/CHANGELOG.md +13 -0
- package/dist/index.cjs +196 -132
- package/dist/index.js +196 -132
- package/package.json +1 -1
- package/src/components/interaction/form/Combobox/Combobox.spec.tsx +28 -0
- package/src/components/interaction/form/Combobox/Combobox.tsx +32 -13
- package/src/components/interaction/form/FormInput/FormInput.spec.tsx +47 -0
- package/src/components/interaction/form/FormInput/FormInput.tsx +53 -31
- package/src/components/interaction/form/NumberInput/NumberInput.spec.tsx +24 -0
- package/src/components/interaction/form/NumberInput/NumberInput.tsx +46 -27
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
All notable changes to `@boostdev/design-system-react-native` are documented here.
|
|
4
|
+
## [0.2.1] - 2026-06-11
|
|
5
|
+
### Fixed
|
|
6
|
+
- Fix(form): prevent iOS text clipping in single-line inputs
|
|
7
|
+
|
|
8
|
+
Remove explicit lineHeight from the TextInput style in FormInput,
|
|
9
|
+
NumberInput, and Combobox. On iOS, an explicit lineHeight on a
|
|
10
|
+
single-line TextInput clips descenders and ascenders at the line box
|
|
11
|
+
boundary. The minHeight: 44 token satisfies touch-target requirements
|
|
12
|
+
without needing lineHeight. Textarea (multiline) is unaffected.
|
|
13
|
+
|
|
14
|
+
Test contract pinned: FormInput spec asserts lineHeight resolves to
|
|
15
|
+
"normal" and minHeight >= 44pt via getComputedStyle. ([`c3055e4`](https://gitlab.com/b00stdev/boostdev-design-system-react-native/-/commit/c3055e464fd0b8891fec1f0eb616b8357390ec50))
|
|
16
|
+
|
|
4
17
|
## [0.2.0] - 2026-06-10
|
|
5
18
|
### Added
|
|
6
19
|
- Feat: rename Drawer to SideSheet, optional @react-navigation/native peer, nav theme docs, git-cliff ([`42e165d`](https://gitlab.com/b00stdev/boostdev-design-system-react-native/-/commit/42e165d6df397876df8b721122259af640eeaa98))
|
package/dist/index.cjs
CHANGED
|
@@ -2682,23 +2682,36 @@ function FileInput({
|
|
|
2682
2682
|
var import_react9 = require("react");
|
|
2683
2683
|
var import_react_native37 = require("react-native");
|
|
2684
2684
|
var import_jsx_runtime37 = require("react/jsx-runtime");
|
|
2685
|
+
var INPUT_HEIGHT = 44;
|
|
2685
2686
|
var MAX_DROPDOWN_HEIGHT = 200;
|
|
2686
2687
|
var styles33 = import_react_native37.StyleSheet.create({
|
|
2687
2688
|
container: {
|
|
2688
2689
|
gap: import_native.spacing.xxs
|
|
2689
2690
|
},
|
|
2691
|
+
/**
|
|
2692
|
+
* Draws the pill border and provides the fixed 44pt touch-target height.
|
|
2693
|
+
* justifyContent: 'center' vertically centers the inner TextInput.
|
|
2694
|
+
* position: 'relative' is required for the dropdown's absolute positioning.
|
|
2695
|
+
*/
|
|
2690
2696
|
inputWrapper: {
|
|
2691
|
-
position: "relative"
|
|
2697
|
+
position: "relative",
|
|
2698
|
+
height: INPUT_HEIGHT,
|
|
2699
|
+
justifyContent: "center",
|
|
2700
|
+
borderWidth: 1,
|
|
2701
|
+
borderRadius: import_native.border.radius.full
|
|
2692
2702
|
},
|
|
2703
|
+
/**
|
|
2704
|
+
* No vertical padding: iOS centers text automatically inside the wrapper height.
|
|
2705
|
+
* textAlignVertical + includeFontPadding provide Android parity.
|
|
2706
|
+
* No lineHeight: explicit lineHeight clips ascenders/descenders on iOS.
|
|
2707
|
+
*/
|
|
2693
2708
|
input: {
|
|
2694
2709
|
fontFamily: import_native.font.family.body,
|
|
2695
2710
|
fontSize: import_native.font.size.body,
|
|
2696
|
-
lineHeight: lineHeight.body,
|
|
2697
|
-
paddingVertical: import_native.spacing.xs,
|
|
2698
2711
|
paddingHorizontal: import_native.spacing.s,
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2712
|
+
paddingVertical: 0,
|
|
2713
|
+
textAlignVertical: "center",
|
|
2714
|
+
includeFontPadding: false
|
|
2702
2715
|
},
|
|
2703
2716
|
dropdown: {
|
|
2704
2717
|
position: "absolute",
|
|
@@ -2767,80 +2780,88 @@ function Combobox({
|
|
|
2767
2780
|
const borderColor = error ? colors2.colorError : colors2.colorOnBg;
|
|
2768
2781
|
return /* @__PURE__ */ (0, import_jsx_runtime37.jsxs)(import_react_native37.View, { style: [styles33.container, style], children: [
|
|
2769
2782
|
/* @__PURE__ */ (0, import_jsx_runtime37.jsx)(Label, { label }),
|
|
2770
|
-
/* @__PURE__ */ (0, import_jsx_runtime37.jsxs)(
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
style: [
|
|
2785
|
-
styles33.input,
|
|
2786
|
-
{
|
|
2787
|
-
borderColor,
|
|
2788
|
-
color: colors2.colorOnBg,
|
|
2789
|
-
backgroundColor: colors2.colorBg
|
|
2790
|
-
},
|
|
2791
|
-
disabled && { opacity: 0.4 }
|
|
2792
|
-
]
|
|
2793
|
-
}
|
|
2794
|
-
),
|
|
2795
|
-
isOpen && filtered.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime37.jsx)(
|
|
2796
|
-
import_react_native37.ScrollView,
|
|
2797
|
-
{
|
|
2798
|
-
style: [
|
|
2799
|
-
styles33.dropdown,
|
|
2783
|
+
/* @__PURE__ */ (0, import_jsx_runtime37.jsxs)(
|
|
2784
|
+
import_react_native37.View,
|
|
2785
|
+
{
|
|
2786
|
+
style: [
|
|
2787
|
+
styles33.inputWrapper,
|
|
2788
|
+
{
|
|
2789
|
+
borderColor,
|
|
2790
|
+
backgroundColor: colors2.colorBg
|
|
2791
|
+
},
|
|
2792
|
+
disabled && { opacity: 0.4 }
|
|
2793
|
+
],
|
|
2794
|
+
children: [
|
|
2795
|
+
/* @__PURE__ */ (0, import_jsx_runtime37.jsx)(
|
|
2796
|
+
import_react_native37.TextInput,
|
|
2800
2797
|
{
|
|
2801
|
-
|
|
2802
|
-
|
|
2798
|
+
value: inputValue,
|
|
2799
|
+
onChangeText: handleChangeText,
|
|
2800
|
+
onFocus: handleFocus,
|
|
2801
|
+
onBlur: handleBlur,
|
|
2802
|
+
placeholder,
|
|
2803
|
+
placeholderTextColor: colors2.colorOnBgSubtle,
|
|
2804
|
+
editable: !disabled,
|
|
2805
|
+
accessibilityRole: "combobox",
|
|
2806
|
+
accessibilityLabel: typeof label === "string" ? label : name,
|
|
2807
|
+
accessibilityState: { disabled, expanded: isOpen },
|
|
2808
|
+
style: [
|
|
2809
|
+
styles33.input,
|
|
2810
|
+
{ color: colors2.colorOnBg }
|
|
2811
|
+
]
|
|
2803
2812
|
}
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
isDisabled && { opacity: 0.4 }
|
|
2823
|
-
],
|
|
2824
|
-
children: /* @__PURE__ */ (0, import_jsx_runtime37.jsx)(
|
|
2825
|
-
import_react_native37.Text,
|
|
2813
|
+
),
|
|
2814
|
+
isOpen && filtered.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime37.jsx)(
|
|
2815
|
+
import_react_native37.ScrollView,
|
|
2816
|
+
{
|
|
2817
|
+
style: [
|
|
2818
|
+
styles33.dropdown,
|
|
2819
|
+
{
|
|
2820
|
+
borderColor: colors2.colorBgSubtle,
|
|
2821
|
+
backgroundColor: colors2.colorBg
|
|
2822
|
+
}
|
|
2823
|
+
],
|
|
2824
|
+
keyboardShouldPersistTaps: "handled",
|
|
2825
|
+
nestedScrollEnabled: true,
|
|
2826
|
+
children: filtered.map((option) => {
|
|
2827
|
+
const isSelected = option.value === value;
|
|
2828
|
+
const isDisabled = option.disabled;
|
|
2829
|
+
return /* @__PURE__ */ (0, import_jsx_runtime37.jsx)(
|
|
2830
|
+
import_react_native37.Pressable,
|
|
2826
2831
|
{
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2832
|
+
onPress: () => !isDisabled && selectOption(option),
|
|
2833
|
+
disabled: isDisabled,
|
|
2834
|
+
accessibilityRole: "menuitem",
|
|
2835
|
+
accessibilityLabel: option.label,
|
|
2836
|
+
accessibilityState: { selected: isSelected, disabled: isDisabled },
|
|
2837
|
+
style: ({ pressed }) => [
|
|
2838
|
+
styles33.option,
|
|
2839
|
+
isSelected && { backgroundColor: colors2.colorBlueSubtle },
|
|
2840
|
+
pressed && !isDisabled && { backgroundColor: colors2.colorBgSubtle },
|
|
2841
|
+
isDisabled && { opacity: 0.4 }
|
|
2833
2842
|
],
|
|
2834
|
-
children:
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2843
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime37.jsx)(
|
|
2844
|
+
import_react_native37.Text,
|
|
2845
|
+
{
|
|
2846
|
+
style: [
|
|
2847
|
+
styles33.optionText,
|
|
2848
|
+
{
|
|
2849
|
+
color: isSelected ? colors2.colorInteractive : colors2.colorOnBg
|
|
2850
|
+
},
|
|
2851
|
+
isSelected && { fontWeight: import_native.font.weight.semibold }
|
|
2852
|
+
],
|
|
2853
|
+
children: option.label
|
|
2854
|
+
}
|
|
2855
|
+
)
|
|
2856
|
+
},
|
|
2857
|
+
option.value
|
|
2858
|
+
);
|
|
2859
|
+
})
|
|
2860
|
+
}
|
|
2861
|
+
)
|
|
2862
|
+
]
|
|
2863
|
+
}
|
|
2864
|
+
),
|
|
2844
2865
|
/* @__PURE__ */ (0, import_jsx_runtime37.jsx)(Message, { type: "error", message: error }),
|
|
2845
2866
|
/* @__PURE__ */ (0, import_jsx_runtime37.jsx)(Message, { type: "hint", message: hint })
|
|
2846
2867
|
] });
|
|
@@ -2928,6 +2949,7 @@ function SegmentedControl({
|
|
|
2928
2949
|
var import_react11 = require("react");
|
|
2929
2950
|
var import_react_native39 = require("react-native");
|
|
2930
2951
|
var import_jsx_runtime39 = require("react/jsx-runtime");
|
|
2952
|
+
var INPUT_HEIGHT2 = 44;
|
|
2931
2953
|
var keyboardTypeMap = {
|
|
2932
2954
|
text: "default",
|
|
2933
2955
|
email: "email-address",
|
|
@@ -2941,15 +2963,28 @@ var styles35 = import_react_native39.StyleSheet.create({
|
|
|
2941
2963
|
container: {
|
|
2942
2964
|
gap: import_native.spacing.xxs
|
|
2943
2965
|
},
|
|
2966
|
+
/**
|
|
2967
|
+
* The wrapper draws the pill border and provides the fixed 44pt touch-target height.
|
|
2968
|
+
* justifyContent: 'center' vertically centers the inner TextInput on both iOS and Android.
|
|
2969
|
+
*/
|
|
2970
|
+
inputWrapper: {
|
|
2971
|
+
height: INPUT_HEIGHT2,
|
|
2972
|
+
justifyContent: "center",
|
|
2973
|
+
borderWidth: 1,
|
|
2974
|
+
borderRadius: import_native.border.radius.full
|
|
2975
|
+
},
|
|
2976
|
+
/**
|
|
2977
|
+
* No vertical padding: iOS centers text automatically inside the wrapper height.
|
|
2978
|
+
* textAlignVertical + includeFontPadding are Android-specific but harmless on iOS.
|
|
2979
|
+
* No lineHeight: explicit lineHeight on a single-line TextInput clips ascenders/descenders on iOS.
|
|
2980
|
+
*/
|
|
2944
2981
|
input: {
|
|
2945
2982
|
fontFamily: import_native.font.family.body,
|
|
2946
2983
|
fontSize: import_native.font.size.body,
|
|
2947
|
-
lineHeight: lineHeight.body,
|
|
2948
2984
|
paddingHorizontal: import_native.spacing.s,
|
|
2949
|
-
paddingVertical:
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
minHeight: 44
|
|
2985
|
+
paddingVertical: 0,
|
|
2986
|
+
textAlignVertical: "center",
|
|
2987
|
+
includeFontPadding: false
|
|
2953
2988
|
}
|
|
2954
2989
|
});
|
|
2955
2990
|
function FormInput({
|
|
@@ -2973,38 +3008,46 @@ function FormInput({
|
|
|
2973
3008
|
return /* @__PURE__ */ (0, import_jsx_runtime39.jsxs)(import_react_native39.View, { style: [styles35.container, style], children: [
|
|
2974
3009
|
/* @__PURE__ */ (0, import_jsx_runtime39.jsx)(Label, { label }),
|
|
2975
3010
|
/* @__PURE__ */ (0, import_jsx_runtime39.jsx)(
|
|
2976
|
-
import_react_native39.
|
|
3011
|
+
import_react_native39.View,
|
|
2977
3012
|
{
|
|
2978
|
-
accessibilityLabel: typeof label === "string" ? label : name,
|
|
2979
|
-
accessibilityState: { disabled },
|
|
2980
|
-
accessibilityHint: hint,
|
|
2981
|
-
value,
|
|
2982
|
-
defaultValue,
|
|
2983
|
-
placeholder,
|
|
2984
|
-
placeholderTextColor: colors2.colorOnBgSubtle,
|
|
2985
|
-
keyboardType: keyboardTypeMap[type],
|
|
2986
|
-
secureTextEntry: type === "password",
|
|
2987
|
-
autoCapitalize: type === "email" || type === "url" ? "none" : "sentences",
|
|
2988
|
-
autoComplete: type === "email" ? "email" : type === "tel" ? "tel" : void 0,
|
|
2989
|
-
editable: !disabled,
|
|
2990
|
-
onChangeText: onChange,
|
|
2991
|
-
onFocus: () => {
|
|
2992
|
-
setFocused(true);
|
|
2993
|
-
onFocus?.();
|
|
2994
|
-
},
|
|
2995
|
-
onBlur: () => {
|
|
2996
|
-
setFocused(false);
|
|
2997
|
-
onBlur?.();
|
|
2998
|
-
},
|
|
2999
3013
|
style: [
|
|
3000
|
-
styles35.
|
|
3014
|
+
styles35.inputWrapper,
|
|
3001
3015
|
{
|
|
3002
3016
|
borderColor,
|
|
3003
|
-
color: colors2.colorOnBg,
|
|
3004
3017
|
backgroundColor: colors2.colorBg
|
|
3005
3018
|
},
|
|
3006
3019
|
disabled && { opacity: 0.4 }
|
|
3007
|
-
]
|
|
3020
|
+
],
|
|
3021
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime39.jsx)(
|
|
3022
|
+
import_react_native39.TextInput,
|
|
3023
|
+
{
|
|
3024
|
+
accessibilityLabel: typeof label === "string" ? label : name,
|
|
3025
|
+
accessibilityState: { disabled },
|
|
3026
|
+
accessibilityHint: hint,
|
|
3027
|
+
value,
|
|
3028
|
+
defaultValue,
|
|
3029
|
+
placeholder,
|
|
3030
|
+
placeholderTextColor: colors2.colorOnBgSubtle,
|
|
3031
|
+
keyboardType: keyboardTypeMap[type],
|
|
3032
|
+
secureTextEntry: type === "password",
|
|
3033
|
+
autoCapitalize: type === "email" || type === "url" ? "none" : "sentences",
|
|
3034
|
+
autoComplete: type === "email" ? "email" : type === "tel" ? "tel" : void 0,
|
|
3035
|
+
editable: !disabled,
|
|
3036
|
+
onChangeText: onChange,
|
|
3037
|
+
onFocus: () => {
|
|
3038
|
+
setFocused(true);
|
|
3039
|
+
onFocus?.();
|
|
3040
|
+
},
|
|
3041
|
+
onBlur: () => {
|
|
3042
|
+
setFocused(false);
|
|
3043
|
+
onBlur?.();
|
|
3044
|
+
},
|
|
3045
|
+
style: [
|
|
3046
|
+
styles35.input,
|
|
3047
|
+
{ color: colors2.colorOnBg }
|
|
3048
|
+
]
|
|
3049
|
+
}
|
|
3050
|
+
)
|
|
3008
3051
|
}
|
|
3009
3052
|
),
|
|
3010
3053
|
/* @__PURE__ */ (0, import_jsx_runtime39.jsx)(Message, { type: "error", message: error }),
|
|
@@ -3500,17 +3543,30 @@ var styles39 = import_react_native43.StyleSheet.create({
|
|
|
3500
3543
|
fontWeight: import_native.font.weight.semibold,
|
|
3501
3544
|
lineHeight: 24
|
|
3502
3545
|
},
|
|
3503
|
-
|
|
3546
|
+
/**
|
|
3547
|
+
* The inputWrapper holds the border and fixed height so justifyContent: 'center'
|
|
3548
|
+
* vertically centers the TextInput. flex: 1 lets it fill the space between steppers.
|
|
3549
|
+
*/
|
|
3550
|
+
inputWrapper: {
|
|
3504
3551
|
flex: 1,
|
|
3552
|
+
height: STEPPER_SIZE,
|
|
3553
|
+
justifyContent: "center",
|
|
3554
|
+
borderTopWidth: 1,
|
|
3555
|
+
borderBottomWidth: 1
|
|
3556
|
+
},
|
|
3557
|
+
/**
|
|
3558
|
+
* No vertical padding: iOS auto-centers within the wrapper height.
|
|
3559
|
+
* textAlignVertical + includeFontPadding provide Android parity.
|
|
3560
|
+
* No lineHeight: explicit lineHeight clips ascenders/descenders on iOS.
|
|
3561
|
+
*/
|
|
3562
|
+
input: {
|
|
3505
3563
|
fontFamily: import_native.font.family.body,
|
|
3506
3564
|
fontSize: import_native.font.size.body,
|
|
3507
|
-
lineHeight: lineHeight.body,
|
|
3508
3565
|
textAlign: "center",
|
|
3509
3566
|
paddingHorizontal: import_native.spacing.xs,
|
|
3510
|
-
paddingVertical:
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
minHeight: STEPPER_SIZE
|
|
3567
|
+
paddingVertical: 0,
|
|
3568
|
+
textAlignVertical: "center",
|
|
3569
|
+
includeFontPadding: false
|
|
3514
3570
|
}
|
|
3515
3571
|
});
|
|
3516
3572
|
function NumberInput({
|
|
@@ -3576,32 +3632,40 @@ function NumberInput({
|
|
|
3576
3632
|
}
|
|
3577
3633
|
),
|
|
3578
3634
|
/* @__PURE__ */ (0, import_jsx_runtime43.jsx)(
|
|
3579
|
-
import_react_native43.
|
|
3635
|
+
import_react_native43.View,
|
|
3580
3636
|
{
|
|
3581
|
-
accessibilityLabel: label,
|
|
3582
|
-
accessibilityState: { disabled },
|
|
3583
|
-
accessibilityValue: {
|
|
3584
|
-
min,
|
|
3585
|
-
max,
|
|
3586
|
-
now: currentValue,
|
|
3587
|
-
text: String(currentValue)
|
|
3588
|
-
},
|
|
3589
|
-
keyboardType: "numeric",
|
|
3590
|
-
value: String(currentValue),
|
|
3591
|
-
editable: !disabled,
|
|
3592
|
-
onChangeText: (text) => {
|
|
3593
|
-
const parsed = parseFloat(text);
|
|
3594
|
-
const safe = isNaN(parsed) ? 0 : parsed;
|
|
3595
|
-
update(safe);
|
|
3596
|
-
},
|
|
3597
3637
|
style: [
|
|
3598
|
-
styles39.
|
|
3638
|
+
styles39.inputWrapper,
|
|
3599
3639
|
{
|
|
3600
3640
|
borderColor,
|
|
3601
|
-
color: colors2.colorOnBg,
|
|
3602
3641
|
backgroundColor: colors2.colorBg
|
|
3603
3642
|
}
|
|
3604
|
-
]
|
|
3643
|
+
],
|
|
3644
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime43.jsx)(
|
|
3645
|
+
import_react_native43.TextInput,
|
|
3646
|
+
{
|
|
3647
|
+
accessibilityLabel: label,
|
|
3648
|
+
accessibilityState: { disabled },
|
|
3649
|
+
accessibilityValue: {
|
|
3650
|
+
min,
|
|
3651
|
+
max,
|
|
3652
|
+
now: currentValue,
|
|
3653
|
+
text: String(currentValue)
|
|
3654
|
+
},
|
|
3655
|
+
keyboardType: "numeric",
|
|
3656
|
+
value: String(currentValue),
|
|
3657
|
+
editable: !disabled,
|
|
3658
|
+
onChangeText: (text) => {
|
|
3659
|
+
const parsed = parseFloat(text);
|
|
3660
|
+
const safe = isNaN(parsed) ? 0 : parsed;
|
|
3661
|
+
update(safe);
|
|
3662
|
+
},
|
|
3663
|
+
style: [
|
|
3664
|
+
styles39.input,
|
|
3665
|
+
{ color: colors2.colorOnBg }
|
|
3666
|
+
]
|
|
3667
|
+
}
|
|
3668
|
+
)
|
|
3605
3669
|
}
|
|
3606
3670
|
),
|
|
3607
3671
|
/* @__PURE__ */ (0, import_jsx_runtime43.jsx)(
|
package/dist/index.js
CHANGED
|
@@ -2635,23 +2635,36 @@ import {
|
|
|
2635
2635
|
ScrollView as ScrollView4
|
|
2636
2636
|
} from "react-native";
|
|
2637
2637
|
import { jsx as jsx37, jsxs as jsxs23 } from "react/jsx-runtime";
|
|
2638
|
+
var INPUT_HEIGHT = 44;
|
|
2638
2639
|
var MAX_DROPDOWN_HEIGHT = 200;
|
|
2639
2640
|
var styles33 = StyleSheet33.create({
|
|
2640
2641
|
container: {
|
|
2641
2642
|
gap: spacing.xxs
|
|
2642
2643
|
},
|
|
2644
|
+
/**
|
|
2645
|
+
* Draws the pill border and provides the fixed 44pt touch-target height.
|
|
2646
|
+
* justifyContent: 'center' vertically centers the inner TextInput.
|
|
2647
|
+
* position: 'relative' is required for the dropdown's absolute positioning.
|
|
2648
|
+
*/
|
|
2643
2649
|
inputWrapper: {
|
|
2644
|
-
position: "relative"
|
|
2650
|
+
position: "relative",
|
|
2651
|
+
height: INPUT_HEIGHT,
|
|
2652
|
+
justifyContent: "center",
|
|
2653
|
+
borderWidth: 1,
|
|
2654
|
+
borderRadius: border.radius.full
|
|
2645
2655
|
},
|
|
2656
|
+
/**
|
|
2657
|
+
* No vertical padding: iOS centers text automatically inside the wrapper height.
|
|
2658
|
+
* textAlignVertical + includeFontPadding provide Android parity.
|
|
2659
|
+
* No lineHeight: explicit lineHeight clips ascenders/descenders on iOS.
|
|
2660
|
+
*/
|
|
2646
2661
|
input: {
|
|
2647
2662
|
fontFamily: font.family.body,
|
|
2648
2663
|
fontSize: font.size.body,
|
|
2649
|
-
lineHeight: lineHeight.body,
|
|
2650
|
-
paddingVertical: spacing.xs,
|
|
2651
2664
|
paddingHorizontal: spacing.s,
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2665
|
+
paddingVertical: 0,
|
|
2666
|
+
textAlignVertical: "center",
|
|
2667
|
+
includeFontPadding: false
|
|
2655
2668
|
},
|
|
2656
2669
|
dropdown: {
|
|
2657
2670
|
position: "absolute",
|
|
@@ -2720,80 +2733,88 @@ function Combobox({
|
|
|
2720
2733
|
const borderColor = error ? colors2.colorError : colors2.colorOnBg;
|
|
2721
2734
|
return /* @__PURE__ */ jsxs23(View30, { style: [styles33.container, style], children: [
|
|
2722
2735
|
/* @__PURE__ */ jsx37(Label, { label }),
|
|
2723
|
-
/* @__PURE__ */ jsxs23(
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
style: [
|
|
2738
|
-
styles33.input,
|
|
2739
|
-
{
|
|
2740
|
-
borderColor,
|
|
2741
|
-
color: colors2.colorOnBg,
|
|
2742
|
-
backgroundColor: colors2.colorBg
|
|
2743
|
-
},
|
|
2744
|
-
disabled && { opacity: 0.4 }
|
|
2745
|
-
]
|
|
2746
|
-
}
|
|
2747
|
-
),
|
|
2748
|
-
isOpen && filtered.length > 0 && /* @__PURE__ */ jsx37(
|
|
2749
|
-
ScrollView4,
|
|
2750
|
-
{
|
|
2751
|
-
style: [
|
|
2752
|
-
styles33.dropdown,
|
|
2736
|
+
/* @__PURE__ */ jsxs23(
|
|
2737
|
+
View30,
|
|
2738
|
+
{
|
|
2739
|
+
style: [
|
|
2740
|
+
styles33.inputWrapper,
|
|
2741
|
+
{
|
|
2742
|
+
borderColor,
|
|
2743
|
+
backgroundColor: colors2.colorBg
|
|
2744
|
+
},
|
|
2745
|
+
disabled && { opacity: 0.4 }
|
|
2746
|
+
],
|
|
2747
|
+
children: [
|
|
2748
|
+
/* @__PURE__ */ jsx37(
|
|
2749
|
+
TextInput,
|
|
2753
2750
|
{
|
|
2754
|
-
|
|
2755
|
-
|
|
2751
|
+
value: inputValue,
|
|
2752
|
+
onChangeText: handleChangeText,
|
|
2753
|
+
onFocus: handleFocus,
|
|
2754
|
+
onBlur: handleBlur,
|
|
2755
|
+
placeholder,
|
|
2756
|
+
placeholderTextColor: colors2.colorOnBgSubtle,
|
|
2757
|
+
editable: !disabled,
|
|
2758
|
+
accessibilityRole: "combobox",
|
|
2759
|
+
accessibilityLabel: typeof label === "string" ? label : name,
|
|
2760
|
+
accessibilityState: { disabled, expanded: isOpen },
|
|
2761
|
+
style: [
|
|
2762
|
+
styles33.input,
|
|
2763
|
+
{ color: colors2.colorOnBg }
|
|
2764
|
+
]
|
|
2756
2765
|
}
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
isDisabled && { opacity: 0.4 }
|
|
2776
|
-
],
|
|
2777
|
-
children: /* @__PURE__ */ jsx37(
|
|
2778
|
-
Text27,
|
|
2766
|
+
),
|
|
2767
|
+
isOpen && filtered.length > 0 && /* @__PURE__ */ jsx37(
|
|
2768
|
+
ScrollView4,
|
|
2769
|
+
{
|
|
2770
|
+
style: [
|
|
2771
|
+
styles33.dropdown,
|
|
2772
|
+
{
|
|
2773
|
+
borderColor: colors2.colorBgSubtle,
|
|
2774
|
+
backgroundColor: colors2.colorBg
|
|
2775
|
+
}
|
|
2776
|
+
],
|
|
2777
|
+
keyboardShouldPersistTaps: "handled",
|
|
2778
|
+
nestedScrollEnabled: true,
|
|
2779
|
+
children: filtered.map((option) => {
|
|
2780
|
+
const isSelected = option.value === value;
|
|
2781
|
+
const isDisabled = option.disabled;
|
|
2782
|
+
return /* @__PURE__ */ jsx37(
|
|
2783
|
+
Pressable18,
|
|
2779
2784
|
{
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2785
|
+
onPress: () => !isDisabled && selectOption(option),
|
|
2786
|
+
disabled: isDisabled,
|
|
2787
|
+
accessibilityRole: "menuitem",
|
|
2788
|
+
accessibilityLabel: option.label,
|
|
2789
|
+
accessibilityState: { selected: isSelected, disabled: isDisabled },
|
|
2790
|
+
style: ({ pressed }) => [
|
|
2791
|
+
styles33.option,
|
|
2792
|
+
isSelected && { backgroundColor: colors2.colorBlueSubtle },
|
|
2793
|
+
pressed && !isDisabled && { backgroundColor: colors2.colorBgSubtle },
|
|
2794
|
+
isDisabled && { opacity: 0.4 }
|
|
2786
2795
|
],
|
|
2787
|
-
children:
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2796
|
+
children: /* @__PURE__ */ jsx37(
|
|
2797
|
+
Text27,
|
|
2798
|
+
{
|
|
2799
|
+
style: [
|
|
2800
|
+
styles33.optionText,
|
|
2801
|
+
{
|
|
2802
|
+
color: isSelected ? colors2.colorInteractive : colors2.colorOnBg
|
|
2803
|
+
},
|
|
2804
|
+
isSelected && { fontWeight: font.weight.semibold }
|
|
2805
|
+
],
|
|
2806
|
+
children: option.label
|
|
2807
|
+
}
|
|
2808
|
+
)
|
|
2809
|
+
},
|
|
2810
|
+
option.value
|
|
2811
|
+
);
|
|
2812
|
+
})
|
|
2813
|
+
}
|
|
2814
|
+
)
|
|
2815
|
+
]
|
|
2816
|
+
}
|
|
2817
|
+
),
|
|
2797
2818
|
/* @__PURE__ */ jsx37(Message, { type: "error", message: error }),
|
|
2798
2819
|
/* @__PURE__ */ jsx37(Message, { type: "hint", message: hint })
|
|
2799
2820
|
] });
|
|
@@ -2885,6 +2906,7 @@ import {
|
|
|
2885
2906
|
TextInput as TextInput2
|
|
2886
2907
|
} from "react-native";
|
|
2887
2908
|
import { jsx as jsx39, jsxs as jsxs24 } from "react/jsx-runtime";
|
|
2909
|
+
var INPUT_HEIGHT2 = 44;
|
|
2888
2910
|
var keyboardTypeMap = {
|
|
2889
2911
|
text: "default",
|
|
2890
2912
|
email: "email-address",
|
|
@@ -2898,15 +2920,28 @@ var styles35 = StyleSheet35.create({
|
|
|
2898
2920
|
container: {
|
|
2899
2921
|
gap: spacing.xxs
|
|
2900
2922
|
},
|
|
2923
|
+
/**
|
|
2924
|
+
* The wrapper draws the pill border and provides the fixed 44pt touch-target height.
|
|
2925
|
+
* justifyContent: 'center' vertically centers the inner TextInput on both iOS and Android.
|
|
2926
|
+
*/
|
|
2927
|
+
inputWrapper: {
|
|
2928
|
+
height: INPUT_HEIGHT2,
|
|
2929
|
+
justifyContent: "center",
|
|
2930
|
+
borderWidth: 1,
|
|
2931
|
+
borderRadius: border.radius.full
|
|
2932
|
+
},
|
|
2933
|
+
/**
|
|
2934
|
+
* No vertical padding: iOS centers text automatically inside the wrapper height.
|
|
2935
|
+
* textAlignVertical + includeFontPadding are Android-specific but harmless on iOS.
|
|
2936
|
+
* No lineHeight: explicit lineHeight on a single-line TextInput clips ascenders/descenders on iOS.
|
|
2937
|
+
*/
|
|
2901
2938
|
input: {
|
|
2902
2939
|
fontFamily: font.family.body,
|
|
2903
2940
|
fontSize: font.size.body,
|
|
2904
|
-
lineHeight: lineHeight.body,
|
|
2905
2941
|
paddingHorizontal: spacing.s,
|
|
2906
|
-
paddingVertical:
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
minHeight: 44
|
|
2942
|
+
paddingVertical: 0,
|
|
2943
|
+
textAlignVertical: "center",
|
|
2944
|
+
includeFontPadding: false
|
|
2910
2945
|
}
|
|
2911
2946
|
});
|
|
2912
2947
|
function FormInput({
|
|
@@ -2930,38 +2965,46 @@ function FormInput({
|
|
|
2930
2965
|
return /* @__PURE__ */ jsxs24(View32, { style: [styles35.container, style], children: [
|
|
2931
2966
|
/* @__PURE__ */ jsx39(Label, { label }),
|
|
2932
2967
|
/* @__PURE__ */ jsx39(
|
|
2933
|
-
|
|
2968
|
+
View32,
|
|
2934
2969
|
{
|
|
2935
|
-
accessibilityLabel: typeof label === "string" ? label : name,
|
|
2936
|
-
accessibilityState: { disabled },
|
|
2937
|
-
accessibilityHint: hint,
|
|
2938
|
-
value,
|
|
2939
|
-
defaultValue,
|
|
2940
|
-
placeholder,
|
|
2941
|
-
placeholderTextColor: colors2.colorOnBgSubtle,
|
|
2942
|
-
keyboardType: keyboardTypeMap[type],
|
|
2943
|
-
secureTextEntry: type === "password",
|
|
2944
|
-
autoCapitalize: type === "email" || type === "url" ? "none" : "sentences",
|
|
2945
|
-
autoComplete: type === "email" ? "email" : type === "tel" ? "tel" : void 0,
|
|
2946
|
-
editable: !disabled,
|
|
2947
|
-
onChangeText: onChange,
|
|
2948
|
-
onFocus: () => {
|
|
2949
|
-
setFocused(true);
|
|
2950
|
-
onFocus?.();
|
|
2951
|
-
},
|
|
2952
|
-
onBlur: () => {
|
|
2953
|
-
setFocused(false);
|
|
2954
|
-
onBlur?.();
|
|
2955
|
-
},
|
|
2956
2970
|
style: [
|
|
2957
|
-
styles35.
|
|
2971
|
+
styles35.inputWrapper,
|
|
2958
2972
|
{
|
|
2959
2973
|
borderColor,
|
|
2960
|
-
color: colors2.colorOnBg,
|
|
2961
2974
|
backgroundColor: colors2.colorBg
|
|
2962
2975
|
},
|
|
2963
2976
|
disabled && { opacity: 0.4 }
|
|
2964
|
-
]
|
|
2977
|
+
],
|
|
2978
|
+
children: /* @__PURE__ */ jsx39(
|
|
2979
|
+
TextInput2,
|
|
2980
|
+
{
|
|
2981
|
+
accessibilityLabel: typeof label === "string" ? label : name,
|
|
2982
|
+
accessibilityState: { disabled },
|
|
2983
|
+
accessibilityHint: hint,
|
|
2984
|
+
value,
|
|
2985
|
+
defaultValue,
|
|
2986
|
+
placeholder,
|
|
2987
|
+
placeholderTextColor: colors2.colorOnBgSubtle,
|
|
2988
|
+
keyboardType: keyboardTypeMap[type],
|
|
2989
|
+
secureTextEntry: type === "password",
|
|
2990
|
+
autoCapitalize: type === "email" || type === "url" ? "none" : "sentences",
|
|
2991
|
+
autoComplete: type === "email" ? "email" : type === "tel" ? "tel" : void 0,
|
|
2992
|
+
editable: !disabled,
|
|
2993
|
+
onChangeText: onChange,
|
|
2994
|
+
onFocus: () => {
|
|
2995
|
+
setFocused(true);
|
|
2996
|
+
onFocus?.();
|
|
2997
|
+
},
|
|
2998
|
+
onBlur: () => {
|
|
2999
|
+
setFocused(false);
|
|
3000
|
+
onBlur?.();
|
|
3001
|
+
},
|
|
3002
|
+
style: [
|
|
3003
|
+
styles35.input,
|
|
3004
|
+
{ color: colors2.colorOnBg }
|
|
3005
|
+
]
|
|
3006
|
+
}
|
|
3007
|
+
)
|
|
2965
3008
|
}
|
|
2966
3009
|
),
|
|
2967
3010
|
/* @__PURE__ */ jsx39(Message, { type: "error", message: error }),
|
|
@@ -3479,17 +3522,30 @@ var styles39 = StyleSheet39.create({
|
|
|
3479
3522
|
fontWeight: font.weight.semibold,
|
|
3480
3523
|
lineHeight: 24
|
|
3481
3524
|
},
|
|
3482
|
-
|
|
3525
|
+
/**
|
|
3526
|
+
* The inputWrapper holds the border and fixed height so justifyContent: 'center'
|
|
3527
|
+
* vertically centers the TextInput. flex: 1 lets it fill the space between steppers.
|
|
3528
|
+
*/
|
|
3529
|
+
inputWrapper: {
|
|
3483
3530
|
flex: 1,
|
|
3531
|
+
height: STEPPER_SIZE,
|
|
3532
|
+
justifyContent: "center",
|
|
3533
|
+
borderTopWidth: 1,
|
|
3534
|
+
borderBottomWidth: 1
|
|
3535
|
+
},
|
|
3536
|
+
/**
|
|
3537
|
+
* No vertical padding: iOS auto-centers within the wrapper height.
|
|
3538
|
+
* textAlignVertical + includeFontPadding provide Android parity.
|
|
3539
|
+
* No lineHeight: explicit lineHeight clips ascenders/descenders on iOS.
|
|
3540
|
+
*/
|
|
3541
|
+
input: {
|
|
3484
3542
|
fontFamily: font.family.body,
|
|
3485
3543
|
fontSize: font.size.body,
|
|
3486
|
-
lineHeight: lineHeight.body,
|
|
3487
3544
|
textAlign: "center",
|
|
3488
3545
|
paddingHorizontal: spacing.xs,
|
|
3489
|
-
paddingVertical:
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
minHeight: STEPPER_SIZE
|
|
3546
|
+
paddingVertical: 0,
|
|
3547
|
+
textAlignVertical: "center",
|
|
3548
|
+
includeFontPadding: false
|
|
3493
3549
|
}
|
|
3494
3550
|
});
|
|
3495
3551
|
function NumberInput({
|
|
@@ -3555,32 +3611,40 @@ function NumberInput({
|
|
|
3555
3611
|
}
|
|
3556
3612
|
),
|
|
3557
3613
|
/* @__PURE__ */ jsx43(
|
|
3558
|
-
|
|
3614
|
+
View36,
|
|
3559
3615
|
{
|
|
3560
|
-
accessibilityLabel: label,
|
|
3561
|
-
accessibilityState: { disabled },
|
|
3562
|
-
accessibilityValue: {
|
|
3563
|
-
min,
|
|
3564
|
-
max,
|
|
3565
|
-
now: currentValue,
|
|
3566
|
-
text: String(currentValue)
|
|
3567
|
-
},
|
|
3568
|
-
keyboardType: "numeric",
|
|
3569
|
-
value: String(currentValue),
|
|
3570
|
-
editable: !disabled,
|
|
3571
|
-
onChangeText: (text) => {
|
|
3572
|
-
const parsed = parseFloat(text);
|
|
3573
|
-
const safe = isNaN(parsed) ? 0 : parsed;
|
|
3574
|
-
update(safe);
|
|
3575
|
-
},
|
|
3576
3616
|
style: [
|
|
3577
|
-
styles39.
|
|
3617
|
+
styles39.inputWrapper,
|
|
3578
3618
|
{
|
|
3579
3619
|
borderColor,
|
|
3580
|
-
color: colors2.colorOnBg,
|
|
3581
3620
|
backgroundColor: colors2.colorBg
|
|
3582
3621
|
}
|
|
3583
|
-
]
|
|
3622
|
+
],
|
|
3623
|
+
children: /* @__PURE__ */ jsx43(
|
|
3624
|
+
TextInput4,
|
|
3625
|
+
{
|
|
3626
|
+
accessibilityLabel: label,
|
|
3627
|
+
accessibilityState: { disabled },
|
|
3628
|
+
accessibilityValue: {
|
|
3629
|
+
min,
|
|
3630
|
+
max,
|
|
3631
|
+
now: currentValue,
|
|
3632
|
+
text: String(currentValue)
|
|
3633
|
+
},
|
|
3634
|
+
keyboardType: "numeric",
|
|
3635
|
+
value: String(currentValue),
|
|
3636
|
+
editable: !disabled,
|
|
3637
|
+
onChangeText: (text) => {
|
|
3638
|
+
const parsed = parseFloat(text);
|
|
3639
|
+
const safe = isNaN(parsed) ? 0 : parsed;
|
|
3640
|
+
update(safe);
|
|
3641
|
+
},
|
|
3642
|
+
style: [
|
|
3643
|
+
styles39.input,
|
|
3644
|
+
{ color: colors2.colorOnBg }
|
|
3645
|
+
]
|
|
3646
|
+
}
|
|
3647
|
+
)
|
|
3584
3648
|
}
|
|
3585
3649
|
),
|
|
3586
3650
|
/* @__PURE__ */ jsx43(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boostdev/design-system-react-native",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "BoostDev React Native component library: accessible, token-driven components built on @boostdev/design-system-foundation",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -57,4 +57,32 @@ describe('Combobox (native)', () => {
|
|
|
57
57
|
);
|
|
58
58
|
expect(screen.getByText('Type to filter.')).toBeInTheDocument();
|
|
59
59
|
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Vertical-centering invariant: the TextInput must have no vertical padding so that
|
|
63
|
+
* iOS centers text automatically within the wrapper height.
|
|
64
|
+
*/
|
|
65
|
+
it('has paddingVertical: 0 on the TextInput (vertical centering)', () => {
|
|
66
|
+
renderWithTheme(
|
|
67
|
+
<Combobox label="Fruit" name="fruit" options={fruits} />,
|
|
68
|
+
);
|
|
69
|
+
const input = screen.getByRole('combobox');
|
|
70
|
+
const style = getComputedStyle(input);
|
|
71
|
+
expect(parseFloat(style.paddingTop)).toBe(0);
|
|
72
|
+
expect(parseFloat(style.paddingBottom)).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Touch-target invariant: the inputWrapper View that draws the pill border must
|
|
77
|
+
* provide the 44pt touch-target height.
|
|
78
|
+
*/
|
|
79
|
+
it('has height of 44pt on the pill wrapper (touch target)', () => {
|
|
80
|
+
renderWithTheme(
|
|
81
|
+
<Combobox label="Fruit" name="fruit" options={fruits} />,
|
|
82
|
+
);
|
|
83
|
+
const input = screen.getByRole('combobox');
|
|
84
|
+
const wrapper = input.parentElement as HTMLElement;
|
|
85
|
+
const heightPx = parseFloat(getComputedStyle(wrapper).height);
|
|
86
|
+
expect(heightPx).toBeGreaterThanOrEqual(44);
|
|
87
|
+
});
|
|
60
88
|
});
|
|
@@ -9,7 +9,10 @@ import {
|
|
|
9
9
|
StyleProp,
|
|
10
10
|
ViewStyle,
|
|
11
11
|
} from 'react-native';
|
|
12
|
-
import { spacing, font, border, lineHeight } from '../../../../native/tokens';
|
|
12
|
+
import { spacing, font, border, lineHeight } from '../../../../native/tokens'; // lineHeight used for optionText (Text, not TextInput)
|
|
13
|
+
|
|
14
|
+
/** Fixed height for single-line control fields (satisfies 44pt touch target). */
|
|
15
|
+
const INPUT_HEIGHT = 44;
|
|
13
16
|
import { useTheme } from '../../../../native/ThemeContext';
|
|
14
17
|
import { Label } from '../atoms/Label';
|
|
15
18
|
import { Message } from '../atoms/Message';
|
|
@@ -39,18 +42,30 @@ const styles = StyleSheet.create({
|
|
|
39
42
|
container: {
|
|
40
43
|
gap: spacing.xxs,
|
|
41
44
|
},
|
|
45
|
+
/**
|
|
46
|
+
* Draws the pill border and provides the fixed 44pt touch-target height.
|
|
47
|
+
* justifyContent: 'center' vertically centers the inner TextInput.
|
|
48
|
+
* position: 'relative' is required for the dropdown's absolute positioning.
|
|
49
|
+
*/
|
|
42
50
|
inputWrapper: {
|
|
43
51
|
position: 'relative',
|
|
52
|
+
height: INPUT_HEIGHT,
|
|
53
|
+
justifyContent: 'center',
|
|
54
|
+
borderWidth: 1,
|
|
55
|
+
borderRadius: border.radius.full,
|
|
44
56
|
},
|
|
57
|
+
/**
|
|
58
|
+
* No vertical padding: iOS centers text automatically inside the wrapper height.
|
|
59
|
+
* textAlignVertical + includeFontPadding provide Android parity.
|
|
60
|
+
* No lineHeight: explicit lineHeight clips ascenders/descenders on iOS.
|
|
61
|
+
*/
|
|
45
62
|
input: {
|
|
46
63
|
fontFamily: font.family.body,
|
|
47
64
|
fontSize: font.size.body,
|
|
48
|
-
lineHeight: lineHeight.body,
|
|
49
|
-
paddingVertical: spacing.xs,
|
|
50
65
|
paddingHorizontal: spacing.s,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
66
|
+
paddingVertical: 0,
|
|
67
|
+
textAlignVertical: 'center',
|
|
68
|
+
includeFontPadding: false,
|
|
54
69
|
},
|
|
55
70
|
dropdown: {
|
|
56
71
|
position: 'absolute',
|
|
@@ -132,7 +147,16 @@ export function Combobox({
|
|
|
132
147
|
return (
|
|
133
148
|
<View style={[styles.container, style]}>
|
|
134
149
|
<Label label={label} />
|
|
135
|
-
<View
|
|
150
|
+
<View
|
|
151
|
+
style={[
|
|
152
|
+
styles.inputWrapper,
|
|
153
|
+
{
|
|
154
|
+
borderColor,
|
|
155
|
+
backgroundColor: colors.colorBg,
|
|
156
|
+
},
|
|
157
|
+
disabled && { opacity: 0.4 },
|
|
158
|
+
]}
|
|
159
|
+
>
|
|
136
160
|
<TextInput
|
|
137
161
|
value={inputValue}
|
|
138
162
|
onChangeText={handleChangeText}
|
|
@@ -146,12 +170,7 @@ export function Combobox({
|
|
|
146
170
|
accessibilityState={{ disabled, expanded: isOpen }}
|
|
147
171
|
style={[
|
|
148
172
|
styles.input,
|
|
149
|
-
{
|
|
150
|
-
borderColor,
|
|
151
|
-
color: colors.colorOnBg,
|
|
152
|
-
backgroundColor: colors.colorBg,
|
|
153
|
-
},
|
|
154
|
-
disabled && { opacity: 0.4 },
|
|
173
|
+
{ color: colors.colorOnBg },
|
|
155
174
|
]}
|
|
156
175
|
/>
|
|
157
176
|
{isOpen && filtered.length > 0 && (
|
|
@@ -48,4 +48,51 @@ describe('FormInput (native)', () => {
|
|
|
48
48
|
const inputs = container.querySelectorAll('input, [style*="opacity"]');
|
|
49
49
|
expect(inputs.length).toBeGreaterThan(0);
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Style-contract invariant: single-line TextInput must NOT have lineHeight set.
|
|
54
|
+
*
|
|
55
|
+
* On iOS, an explicit lineHeight on a single-line TextInput causes the text to
|
|
56
|
+
* render vertically clipped — descenders and ascenders are cut off at the line box
|
|
57
|
+
* boundary. The wrapper height + justifyContent: 'center' satisfies vertical centering
|
|
58
|
+
* without needing lineHeight. This test pins that invariant so it can't silently regress.
|
|
59
|
+
*
|
|
60
|
+
* react-native-web applies token-based styles as atomic CSS classes. getComputedStyle
|
|
61
|
+
* resolves those classes in jsdom and returns "normal" when lineHeight is absent.
|
|
62
|
+
*/
|
|
63
|
+
it('does not set lineHeight on the TextInput (iOS clipping prevention)', () => {
|
|
64
|
+
renderWithTheme(<FormInput label="Email" name="email" value="bjorn@boonen.eu" />);
|
|
65
|
+
const input = screen.getByDisplayValue('bjorn@boonen.eu');
|
|
66
|
+
// "normal" means no explicit lineHeight was applied; a numeric value means it was.
|
|
67
|
+
expect(getComputedStyle(input).lineHeight).toBe('normal');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Vertical-centering invariant: the TextInput must have no vertical padding so that
|
|
72
|
+
* iOS centers text automatically within the wrapper height. A non-zero paddingVertical
|
|
73
|
+
* shifts text off-center because iOS adds its own default internal top padding on top.
|
|
74
|
+
*/
|
|
75
|
+
it('has paddingVertical: 0 on the TextInput (vertical centering)', () => {
|
|
76
|
+
renderWithTheme(<FormInput label="Email" name="email" />);
|
|
77
|
+
const input = screen.getByRole('textbox');
|
|
78
|
+
const style = getComputedStyle(input);
|
|
79
|
+
expect(parseFloat(style.paddingTop)).toBe(0);
|
|
80
|
+
expect(parseFloat(style.paddingBottom)).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Touch-target invariant: the wrapper View that draws the pill border must provide
|
|
85
|
+
* the 44pt touch-target height (replacing the minHeight that was previously on the
|
|
86
|
+
* TextInput itself).
|
|
87
|
+
*/
|
|
88
|
+
it('has height of 44pt on the pill wrapper (touch target)', () => {
|
|
89
|
+
const { container } = renderWithTheme(<FormInput label="Email" name="email" />);
|
|
90
|
+
// The wrapper is the first div that contains the input in react-native-web's output.
|
|
91
|
+
const input = screen.getByRole('textbox');
|
|
92
|
+
const wrapper = input.parentElement as HTMLElement;
|
|
93
|
+
const heightPx = parseFloat(getComputedStyle(wrapper).height);
|
|
94
|
+
expect(heightPx).toBeGreaterThanOrEqual(44);
|
|
95
|
+
// Suppress unused-variable warning from destructuring
|
|
96
|
+
void container;
|
|
97
|
+
});
|
|
51
98
|
});
|
|
@@ -7,11 +7,14 @@ import {
|
|
|
7
7
|
StyleProp,
|
|
8
8
|
ViewStyle,
|
|
9
9
|
} from 'react-native';
|
|
10
|
-
import { spacing, font, border
|
|
10
|
+
import { spacing, font, border } from '../../../../native/tokens';
|
|
11
11
|
import { useTheme } from '../../../../native/ThemeContext';
|
|
12
12
|
import { Label } from '../atoms/Label';
|
|
13
13
|
import { Message } from '../atoms/Message';
|
|
14
14
|
|
|
15
|
+
/** Fixed height for single-line control fields (satisfies 44pt touch target). */
|
|
16
|
+
const INPUT_HEIGHT = 44;
|
|
17
|
+
|
|
15
18
|
type InputType = 'text' | 'email' | 'password' | 'tel' | 'url' | 'number' | 'search';
|
|
16
19
|
|
|
17
20
|
interface FormInputProps {
|
|
@@ -44,15 +47,28 @@ const styles = StyleSheet.create({
|
|
|
44
47
|
container: {
|
|
45
48
|
gap: spacing.xxs,
|
|
46
49
|
},
|
|
50
|
+
/**
|
|
51
|
+
* The wrapper draws the pill border and provides the fixed 44pt touch-target height.
|
|
52
|
+
* justifyContent: 'center' vertically centers the inner TextInput on both iOS and Android.
|
|
53
|
+
*/
|
|
54
|
+
inputWrapper: {
|
|
55
|
+
height: INPUT_HEIGHT,
|
|
56
|
+
justifyContent: 'center',
|
|
57
|
+
borderWidth: 1,
|
|
58
|
+
borderRadius: border.radius.full,
|
|
59
|
+
},
|
|
60
|
+
/**
|
|
61
|
+
* No vertical padding: iOS centers text automatically inside the wrapper height.
|
|
62
|
+
* textAlignVertical + includeFontPadding are Android-specific but harmless on iOS.
|
|
63
|
+
* No lineHeight: explicit lineHeight on a single-line TextInput clips ascenders/descenders on iOS.
|
|
64
|
+
*/
|
|
47
65
|
input: {
|
|
48
66
|
fontFamily: font.family.body,
|
|
49
67
|
fontSize: font.size.body,
|
|
50
|
-
lineHeight: lineHeight.body,
|
|
51
68
|
paddingHorizontal: spacing.s,
|
|
52
|
-
paddingVertical:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
minHeight: 44,
|
|
69
|
+
paddingVertical: 0,
|
|
70
|
+
textAlignVertical: 'center',
|
|
71
|
+
includeFontPadding: false,
|
|
56
72
|
},
|
|
57
73
|
});
|
|
58
74
|
|
|
@@ -83,38 +99,44 @@ export function FormInput({
|
|
|
83
99
|
return (
|
|
84
100
|
<View style={[styles.container, style]}>
|
|
85
101
|
<Label label={label} />
|
|
86
|
-
<
|
|
87
|
-
accessibilityLabel={typeof label === 'string' ? label : name}
|
|
88
|
-
accessibilityState={{ disabled }}
|
|
89
|
-
accessibilityHint={hint}
|
|
90
|
-
value={value}
|
|
91
|
-
defaultValue={defaultValue}
|
|
92
|
-
placeholder={placeholder}
|
|
93
|
-
placeholderTextColor={colors.colorOnBgSubtle}
|
|
94
|
-
keyboardType={keyboardTypeMap[type]}
|
|
95
|
-
secureTextEntry={type === 'password'}
|
|
96
|
-
autoCapitalize={type === 'email' || type === 'url' ? 'none' : 'sentences'}
|
|
97
|
-
autoComplete={type === 'email' ? 'email' : type === 'tel' ? 'tel' : undefined}
|
|
98
|
-
editable={!disabled}
|
|
99
|
-
onChangeText={onChange}
|
|
100
|
-
onFocus={() => {
|
|
101
|
-
setFocused(true);
|
|
102
|
-
onFocus?.();
|
|
103
|
-
}}
|
|
104
|
-
onBlur={() => {
|
|
105
|
-
setFocused(false);
|
|
106
|
-
onBlur?.();
|
|
107
|
-
}}
|
|
102
|
+
<View
|
|
108
103
|
style={[
|
|
109
|
-
styles.
|
|
104
|
+
styles.inputWrapper,
|
|
110
105
|
{
|
|
111
106
|
borderColor,
|
|
112
|
-
color: colors.colorOnBg,
|
|
113
107
|
backgroundColor: colors.colorBg,
|
|
114
108
|
},
|
|
115
109
|
disabled && { opacity: 0.4 },
|
|
116
110
|
]}
|
|
117
|
-
|
|
111
|
+
>
|
|
112
|
+
<TextInput
|
|
113
|
+
accessibilityLabel={typeof label === 'string' ? label : name}
|
|
114
|
+
accessibilityState={{ disabled }}
|
|
115
|
+
accessibilityHint={hint}
|
|
116
|
+
value={value}
|
|
117
|
+
defaultValue={defaultValue}
|
|
118
|
+
placeholder={placeholder}
|
|
119
|
+
placeholderTextColor={colors.colorOnBgSubtle}
|
|
120
|
+
keyboardType={keyboardTypeMap[type]}
|
|
121
|
+
secureTextEntry={type === 'password'}
|
|
122
|
+
autoCapitalize={type === 'email' || type === 'url' ? 'none' : 'sentences'}
|
|
123
|
+
autoComplete={type === 'email' ? 'email' : type === 'tel' ? 'tel' : undefined}
|
|
124
|
+
editable={!disabled}
|
|
125
|
+
onChangeText={onChange}
|
|
126
|
+
onFocus={() => {
|
|
127
|
+
setFocused(true);
|
|
128
|
+
onFocus?.();
|
|
129
|
+
}}
|
|
130
|
+
onBlur={() => {
|
|
131
|
+
setFocused(false);
|
|
132
|
+
onBlur?.();
|
|
133
|
+
}}
|
|
134
|
+
style={[
|
|
135
|
+
styles.input,
|
|
136
|
+
{ color: colors.colorOnBg },
|
|
137
|
+
]}
|
|
138
|
+
/>
|
|
139
|
+
</View>
|
|
118
140
|
<Message type="error" message={error} />
|
|
119
141
|
<Message type="hint" message={hint} />
|
|
120
142
|
</View>
|
|
@@ -60,4 +60,28 @@ describe('NumberInput (native)', () => {
|
|
|
60
60
|
expect(screen.getByRole('button', { name: 'Decrease' })).toBeDisabled();
|
|
61
61
|
expect(screen.getByRole('button', { name: 'Increase' })).toBeDisabled();
|
|
62
62
|
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Vertical-centering invariant: the TextInput must have no vertical padding so that
|
|
66
|
+
* iOS centers text automatically within the wrapper height.
|
|
67
|
+
*/
|
|
68
|
+
it('has paddingVertical: 0 on the TextInput (vertical centering)', () => {
|
|
69
|
+
renderWithTheme(<NumberInput label="Quantity" name="qty" value={3} />);
|
|
70
|
+
const input = screen.getByDisplayValue('3');
|
|
71
|
+
const style = getComputedStyle(input);
|
|
72
|
+
expect(parseFloat(style.paddingTop)).toBe(0);
|
|
73
|
+
expect(parseFloat(style.paddingBottom)).toBe(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Touch-target invariant: the wrapper View around the TextInput must provide
|
|
78
|
+
* the 44pt height (stepper height), replacing minHeight on the TextInput itself.
|
|
79
|
+
*/
|
|
80
|
+
it('has height of 44pt on the TextInput wrapper (touch target)', () => {
|
|
81
|
+
renderWithTheme(<NumberInput label="Quantity" name="qty" value={3} />);
|
|
82
|
+
const input = screen.getByDisplayValue('3');
|
|
83
|
+
const wrapper = input.parentElement as HTMLElement;
|
|
84
|
+
const heightPx = parseFloat(getComputedStyle(wrapper).height);
|
|
85
|
+
expect(heightPx).toBeGreaterThanOrEqual(44);
|
|
86
|
+
});
|
|
63
87
|
});
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
StyleProp,
|
|
9
9
|
ViewStyle,
|
|
10
10
|
} from 'react-native';
|
|
11
|
-
import { spacing, font, border
|
|
11
|
+
import { spacing, font, border } from '../../../../native/tokens';
|
|
12
12
|
import { useTheme } from '../../../../native/ThemeContext';
|
|
13
13
|
import { Label } from '../atoms/Label';
|
|
14
14
|
import { Message } from '../atoms/Message';
|
|
@@ -60,17 +60,30 @@ const styles = StyleSheet.create({
|
|
|
60
60
|
fontWeight: font.weight.semibold,
|
|
61
61
|
lineHeight: 24,
|
|
62
62
|
},
|
|
63
|
-
|
|
63
|
+
/**
|
|
64
|
+
* The inputWrapper holds the border and fixed height so justifyContent: 'center'
|
|
65
|
+
* vertically centers the TextInput. flex: 1 lets it fill the space between steppers.
|
|
66
|
+
*/
|
|
67
|
+
inputWrapper: {
|
|
64
68
|
flex: 1,
|
|
69
|
+
height: STEPPER_SIZE,
|
|
70
|
+
justifyContent: 'center',
|
|
71
|
+
borderTopWidth: 1,
|
|
72
|
+
borderBottomWidth: 1,
|
|
73
|
+
},
|
|
74
|
+
/**
|
|
75
|
+
* No vertical padding: iOS auto-centers within the wrapper height.
|
|
76
|
+
* textAlignVertical + includeFontPadding provide Android parity.
|
|
77
|
+
* No lineHeight: explicit lineHeight clips ascenders/descenders on iOS.
|
|
78
|
+
*/
|
|
79
|
+
input: {
|
|
65
80
|
fontFamily: font.family.body,
|
|
66
81
|
fontSize: font.size.body,
|
|
67
|
-
lineHeight: lineHeight.body,
|
|
68
82
|
textAlign: 'center',
|
|
69
83
|
paddingHorizontal: spacing.xs,
|
|
70
|
-
paddingVertical:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
minHeight: STEPPER_SIZE,
|
|
84
|
+
paddingVertical: 0,
|
|
85
|
+
textAlignVertical: 'center',
|
|
86
|
+
includeFontPadding: false,
|
|
74
87
|
},
|
|
75
88
|
});
|
|
76
89
|
|
|
@@ -139,32 +152,38 @@ export function NumberInput({
|
|
|
139
152
|
</Text>
|
|
140
153
|
</Pressable>
|
|
141
154
|
|
|
142
|
-
<
|
|
143
|
-
accessibilityLabel={label}
|
|
144
|
-
accessibilityState={{ disabled }}
|
|
145
|
-
accessibilityValue={{
|
|
146
|
-
min,
|
|
147
|
-
max,
|
|
148
|
-
now: currentValue,
|
|
149
|
-
text: String(currentValue),
|
|
150
|
-
}}
|
|
151
|
-
keyboardType="numeric"
|
|
152
|
-
value={String(currentValue)}
|
|
153
|
-
editable={!disabled}
|
|
154
|
-
onChangeText={(text) => {
|
|
155
|
-
const parsed = parseFloat(text);
|
|
156
|
-
const safe = isNaN(parsed) ? 0 : parsed;
|
|
157
|
-
update(safe);
|
|
158
|
-
}}
|
|
155
|
+
<View
|
|
159
156
|
style={[
|
|
160
|
-
styles.
|
|
157
|
+
styles.inputWrapper,
|
|
161
158
|
{
|
|
162
159
|
borderColor,
|
|
163
|
-
color: colors.colorOnBg,
|
|
164
160
|
backgroundColor: colors.colorBg,
|
|
165
161
|
},
|
|
166
162
|
]}
|
|
167
|
-
|
|
163
|
+
>
|
|
164
|
+
<TextInput
|
|
165
|
+
accessibilityLabel={label}
|
|
166
|
+
accessibilityState={{ disabled }}
|
|
167
|
+
accessibilityValue={{
|
|
168
|
+
min,
|
|
169
|
+
max,
|
|
170
|
+
now: currentValue,
|
|
171
|
+
text: String(currentValue),
|
|
172
|
+
}}
|
|
173
|
+
keyboardType="numeric"
|
|
174
|
+
value={String(currentValue)}
|
|
175
|
+
editable={!disabled}
|
|
176
|
+
onChangeText={(text) => {
|
|
177
|
+
const parsed = parseFloat(text);
|
|
178
|
+
const safe = isNaN(parsed) ? 0 : parsed;
|
|
179
|
+
update(safe);
|
|
180
|
+
}}
|
|
181
|
+
style={[
|
|
182
|
+
styles.input,
|
|
183
|
+
{ color: colors.colorOnBg },
|
|
184
|
+
]}
|
|
185
|
+
/>
|
|
186
|
+
</View>
|
|
168
187
|
|
|
169
188
|
<Pressable
|
|
170
189
|
accessibilityRole="button"
|