@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 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
- borderWidth: 1,
2700
- borderRadius: import_native.border.radius.full,
2701
- minHeight: 44
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)(import_react_native37.View, { style: styles33.inputWrapper, children: [
2771
- /* @__PURE__ */ (0, import_jsx_runtime37.jsx)(
2772
- import_react_native37.TextInput,
2773
- {
2774
- value: inputValue,
2775
- onChangeText: handleChangeText,
2776
- onFocus: handleFocus,
2777
- onBlur: handleBlur,
2778
- placeholder,
2779
- placeholderTextColor: colors2.colorOnBgSubtle,
2780
- editable: !disabled,
2781
- accessibilityRole: "combobox",
2782
- accessibilityLabel: typeof label === "string" ? label : name,
2783
- accessibilityState: { disabled, expanded: isOpen },
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
- borderColor: colors2.colorBgSubtle,
2802
- backgroundColor: colors2.colorBg
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
- keyboardShouldPersistTaps: "handled",
2806
- nestedScrollEnabled: true,
2807
- children: filtered.map((option) => {
2808
- const isSelected = option.value === value;
2809
- const isDisabled = option.disabled;
2810
- return /* @__PURE__ */ (0, import_jsx_runtime37.jsx)(
2811
- import_react_native37.Pressable,
2812
- {
2813
- onPress: () => !isDisabled && selectOption(option),
2814
- disabled: isDisabled,
2815
- accessibilityRole: "menuitem",
2816
- accessibilityLabel: option.label,
2817
- accessibilityState: { selected: isSelected, disabled: isDisabled },
2818
- style: ({ pressed }) => [
2819
- styles33.option,
2820
- isSelected && { backgroundColor: colors2.colorBlueSubtle },
2821
- pressed && !isDisabled && { backgroundColor: colors2.colorBgSubtle },
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
- style: [
2828
- styles33.optionText,
2829
- {
2830
- color: isSelected ? colors2.colorInteractive : colors2.colorOnBg
2831
- },
2832
- isSelected && { fontWeight: import_native.font.weight.semibold }
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: option.label
2835
- }
2836
- )
2837
- },
2838
- option.value
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: import_native.spacing.xs,
2950
- borderWidth: 1,
2951
- borderRadius: import_native.border.radius.full,
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.TextInput,
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.input,
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
- input: {
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: import_native.spacing.xs,
3511
- borderTopWidth: 1,
3512
- borderBottomWidth: 1,
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.TextInput,
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.input,
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
- borderWidth: 1,
2653
- borderRadius: border.radius.full,
2654
- minHeight: 44
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(View30, { style: styles33.inputWrapper, children: [
2724
- /* @__PURE__ */ jsx37(
2725
- TextInput,
2726
- {
2727
- value: inputValue,
2728
- onChangeText: handleChangeText,
2729
- onFocus: handleFocus,
2730
- onBlur: handleBlur,
2731
- placeholder,
2732
- placeholderTextColor: colors2.colorOnBgSubtle,
2733
- editable: !disabled,
2734
- accessibilityRole: "combobox",
2735
- accessibilityLabel: typeof label === "string" ? label : name,
2736
- accessibilityState: { disabled, expanded: isOpen },
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
- borderColor: colors2.colorBgSubtle,
2755
- backgroundColor: colors2.colorBg
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
- keyboardShouldPersistTaps: "handled",
2759
- nestedScrollEnabled: true,
2760
- children: filtered.map((option) => {
2761
- const isSelected = option.value === value;
2762
- const isDisabled = option.disabled;
2763
- return /* @__PURE__ */ jsx37(
2764
- Pressable18,
2765
- {
2766
- onPress: () => !isDisabled && selectOption(option),
2767
- disabled: isDisabled,
2768
- accessibilityRole: "menuitem",
2769
- accessibilityLabel: option.label,
2770
- accessibilityState: { selected: isSelected, disabled: isDisabled },
2771
- style: ({ pressed }) => [
2772
- styles33.option,
2773
- isSelected && { backgroundColor: colors2.colorBlueSubtle },
2774
- pressed && !isDisabled && { backgroundColor: colors2.colorBgSubtle },
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
- style: [
2781
- styles33.optionText,
2782
- {
2783
- color: isSelected ? colors2.colorInteractive : colors2.colorOnBg
2784
- },
2785
- isSelected && { fontWeight: font.weight.semibold }
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: option.label
2788
- }
2789
- )
2790
- },
2791
- option.value
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: spacing.xs,
2907
- borderWidth: 1,
2908
- borderRadius: border.radius.full,
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
- TextInput2,
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.input,
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
- input: {
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: spacing.xs,
3490
- borderTopWidth: 1,
3491
- borderBottomWidth: 1,
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
- TextInput4,
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.input,
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.0",
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
- borderWidth: 1,
52
- borderRadius: border.radius.full,
53
- minHeight: 44,
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 style={styles.inputWrapper}>
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, lineHeight } from '../../../../native/tokens';
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: spacing.xs,
53
- borderWidth: 1,
54
- borderRadius: border.radius.full,
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
- <TextInput
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.input,
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, lineHeight } from '../../../../native/tokens';
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
- input: {
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: spacing.xs,
71
- borderTopWidth: 1,
72
- borderBottomWidth: 1,
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
- <TextInput
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.input,
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"