@boostdev/design-system-react-native 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2635,24 +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
- // No lineHeight on single-line TextInput: iOS clips descenders/ascenders when
2650
- // lineHeight is set explicitly. minHeight: 44 satisfies the touch-target requirement.
2651
- paddingVertical: spacing.xs,
2652
2664
  paddingHorizontal: spacing.s,
2653
- borderWidth: 1,
2654
- borderRadius: border.radius.full,
2655
- minHeight: 44
2665
+ paddingVertical: 0,
2666
+ textAlignVertical: "center",
2667
+ includeFontPadding: false
2656
2668
  },
2657
2669
  dropdown: {
2658
2670
  position: "absolute",
@@ -2721,80 +2733,88 @@ function Combobox({
2721
2733
  const borderColor = error ? colors2.colorError : colors2.colorOnBg;
2722
2734
  return /* @__PURE__ */ jsxs23(View30, { style: [styles33.container, style], children: [
2723
2735
  /* @__PURE__ */ jsx37(Label, { label }),
2724
- /* @__PURE__ */ jsxs23(View30, { style: styles33.inputWrapper, children: [
2725
- /* @__PURE__ */ jsx37(
2726
- TextInput,
2727
- {
2728
- value: inputValue,
2729
- onChangeText: handleChangeText,
2730
- onFocus: handleFocus,
2731
- onBlur: handleBlur,
2732
- placeholder,
2733
- placeholderTextColor: colors2.colorOnBgSubtle,
2734
- editable: !disabled,
2735
- accessibilityRole: "combobox",
2736
- accessibilityLabel: typeof label === "string" ? label : name,
2737
- accessibilityState: { disabled, expanded: isOpen },
2738
- style: [
2739
- styles33.input,
2740
- {
2741
- borderColor,
2742
- color: colors2.colorOnBg,
2743
- backgroundColor: colors2.colorBg
2744
- },
2745
- disabled && { opacity: 0.4 }
2746
- ]
2747
- }
2748
- ),
2749
- isOpen && filtered.length > 0 && /* @__PURE__ */ jsx37(
2750
- ScrollView4,
2751
- {
2752
- style: [
2753
- 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,
2754
2750
  {
2755
- borderColor: colors2.colorBgSubtle,
2756
- 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
+ ]
2757
2765
  }
2758
- ],
2759
- keyboardShouldPersistTaps: "handled",
2760
- nestedScrollEnabled: true,
2761
- children: filtered.map((option) => {
2762
- const isSelected = option.value === value;
2763
- const isDisabled = option.disabled;
2764
- return /* @__PURE__ */ jsx37(
2765
- Pressable18,
2766
- {
2767
- onPress: () => !isDisabled && selectOption(option),
2768
- disabled: isDisabled,
2769
- accessibilityRole: "menuitem",
2770
- accessibilityLabel: option.label,
2771
- accessibilityState: { selected: isSelected, disabled: isDisabled },
2772
- style: ({ pressed }) => [
2773
- styles33.option,
2774
- isSelected && { backgroundColor: colors2.colorBlueSubtle },
2775
- pressed && !isDisabled && { backgroundColor: colors2.colorBgSubtle },
2776
- isDisabled && { opacity: 0.4 }
2777
- ],
2778
- children: /* @__PURE__ */ jsx37(
2779
- 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,
2780
2784
  {
2781
- style: [
2782
- styles33.optionText,
2783
- {
2784
- color: isSelected ? colors2.colorInteractive : colors2.colorOnBg
2785
- },
2786
- 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 }
2787
2795
  ],
2788
- children: option.label
2789
- }
2790
- )
2791
- },
2792
- option.value
2793
- );
2794
- })
2795
- }
2796
- )
2797
- ] }),
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
+ ),
2798
2818
  /* @__PURE__ */ jsx37(Message, { type: "error", message: error }),
2799
2819
  /* @__PURE__ */ jsx37(Message, { type: "hint", message: hint })
2800
2820
  ] });
@@ -2804,22 +2824,32 @@ function Combobox({
2804
2824
  import { Children } from "react";
2805
2825
  import { StyleSheet as StyleSheet34, View as View31, Pressable as Pressable19, Text as Text28 } from "react-native";
2806
2826
  import { jsx as jsx38 } from "react/jsx-runtime";
2827
+ var TRACK_PADDING = spacing.xxxs;
2807
2828
  var sizeTokens2 = {
2808
- small: { height: 32, fontSize: font.size.bodyS, px: spacing.s },
2809
- medium: { height: 40, fontSize: font.size.body, px: spacing.m },
2810
- large: { height: 48, fontSize: font.size.body, px: spacing.l }
2829
+ small: { minHeight: 32, fontSize: font.size.bodyS, px: spacing.s, py: spacing.xxs },
2830
+ medium: { minHeight: 44, fontSize: font.size.body, px: spacing.m, py: spacing.xs },
2831
+ large: { minHeight: 48, fontSize: font.size.body, px: spacing.m, py: spacing.s }
2832
+ };
2833
+ var thumbShadow = {
2834
+ shadowColor: "#000",
2835
+ shadowOffset: { width: 0, height: 1 },
2836
+ shadowOpacity: 0.12,
2837
+ shadowRadius: 2,
2838
+ elevation: 2
2811
2839
  };
2812
2840
  var styles34 = StyleSheet34.create({
2813
2841
  track: {
2814
2842
  flexDirection: "row",
2815
2843
  borderRadius: border.radius.full,
2844
+ padding: TRACK_PADDING,
2816
2845
  overflow: "hidden"
2817
2846
  },
2818
- item: {
2847
+ segment: {
2819
2848
  flex: 1,
2820
2849
  alignItems: "center",
2821
2850
  justifyContent: "center",
2822
- minHeight: 44
2851
+ // Thumb radius = track radius minus the padding gap (mirrors thumb-radius calc in web CSS)
2852
+ borderRadius: border.radius.full - TRACK_PADDING
2823
2853
  },
2824
2854
  label: {
2825
2855
  fontFamily: font.family.body,
@@ -2838,21 +2868,26 @@ function SegmentedControl({
2838
2868
  style
2839
2869
  }) {
2840
2870
  const { colors: colors2 } = useTheme();
2841
- const { height, fontSize, px } = sizeTokens2[size];
2871
+ const { minHeight, fontSize, px, py } = sizeTokens2[size];
2842
2872
  const items = Children.toArray(children);
2843
- const trackStyle = variant === "filled" ? { backgroundColor: colors2.colorBgSubtle } : { backgroundColor: "transparent", borderWidth: 1, borderColor: colors2.colorBgSubtle };
2873
+ const trackBg = colors2.colorBgSubtle;
2874
+ const thumbBg = variant === "outline" ? colors2.colorBg : colors2.colorInteractive;
2844
2875
  return /* @__PURE__ */ jsx38(
2845
2876
  View31,
2846
2877
  {
2847
- style: [styles34.track, trackStyle, { borderRadius: border.radius.full }, style],
2878
+ style: [styles34.track, { backgroundColor: trackBg }, style],
2848
2879
  accessibilityRole: "tablist",
2849
2880
  accessibilityLabel,
2850
2881
  children: items.map((child, index) => {
2851
2882
  const isActive = index === selectedIndex;
2852
- const activeStyle = variant === "filled" ? { backgroundColor: colors2.colorInteractive } : { borderWidth: 2, borderColor: colors2.colorInteractive };
2853
- const inactiveStyle = variant === "filled" ? {} : {};
2854
- const textColor = variant === "filled" && isActive ? colors2.colorOnInteractive : isActive ? colors2.colorInteractive : colors2.colorOnBgSubtle;
2883
+ const labelColor = isActive ? variant === "outline" ? colors2.colorInteractiveOnBg : colors2.colorOnInteractive : colors2.colorOnBgSubtle;
2855
2884
  const label = typeof child === "string" || typeof child === "number" ? String(child) : null;
2885
+ const segmentStyle = {
2886
+ minHeight,
2887
+ paddingHorizontal: px,
2888
+ paddingVertical: py,
2889
+ ...isActive ? { backgroundColor: thumbBg, ...thumbShadow } : { backgroundColor: "transparent" }
2890
+ };
2856
2891
  return /* @__PURE__ */ jsx38(
2857
2892
  Pressable19,
2858
2893
  {
@@ -2862,14 +2897,12 @@ function SegmentedControl({
2862
2897
  accessibilityLabel: label ?? void 0,
2863
2898
  accessibilityState: { selected: isActive, disabled },
2864
2899
  style: ({ pressed }) => [
2865
- styles34.item,
2866
- { height, paddingHorizontal: px },
2867
- isActive ? activeStyle : inactiveStyle,
2868
- variant === "filled" && isActive && { borderRadius: border.radius.full },
2900
+ styles34.segment,
2901
+ segmentStyle,
2869
2902
  pressed && !disabled && { opacity: 0.7 },
2870
2903
  disabled && { opacity: 0.4 }
2871
2904
  ],
2872
- children: label !== null ? /* @__PURE__ */ jsx38(Text28, { style: [styles34.label, { fontSize, color: textColor }], children: label }) : child
2905
+ children: label !== null ? /* @__PURE__ */ jsx38(Text28, { style: [styles34.label, { fontSize, color: labelColor }], children: label }) : child
2873
2906
  },
2874
2907
  index
2875
2908
  );
@@ -2886,6 +2919,7 @@ import {
2886
2919
  TextInput as TextInput2
2887
2920
  } from "react-native";
2888
2921
  import { jsx as jsx39, jsxs as jsxs24 } from "react/jsx-runtime";
2922
+ var INPUT_HEIGHT2 = 44;
2889
2923
  var keyboardTypeMap = {
2890
2924
  text: "default",
2891
2925
  email: "email-address",
@@ -2899,16 +2933,28 @@ var styles35 = StyleSheet35.create({
2899
2933
  container: {
2900
2934
  gap: spacing.xxs
2901
2935
  },
2936
+ /**
2937
+ * The wrapper draws the pill border and provides the fixed 44pt touch-target height.
2938
+ * justifyContent: 'center' vertically centers the inner TextInput on both iOS and Android.
2939
+ */
2940
+ inputWrapper: {
2941
+ height: INPUT_HEIGHT2,
2942
+ justifyContent: "center",
2943
+ borderWidth: 1,
2944
+ borderRadius: border.radius.full
2945
+ },
2946
+ /**
2947
+ * No vertical padding: iOS centers text automatically inside the wrapper height.
2948
+ * textAlignVertical + includeFontPadding are Android-specific but harmless on iOS.
2949
+ * No lineHeight: explicit lineHeight on a single-line TextInput clips ascenders/descenders on iOS.
2950
+ */
2902
2951
  input: {
2903
2952
  fontFamily: font.family.body,
2904
2953
  fontSize: font.size.body,
2905
- // No lineHeight on single-line TextInput: iOS clips descenders/ascenders when
2906
- // lineHeight is set explicitly. minHeight: 44 satisfies the touch-target requirement.
2907
2954
  paddingHorizontal: spacing.s,
2908
- paddingVertical: spacing.xs,
2909
- borderWidth: 1,
2910
- borderRadius: border.radius.full,
2911
- minHeight: 44
2955
+ paddingVertical: 0,
2956
+ textAlignVertical: "center",
2957
+ includeFontPadding: false
2912
2958
  }
2913
2959
  });
2914
2960
  function FormInput({
@@ -2932,38 +2978,46 @@ function FormInput({
2932
2978
  return /* @__PURE__ */ jsxs24(View32, { style: [styles35.container, style], children: [
2933
2979
  /* @__PURE__ */ jsx39(Label, { label }),
2934
2980
  /* @__PURE__ */ jsx39(
2935
- TextInput2,
2981
+ View32,
2936
2982
  {
2937
- accessibilityLabel: typeof label === "string" ? label : name,
2938
- accessibilityState: { disabled },
2939
- accessibilityHint: hint,
2940
- value,
2941
- defaultValue,
2942
- placeholder,
2943
- placeholderTextColor: colors2.colorOnBgSubtle,
2944
- keyboardType: keyboardTypeMap[type],
2945
- secureTextEntry: type === "password",
2946
- autoCapitalize: type === "email" || type === "url" ? "none" : "sentences",
2947
- autoComplete: type === "email" ? "email" : type === "tel" ? "tel" : void 0,
2948
- editable: !disabled,
2949
- onChangeText: onChange,
2950
- onFocus: () => {
2951
- setFocused(true);
2952
- onFocus?.();
2953
- },
2954
- onBlur: () => {
2955
- setFocused(false);
2956
- onBlur?.();
2957
- },
2958
2983
  style: [
2959
- styles35.input,
2984
+ styles35.inputWrapper,
2960
2985
  {
2961
2986
  borderColor,
2962
- color: colors2.colorOnBg,
2963
2987
  backgroundColor: colors2.colorBg
2964
2988
  },
2965
2989
  disabled && { opacity: 0.4 }
2966
- ]
2990
+ ],
2991
+ children: /* @__PURE__ */ jsx39(
2992
+ TextInput2,
2993
+ {
2994
+ accessibilityLabel: typeof label === "string" ? label : name,
2995
+ accessibilityState: { disabled },
2996
+ accessibilityHint: hint,
2997
+ value,
2998
+ defaultValue,
2999
+ placeholder,
3000
+ placeholderTextColor: colors2.colorOnBgSubtle,
3001
+ keyboardType: keyboardTypeMap[type],
3002
+ secureTextEntry: type === "password",
3003
+ autoCapitalize: type === "email" || type === "url" ? "none" : "sentences",
3004
+ autoComplete: type === "email" ? "email" : type === "tel" ? "tel" : void 0,
3005
+ editable: !disabled,
3006
+ onChangeText: onChange,
3007
+ onFocus: () => {
3008
+ setFocused(true);
3009
+ onFocus?.();
3010
+ },
3011
+ onBlur: () => {
3012
+ setFocused(false);
3013
+ onBlur?.();
3014
+ },
3015
+ style: [
3016
+ styles35.input,
3017
+ { color: colors2.colorOnBg }
3018
+ ]
3019
+ }
3020
+ )
2967
3021
  }
2968
3022
  ),
2969
3023
  /* @__PURE__ */ jsx39(Message, { type: "error", message: error }),
@@ -3481,18 +3535,30 @@ var styles39 = StyleSheet39.create({
3481
3535
  fontWeight: font.weight.semibold,
3482
3536
  lineHeight: 24
3483
3537
  },
3484
- input: {
3538
+ /**
3539
+ * The inputWrapper holds the border and fixed height so justifyContent: 'center'
3540
+ * vertically centers the TextInput. flex: 1 lets it fill the space between steppers.
3541
+ */
3542
+ inputWrapper: {
3485
3543
  flex: 1,
3544
+ height: STEPPER_SIZE,
3545
+ justifyContent: "center",
3546
+ borderTopWidth: 1,
3547
+ borderBottomWidth: 1
3548
+ },
3549
+ /**
3550
+ * No vertical padding: iOS auto-centers within the wrapper height.
3551
+ * textAlignVertical + includeFontPadding provide Android parity.
3552
+ * No lineHeight: explicit lineHeight clips ascenders/descenders on iOS.
3553
+ */
3554
+ input: {
3486
3555
  fontFamily: font.family.body,
3487
3556
  fontSize: font.size.body,
3488
- // No lineHeight on single-line TextInput: iOS clips descenders/ascenders when
3489
- // lineHeight is set explicitly. minHeight: STEPPER_SIZE (44) covers the touch target.
3490
3557
  textAlign: "center",
3491
3558
  paddingHorizontal: spacing.xs,
3492
- paddingVertical: spacing.xs,
3493
- borderTopWidth: 1,
3494
- borderBottomWidth: 1,
3495
- minHeight: STEPPER_SIZE
3559
+ paddingVertical: 0,
3560
+ textAlignVertical: "center",
3561
+ includeFontPadding: false
3496
3562
  }
3497
3563
  });
3498
3564
  function NumberInput({
@@ -3558,32 +3624,40 @@ function NumberInput({
3558
3624
  }
3559
3625
  ),
3560
3626
  /* @__PURE__ */ jsx43(
3561
- TextInput4,
3627
+ View36,
3562
3628
  {
3563
- accessibilityLabel: label,
3564
- accessibilityState: { disabled },
3565
- accessibilityValue: {
3566
- min,
3567
- max,
3568
- now: currentValue,
3569
- text: String(currentValue)
3570
- },
3571
- keyboardType: "numeric",
3572
- value: String(currentValue),
3573
- editable: !disabled,
3574
- onChangeText: (text) => {
3575
- const parsed = parseFloat(text);
3576
- const safe = isNaN(parsed) ? 0 : parsed;
3577
- update(safe);
3578
- },
3579
3629
  style: [
3580
- styles39.input,
3630
+ styles39.inputWrapper,
3581
3631
  {
3582
3632
  borderColor,
3583
- color: colors2.colorOnBg,
3584
3633
  backgroundColor: colors2.colorBg
3585
3634
  }
3586
- ]
3635
+ ],
3636
+ children: /* @__PURE__ */ jsx43(
3637
+ TextInput4,
3638
+ {
3639
+ accessibilityLabel: label,
3640
+ accessibilityState: { disabled },
3641
+ accessibilityValue: {
3642
+ min,
3643
+ max,
3644
+ now: currentValue,
3645
+ text: String(currentValue)
3646
+ },
3647
+ keyboardType: "numeric",
3648
+ value: String(currentValue),
3649
+ editable: !disabled,
3650
+ onChangeText: (text) => {
3651
+ const parsed = parseFloat(text);
3652
+ const safe = isNaN(parsed) ? 0 : parsed;
3653
+ update(safe);
3654
+ },
3655
+ style: [
3656
+ styles39.input,
3657
+ { color: colors2.colorOnBg }
3658
+ ]
3659
+ }
3660
+ )
3587
3661
  }
3588
3662
  ),
3589
3663
  /* @__PURE__ */ jsx43(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boostdev/design-system-react-native",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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
  });
@@ -10,6 +10,9 @@ import {
10
10
  ViewStyle,
11
11
  } from 'react-native';
12
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,19 +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
- // No lineHeight on single-line TextInput: iOS clips descenders/ascenders when
49
- // lineHeight is set explicitly. minHeight: 44 satisfies the touch-target requirement.
50
- paddingVertical: spacing.xs,
51
65
  paddingHorizontal: spacing.s,
52
- borderWidth: 1,
53
- borderRadius: border.radius.full,
54
- minHeight: 44,
66
+ paddingVertical: 0,
67
+ textAlignVertical: 'center',
68
+ includeFontPadding: false,
55
69
  },
56
70
  dropdown: {
57
71
  position: 'absolute',
@@ -133,7 +147,16 @@ export function Combobox({
133
147
  return (
134
148
  <View style={[styles.container, style]}>
135
149
  <Label label={label} />
136
- <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
+ >
137
160
  <TextInput
138
161
  value={inputValue}
139
162
  onChangeText={handleChangeText}
@@ -147,12 +170,7 @@ export function Combobox({
147
170
  accessibilityState={{ disabled, expanded: isOpen }}
148
171
  style={[
149
172
  styles.input,
150
- {
151
- borderColor,
152
- color: colors.colorOnBg,
153
- backgroundColor: colors.colorBg,
154
- },
155
- disabled && { opacity: 0.4 },
173
+ { color: colors.colorOnBg },
156
174
  ]}
157
175
  />
158
176
  {isOpen && filtered.length > 0 && (
@@ -54,8 +54,8 @@ describe('FormInput (native)', () => {
54
54
  *
55
55
  * On iOS, an explicit lineHeight on a single-line TextInput causes the text to
56
56
  * render vertically clipped — descenders and ascenders are cut off at the line box
57
- * boundary. The minHeight: 44 token satisfies the touch-target requirement without
58
- * needing lineHeight. This test pins that invariant so it can't silently regress.
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
59
  *
60
60
  * react-native-web applies token-based styles as atomic CSS classes. getComputedStyle
61
61
  * resolves those classes in jsdom and returns "normal" when lineHeight is absent.
@@ -68,14 +68,31 @@ describe('FormInput (native)', () => {
68
68
  });
69
69
 
70
70
  /**
71
- * Touch-target invariant: single-line input must meet the 44pt minimum touch target.
72
- * This is the replacement constraint that makes lineHeight safe to omit.
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.
73
74
  */
74
- it('has minHeight of at least 44pt on the TextInput', () => {
75
+ it('has paddingVertical: 0 on the TextInput (vertical centering)', () => {
75
76
  renderWithTheme(<FormInput label="Email" name="email" />);
76
77
  const input = screen.getByRole('textbox');
77
- // getComputedStyle resolves the atomic CSS class to a pixel value.
78
- const minHeightPx = parseFloat(getComputedStyle(input).minHeight);
79
- expect(minHeightPx).toBeGreaterThanOrEqual(44);
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;
80
97
  });
81
98
  });