@boostdev/design-system-react-native 0.2.2 → 0.2.4

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,54 @@
1
1
  # Changelog
2
2
 
3
3
  All notable changes to `@boostdev/design-system-react-native` are documented here.
4
+ ## [0.2.4] - 2026-06-11
5
+ ### Fixed
6
+ - Fix(form): optical vertical centering for single-line input text
7
+
8
+ Arial's asymmetric metrics (ascent 0.905em, descent 0.212em) place ink
9
+ ~0.08em below the layout-box centre on iOS. Add OPTICAL_TEXT_SHIFT = -1.5
10
+ constant to form/atoms/inputHelpers.ts (single source of truth) and apply
11
+ transform: [{translateY: OPTICAL_TEXT_SHIFT}] to the single-line TextInput
12
+ in FormInput, NumberInput, and Combobox. Verified on-simulator: shifts ink
13
+ from +1.33pt to -0.33pt at fontSize 16.
14
+
15
+ Textarea (multiline) is unchanged.
16
+
17
+ Spec invariants extended: pin translateY shift alongside existing
18
+ no-lineHeight, paddingVertical-0, and wrapper-height-≥44 assertions. ([`a3e08e5`](https://gitlab.com/b00stdev/boostdev-design-system-react-native/-/commit/a3e08e5a6f4c53ef12ba810ef96d395e4ee116e7))
19
+
20
+ ## [0.2.3] - 2026-06-11
21
+ ### Fixed
22
+ - Fix(segmented-control): proper track + thumb rendering
23
+
24
+ Replace per-segment outline/border approach with a single rounded track
25
+ (colorBgSubtle background, 2 px inner padding) containing equal-width
26
+ segments. Selected segment renders a contrasting thumb: colorBg + shadow
27
+ for the outline variant (white elevated surface), colorInteractive for
28
+ the filled variant. No per-segment borders; no stray focus rectangle.
29
+
30
+ Mirrors the web DS visual contract (track bg, thumb bg, thumb radius =
31
+ track radius minus padding). Dark mode via useTheme throughout.
32
+ All accessibilityRole/accessibilityState props preserved.
33
+ Spec updated to pin track-has-one-background and thumb structure. ([`2a83f88`](https://gitlab.com/b00stdev/boostdev-design-system-react-native/-/commit/2a83f8804553e390528fc85dd79c1541bf92343e))
34
+
35
+ ## [0.2.2] - 2026-06-11
36
+ ### Fixed
37
+ - Fix(form): vertically center text in single-line inputs
38
+
39
+ Replace minHeight + paddingVertical on TextInput with a wrapper View
40
+ (height: 44, justifyContent: center) + paddingVertical: 0 on the inner
41
+ TextInput. iOS auto-centers text within the wrapper height; paddingVertical
42
+ on the TextInput itself caused text to sit off-center after the 0.2.1
43
+ lineHeight removal. Added textAlignVertical: center and includeFontPadding:
44
+ false for Android parity.
45
+
46
+ Applied consistently to FormInput, NumberInput, and Combobox.
47
+ Textarea (multiline) is unaffected.
48
+
49
+ Spec invariants updated: paddingVertical === 0 on TextInput, wrapper
50
+ height >= 44 replace the previous minHeight >= 44 assertion. ([`437b16d`](https://gitlab.com/b00stdev/boostdev-design-system-react-native/-/commit/437b16dccb60b3b3948c84519990c18688e51f71))
51
+
4
52
  ## [0.2.1] - 2026-06-11
5
53
  ### Fixed
6
54
  - Fix(form): prevent iOS text clipping in single-line inputs
package/dist/index.cjs CHANGED
@@ -2681,6 +2681,11 @@ function FileInput({
2681
2681
  // src/components/interaction/form/Combobox/Combobox.tsx
2682
2682
  var import_react9 = require("react");
2683
2683
  var import_react_native37 = require("react-native");
2684
+
2685
+ // src/components/interaction/form/atoms/inputHelpers.ts
2686
+ var OPTICAL_TEXT_SHIFT = -1.5;
2687
+
2688
+ // src/components/interaction/form/Combobox/Combobox.tsx
2684
2689
  var import_jsx_runtime37 = require("react/jsx-runtime");
2685
2690
  var INPUT_HEIGHT = 44;
2686
2691
  var MAX_DROPDOWN_HEIGHT = 200;
@@ -2704,6 +2709,7 @@ var styles33 = import_react_native37.StyleSheet.create({
2704
2709
  * No vertical padding: iOS centers text automatically inside the wrapper height.
2705
2710
  * textAlignVertical + includeFontPadding provide Android parity.
2706
2711
  * No lineHeight: explicit lineHeight clips ascenders/descenders on iOS.
2712
+ * transform translateY: optical correction for Arial's asymmetric metrics — see OPTICAL_TEXT_SHIFT.
2707
2713
  */
2708
2714
  input: {
2709
2715
  fontFamily: import_native.font.family.body,
@@ -2711,7 +2717,8 @@ var styles33 = import_react_native37.StyleSheet.create({
2711
2717
  paddingHorizontal: import_native.spacing.s,
2712
2718
  paddingVertical: 0,
2713
2719
  textAlignVertical: "center",
2714
- includeFontPadding: false
2720
+ includeFontPadding: false,
2721
+ transform: [{ translateY: OPTICAL_TEXT_SHIFT }]
2715
2722
  },
2716
2723
  dropdown: {
2717
2724
  position: "absolute",
@@ -2871,22 +2878,32 @@ function Combobox({
2871
2878
  var import_react10 = require("react");
2872
2879
  var import_react_native38 = require("react-native");
2873
2880
  var import_jsx_runtime38 = require("react/jsx-runtime");
2881
+ var TRACK_PADDING = import_native.spacing.xxxs;
2874
2882
  var sizeTokens2 = {
2875
- small: { height: 32, fontSize: import_native.font.size.bodyS, px: import_native.spacing.s },
2876
- medium: { height: 40, fontSize: import_native.font.size.body, px: import_native.spacing.m },
2877
- large: { height: 48, fontSize: import_native.font.size.body, px: import_native.spacing.l }
2883
+ small: { minHeight: 32, fontSize: import_native.font.size.bodyS, px: import_native.spacing.s, py: import_native.spacing.xxs },
2884
+ medium: { minHeight: 44, fontSize: import_native.font.size.body, px: import_native.spacing.m, py: import_native.spacing.xs },
2885
+ large: { minHeight: 48, fontSize: import_native.font.size.body, px: import_native.spacing.m, py: import_native.spacing.s }
2886
+ };
2887
+ var thumbShadow = {
2888
+ shadowColor: "#000",
2889
+ shadowOffset: { width: 0, height: 1 },
2890
+ shadowOpacity: 0.12,
2891
+ shadowRadius: 2,
2892
+ elevation: 2
2878
2893
  };
2879
2894
  var styles34 = import_react_native38.StyleSheet.create({
2880
2895
  track: {
2881
2896
  flexDirection: "row",
2882
2897
  borderRadius: import_native.border.radius.full,
2898
+ padding: TRACK_PADDING,
2883
2899
  overflow: "hidden"
2884
2900
  },
2885
- item: {
2901
+ segment: {
2886
2902
  flex: 1,
2887
2903
  alignItems: "center",
2888
2904
  justifyContent: "center",
2889
- minHeight: 44
2905
+ // Thumb radius = track radius minus the padding gap (mirrors thumb-radius calc in web CSS)
2906
+ borderRadius: import_native.border.radius.full - TRACK_PADDING
2890
2907
  },
2891
2908
  label: {
2892
2909
  fontFamily: import_native.font.family.body,
@@ -2905,21 +2922,26 @@ function SegmentedControl({
2905
2922
  style
2906
2923
  }) {
2907
2924
  const { colors: colors2 } = useTheme();
2908
- const { height, fontSize, px } = sizeTokens2[size];
2925
+ const { minHeight, fontSize, px, py } = sizeTokens2[size];
2909
2926
  const items = import_react10.Children.toArray(children);
2910
- const trackStyle = variant === "filled" ? { backgroundColor: colors2.colorBgSubtle } : { backgroundColor: "transparent", borderWidth: 1, borderColor: colors2.colorBgSubtle };
2927
+ const trackBg = colors2.colorBgSubtle;
2928
+ const thumbBg = variant === "outline" ? colors2.colorBg : colors2.colorInteractive;
2911
2929
  return /* @__PURE__ */ (0, import_jsx_runtime38.jsx)(
2912
2930
  import_react_native38.View,
2913
2931
  {
2914
- style: [styles34.track, trackStyle, { borderRadius: import_native.border.radius.full }, style],
2932
+ style: [styles34.track, { backgroundColor: trackBg }, style],
2915
2933
  accessibilityRole: "tablist",
2916
2934
  accessibilityLabel,
2917
2935
  children: items.map((child, index) => {
2918
2936
  const isActive = index === selectedIndex;
2919
- const activeStyle = variant === "filled" ? { backgroundColor: colors2.colorInteractive } : { borderWidth: 2, borderColor: colors2.colorInteractive };
2920
- const inactiveStyle = variant === "filled" ? {} : {};
2921
- const textColor = variant === "filled" && isActive ? colors2.colorOnInteractive : isActive ? colors2.colorInteractive : colors2.colorOnBgSubtle;
2937
+ const labelColor = isActive ? variant === "outline" ? colors2.colorInteractiveOnBg : colors2.colorOnInteractive : colors2.colorOnBgSubtle;
2922
2938
  const label = typeof child === "string" || typeof child === "number" ? String(child) : null;
2939
+ const segmentStyle = {
2940
+ minHeight,
2941
+ paddingHorizontal: px,
2942
+ paddingVertical: py,
2943
+ ...isActive ? { backgroundColor: thumbBg, ...thumbShadow } : { backgroundColor: "transparent" }
2944
+ };
2923
2945
  return /* @__PURE__ */ (0, import_jsx_runtime38.jsx)(
2924
2946
  import_react_native38.Pressable,
2925
2947
  {
@@ -2929,14 +2951,12 @@ function SegmentedControl({
2929
2951
  accessibilityLabel: label ?? void 0,
2930
2952
  accessibilityState: { selected: isActive, disabled },
2931
2953
  style: ({ pressed }) => [
2932
- styles34.item,
2933
- { height, paddingHorizontal: px },
2934
- isActive ? activeStyle : inactiveStyle,
2935
- variant === "filled" && isActive && { borderRadius: import_native.border.radius.full },
2954
+ styles34.segment,
2955
+ segmentStyle,
2936
2956
  pressed && !disabled && { opacity: 0.7 },
2937
2957
  disabled && { opacity: 0.4 }
2938
2958
  ],
2939
- children: label !== null ? /* @__PURE__ */ (0, import_jsx_runtime38.jsx)(import_react_native38.Text, { style: [styles34.label, { fontSize, color: textColor }], children: label }) : child
2959
+ children: label !== null ? /* @__PURE__ */ (0, import_jsx_runtime38.jsx)(import_react_native38.Text, { style: [styles34.label, { fontSize, color: labelColor }], children: label }) : child
2940
2960
  },
2941
2961
  index
2942
2962
  );
@@ -2977,6 +2997,7 @@ var styles35 = import_react_native39.StyleSheet.create({
2977
2997
  * No vertical padding: iOS centers text automatically inside the wrapper height.
2978
2998
  * textAlignVertical + includeFontPadding are Android-specific but harmless on iOS.
2979
2999
  * No lineHeight: explicit lineHeight on a single-line TextInput clips ascenders/descenders on iOS.
3000
+ * transform translateY: optical correction for Arial's asymmetric metrics — see OPTICAL_TEXT_SHIFT.
2980
3001
  */
2981
3002
  input: {
2982
3003
  fontFamily: import_native.font.family.body,
@@ -2984,7 +3005,8 @@ var styles35 = import_react_native39.StyleSheet.create({
2984
3005
  paddingHorizontal: import_native.spacing.s,
2985
3006
  paddingVertical: 0,
2986
3007
  textAlignVertical: "center",
2987
- includeFontPadding: false
3008
+ includeFontPadding: false,
3009
+ transform: [{ translateY: OPTICAL_TEXT_SHIFT }]
2988
3010
  }
2989
3011
  });
2990
3012
  function FormInput({
@@ -3558,6 +3580,7 @@ var styles39 = import_react_native43.StyleSheet.create({
3558
3580
  * No vertical padding: iOS auto-centers within the wrapper height.
3559
3581
  * textAlignVertical + includeFontPadding provide Android parity.
3560
3582
  * No lineHeight: explicit lineHeight clips ascenders/descenders on iOS.
3583
+ * transform translateY: optical correction for Arial's asymmetric metrics — see OPTICAL_TEXT_SHIFT.
3561
3584
  */
3562
3585
  input: {
3563
3586
  fontFamily: import_native.font.family.body,
@@ -3566,7 +3589,8 @@ var styles39 = import_react_native43.StyleSheet.create({
3566
3589
  paddingHorizontal: import_native.spacing.xs,
3567
3590
  paddingVertical: 0,
3568
3591
  textAlignVertical: "center",
3569
- includeFontPadding: false
3592
+ includeFontPadding: false,
3593
+ transform: [{ translateY: OPTICAL_TEXT_SHIFT }]
3570
3594
  }
3571
3595
  });
3572
3596
  function NumberInput({
package/dist/index.js CHANGED
@@ -2634,6 +2634,11 @@ import {
2634
2634
  Text as Text27,
2635
2635
  ScrollView as ScrollView4
2636
2636
  } from "react-native";
2637
+
2638
+ // src/components/interaction/form/atoms/inputHelpers.ts
2639
+ var OPTICAL_TEXT_SHIFT = -1.5;
2640
+
2641
+ // src/components/interaction/form/Combobox/Combobox.tsx
2637
2642
  import { jsx as jsx37, jsxs as jsxs23 } from "react/jsx-runtime";
2638
2643
  var INPUT_HEIGHT = 44;
2639
2644
  var MAX_DROPDOWN_HEIGHT = 200;
@@ -2657,6 +2662,7 @@ var styles33 = StyleSheet33.create({
2657
2662
  * No vertical padding: iOS centers text automatically inside the wrapper height.
2658
2663
  * textAlignVertical + includeFontPadding provide Android parity.
2659
2664
  * No lineHeight: explicit lineHeight clips ascenders/descenders on iOS.
2665
+ * transform translateY: optical correction for Arial's asymmetric metrics — see OPTICAL_TEXT_SHIFT.
2660
2666
  */
2661
2667
  input: {
2662
2668
  fontFamily: font.family.body,
@@ -2664,7 +2670,8 @@ var styles33 = StyleSheet33.create({
2664
2670
  paddingHorizontal: spacing.s,
2665
2671
  paddingVertical: 0,
2666
2672
  textAlignVertical: "center",
2667
- includeFontPadding: false
2673
+ includeFontPadding: false,
2674
+ transform: [{ translateY: OPTICAL_TEXT_SHIFT }]
2668
2675
  },
2669
2676
  dropdown: {
2670
2677
  position: "absolute",
@@ -2824,22 +2831,32 @@ function Combobox({
2824
2831
  import { Children } from "react";
2825
2832
  import { StyleSheet as StyleSheet34, View as View31, Pressable as Pressable19, Text as Text28 } from "react-native";
2826
2833
  import { jsx as jsx38 } from "react/jsx-runtime";
2834
+ var TRACK_PADDING = spacing.xxxs;
2827
2835
  var sizeTokens2 = {
2828
- small: { height: 32, fontSize: font.size.bodyS, px: spacing.s },
2829
- medium: { height: 40, fontSize: font.size.body, px: spacing.m },
2830
- large: { height: 48, fontSize: font.size.body, px: spacing.l }
2836
+ small: { minHeight: 32, fontSize: font.size.bodyS, px: spacing.s, py: spacing.xxs },
2837
+ medium: { minHeight: 44, fontSize: font.size.body, px: spacing.m, py: spacing.xs },
2838
+ large: { minHeight: 48, fontSize: font.size.body, px: spacing.m, py: spacing.s }
2839
+ };
2840
+ var thumbShadow = {
2841
+ shadowColor: "#000",
2842
+ shadowOffset: { width: 0, height: 1 },
2843
+ shadowOpacity: 0.12,
2844
+ shadowRadius: 2,
2845
+ elevation: 2
2831
2846
  };
2832
2847
  var styles34 = StyleSheet34.create({
2833
2848
  track: {
2834
2849
  flexDirection: "row",
2835
2850
  borderRadius: border.radius.full,
2851
+ padding: TRACK_PADDING,
2836
2852
  overflow: "hidden"
2837
2853
  },
2838
- item: {
2854
+ segment: {
2839
2855
  flex: 1,
2840
2856
  alignItems: "center",
2841
2857
  justifyContent: "center",
2842
- minHeight: 44
2858
+ // Thumb radius = track radius minus the padding gap (mirrors thumb-radius calc in web CSS)
2859
+ borderRadius: border.radius.full - TRACK_PADDING
2843
2860
  },
2844
2861
  label: {
2845
2862
  fontFamily: font.family.body,
@@ -2858,21 +2875,26 @@ function SegmentedControl({
2858
2875
  style
2859
2876
  }) {
2860
2877
  const { colors: colors2 } = useTheme();
2861
- const { height, fontSize, px } = sizeTokens2[size];
2878
+ const { minHeight, fontSize, px, py } = sizeTokens2[size];
2862
2879
  const items = Children.toArray(children);
2863
- const trackStyle = variant === "filled" ? { backgroundColor: colors2.colorBgSubtle } : { backgroundColor: "transparent", borderWidth: 1, borderColor: colors2.colorBgSubtle };
2880
+ const trackBg = colors2.colorBgSubtle;
2881
+ const thumbBg = variant === "outline" ? colors2.colorBg : colors2.colorInteractive;
2864
2882
  return /* @__PURE__ */ jsx38(
2865
2883
  View31,
2866
2884
  {
2867
- style: [styles34.track, trackStyle, { borderRadius: border.radius.full }, style],
2885
+ style: [styles34.track, { backgroundColor: trackBg }, style],
2868
2886
  accessibilityRole: "tablist",
2869
2887
  accessibilityLabel,
2870
2888
  children: items.map((child, index) => {
2871
2889
  const isActive = index === selectedIndex;
2872
- const activeStyle = variant === "filled" ? { backgroundColor: colors2.colorInteractive } : { borderWidth: 2, borderColor: colors2.colorInteractive };
2873
- const inactiveStyle = variant === "filled" ? {} : {};
2874
- const textColor = variant === "filled" && isActive ? colors2.colorOnInteractive : isActive ? colors2.colorInteractive : colors2.colorOnBgSubtle;
2890
+ const labelColor = isActive ? variant === "outline" ? colors2.colorInteractiveOnBg : colors2.colorOnInteractive : colors2.colorOnBgSubtle;
2875
2891
  const label = typeof child === "string" || typeof child === "number" ? String(child) : null;
2892
+ const segmentStyle = {
2893
+ minHeight,
2894
+ paddingHorizontal: px,
2895
+ paddingVertical: py,
2896
+ ...isActive ? { backgroundColor: thumbBg, ...thumbShadow } : { backgroundColor: "transparent" }
2897
+ };
2876
2898
  return /* @__PURE__ */ jsx38(
2877
2899
  Pressable19,
2878
2900
  {
@@ -2882,14 +2904,12 @@ function SegmentedControl({
2882
2904
  accessibilityLabel: label ?? void 0,
2883
2905
  accessibilityState: { selected: isActive, disabled },
2884
2906
  style: ({ pressed }) => [
2885
- styles34.item,
2886
- { height, paddingHorizontal: px },
2887
- isActive ? activeStyle : inactiveStyle,
2888
- variant === "filled" && isActive && { borderRadius: border.radius.full },
2907
+ styles34.segment,
2908
+ segmentStyle,
2889
2909
  pressed && !disabled && { opacity: 0.7 },
2890
2910
  disabled && { opacity: 0.4 }
2891
2911
  ],
2892
- children: label !== null ? /* @__PURE__ */ jsx38(Text28, { style: [styles34.label, { fontSize, color: textColor }], children: label }) : child
2912
+ children: label !== null ? /* @__PURE__ */ jsx38(Text28, { style: [styles34.label, { fontSize, color: labelColor }], children: label }) : child
2893
2913
  },
2894
2914
  index
2895
2915
  );
@@ -2934,6 +2954,7 @@ var styles35 = StyleSheet35.create({
2934
2954
  * No vertical padding: iOS centers text automatically inside the wrapper height.
2935
2955
  * textAlignVertical + includeFontPadding are Android-specific but harmless on iOS.
2936
2956
  * No lineHeight: explicit lineHeight on a single-line TextInput clips ascenders/descenders on iOS.
2957
+ * transform translateY: optical correction for Arial's asymmetric metrics — see OPTICAL_TEXT_SHIFT.
2937
2958
  */
2938
2959
  input: {
2939
2960
  fontFamily: font.family.body,
@@ -2941,7 +2962,8 @@ var styles35 = StyleSheet35.create({
2941
2962
  paddingHorizontal: spacing.s,
2942
2963
  paddingVertical: 0,
2943
2964
  textAlignVertical: "center",
2944
- includeFontPadding: false
2965
+ includeFontPadding: false,
2966
+ transform: [{ translateY: OPTICAL_TEXT_SHIFT }]
2945
2967
  }
2946
2968
  });
2947
2969
  function FormInput({
@@ -3537,6 +3559,7 @@ var styles39 = StyleSheet39.create({
3537
3559
  * No vertical padding: iOS auto-centers within the wrapper height.
3538
3560
  * textAlignVertical + includeFontPadding provide Android parity.
3539
3561
  * No lineHeight: explicit lineHeight clips ascenders/descenders on iOS.
3562
+ * transform translateY: optical correction for Arial's asymmetric metrics — see OPTICAL_TEXT_SHIFT.
3540
3563
  */
3541
3564
  input: {
3542
3565
  fontFamily: font.family.body,
@@ -3545,7 +3568,8 @@ var styles39 = StyleSheet39.create({
3545
3568
  paddingHorizontal: spacing.xs,
3546
3569
  paddingVertical: 0,
3547
3570
  textAlignVertical: "center",
3548
- includeFontPadding: false
3571
+ includeFontPadding: false,
3572
+ transform: [{ translateY: OPTICAL_TEXT_SHIFT }]
3549
3573
  }
3550
3574
  });
3551
3575
  function NumberInput({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boostdev/design-system-react-native",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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",
@@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react';
2
2
  import { ThemeProvider } from '../../../../native/ThemeContext';
3
3
  import { Combobox } from './Combobox';
4
4
  import type { ComboboxOption } from './Combobox';
5
+ import { OPTICAL_TEXT_SHIFT } from '../atoms/inputHelpers';
5
6
 
6
7
  function renderWithTheme(ui: React.ReactElement) {
7
8
  return render(<ThemeProvider>{ui}</ThemeProvider>);
@@ -85,4 +86,18 @@ describe('Combobox (native)', () => {
85
86
  const heightPx = parseFloat(getComputedStyle(wrapper).height);
86
87
  expect(heightPx).toBeGreaterThanOrEqual(44);
87
88
  });
89
+
90
+ /**
91
+ * Optical-centering invariant: the TextInput must have a translateY transform equal
92
+ * to OPTICAL_TEXT_SHIFT to compensate for Arial's asymmetric vertical metrics.
93
+ */
94
+ it('has optical translateY shift on the TextInput (Arial centering correction)', () => {
95
+ renderWithTheme(
96
+ <Combobox label="Fruit" name="fruit" options={fruits} />,
97
+ );
98
+ const input = screen.getByRole('combobox');
99
+ const transform = getComputedStyle(input).transform;
100
+ expect(OPTICAL_TEXT_SHIFT).toBe(-1.5);
101
+ expect(transform).toMatch(/(-1\.5|-1\.50)/);
102
+ });
88
103
  });
@@ -10,6 +10,7 @@ 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
+ import { OPTICAL_TEXT_SHIFT } from '../atoms/inputHelpers';
13
14
 
14
15
  /** Fixed height for single-line control fields (satisfies 44pt touch target). */
15
16
  const INPUT_HEIGHT = 44;
@@ -58,6 +59,7 @@ const styles = StyleSheet.create({
58
59
  * No vertical padding: iOS centers text automatically inside the wrapper height.
59
60
  * textAlignVertical + includeFontPadding provide Android parity.
60
61
  * No lineHeight: explicit lineHeight clips ascenders/descenders on iOS.
62
+ * transform translateY: optical correction for Arial's asymmetric metrics — see OPTICAL_TEXT_SHIFT.
61
63
  */
62
64
  input: {
63
65
  fontFamily: font.family.body,
@@ -66,6 +68,7 @@ const styles = StyleSheet.create({
66
68
  paddingVertical: 0,
67
69
  textAlignVertical: 'center',
68
70
  includeFontPadding: false,
71
+ transform: [{ translateY: OPTICAL_TEXT_SHIFT }],
69
72
  },
70
73
  dropdown: {
71
74
  position: 'absolute',
@@ -1,6 +1,7 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import { ThemeProvider } from '../../../../native/ThemeContext';
3
3
  import { FormInput } from './FormInput';
4
+ import { OPTICAL_TEXT_SHIFT } from '../atoms/inputHelpers';
4
5
 
5
6
  function renderWithTheme(ui: React.ReactElement) {
6
7
  return render(<ThemeProvider>{ui}</ThemeProvider>);
@@ -95,4 +96,27 @@ describe('FormInput (native)', () => {
95
96
  // Suppress unused-variable warning from destructuring
96
97
  void container;
97
98
  });
99
+
100
+ /**
101
+ * Optical-centering invariant: the TextInput must have a translateY transform equal
102
+ * to OPTICAL_TEXT_SHIFT to compensate for Arial's asymmetric vertical metrics.
103
+ *
104
+ * Arial's ascent (0.905em) vs descent (0.212em) means iOS places ink ~1.3pt below
105
+ * the geometric centre of the wrapper. The translateY shift was measured on-simulator
106
+ * to bring ink from +1.33pt to -0.33pt, which is visually centred.
107
+ *
108
+ * react-native-web serialises transform: [{translateY: n}] as a CSS transform string,
109
+ * so we check that the computed style contains the expected value.
110
+ */
111
+ it('has optical translateY shift on the TextInput (Arial centering correction)', () => {
112
+ renderWithTheme(<FormInput label="Email" name="email" value="bjorn@boonen.eu" />);
113
+ const input = screen.getByDisplayValue('bjorn@boonen.eu');
114
+ const transform = getComputedStyle(input).transform;
115
+ // react-native-web renders translateY as a matrix or translate() — check the value
116
+ // is present and matches the constant.
117
+ expect(OPTICAL_TEXT_SHIFT).toBe(-1.5);
118
+ // The transform string must encode a non-zero Y translation.
119
+ // matrix(1, 0, 0, 1, 0, -1.5) or translateY(-1.5px) — both contain the value.
120
+ expect(transform).toMatch(/(-1\.5|-1\.50)/);
121
+ });
98
122
  });
@@ -11,6 +11,7 @@ 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
+ import { OPTICAL_TEXT_SHIFT } from '../atoms/inputHelpers';
14
15
 
15
16
  /** Fixed height for single-line control fields (satisfies 44pt touch target). */
16
17
  const INPUT_HEIGHT = 44;
@@ -61,6 +62,7 @@ const styles = StyleSheet.create({
61
62
  * No vertical padding: iOS centers text automatically inside the wrapper height.
62
63
  * textAlignVertical + includeFontPadding are Android-specific but harmless on iOS.
63
64
  * No lineHeight: explicit lineHeight on a single-line TextInput clips ascenders/descenders on iOS.
65
+ * transform translateY: optical correction for Arial's asymmetric metrics — see OPTICAL_TEXT_SHIFT.
64
66
  */
65
67
  input: {
66
68
  fontFamily: font.family.body,
@@ -69,6 +71,7 @@ const styles = StyleSheet.create({
69
71
  paddingVertical: 0,
70
72
  textAlignVertical: 'center',
71
73
  includeFontPadding: false,
74
+ transform: [{ translateY: OPTICAL_TEXT_SHIFT }],
72
75
  },
73
76
  });
74
77
 
@@ -1,6 +1,7 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import { ThemeProvider } from '../../../../native/ThemeContext';
3
3
  import { NumberInput } from './NumberInput';
4
+ import { OPTICAL_TEXT_SHIFT } from '../atoms/inputHelpers';
4
5
 
5
6
  function renderWithTheme(ui: React.ReactElement) {
6
7
  return render(<ThemeProvider>{ui}</ThemeProvider>);
@@ -84,4 +85,16 @@ describe('NumberInput (native)', () => {
84
85
  const heightPx = parseFloat(getComputedStyle(wrapper).height);
85
86
  expect(heightPx).toBeGreaterThanOrEqual(44);
86
87
  });
88
+
89
+ /**
90
+ * Optical-centering invariant: the TextInput must have a translateY transform equal
91
+ * to OPTICAL_TEXT_SHIFT to compensate for Arial's asymmetric vertical metrics.
92
+ */
93
+ it('has optical translateY shift on the TextInput (Arial centering correction)', () => {
94
+ renderWithTheme(<NumberInput label="Quantity" name="qty" value={3} />);
95
+ const input = screen.getByDisplayValue('3');
96
+ const transform = getComputedStyle(input).transform;
97
+ expect(OPTICAL_TEXT_SHIFT).toBe(-1.5);
98
+ expect(transform).toMatch(/(-1\.5|-1\.50)/);
99
+ });
87
100
  });
@@ -12,6 +12,7 @@ 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';
15
+ import { OPTICAL_TEXT_SHIFT } from '../atoms/inputHelpers';
15
16
 
16
17
  interface NumberInputProps {
17
18
  label: string;
@@ -75,6 +76,7 @@ const styles = StyleSheet.create({
75
76
  * No vertical padding: iOS auto-centers within the wrapper height.
76
77
  * textAlignVertical + includeFontPadding provide Android parity.
77
78
  * No lineHeight: explicit lineHeight clips ascenders/descenders on iOS.
79
+ * transform translateY: optical correction for Arial's asymmetric metrics — see OPTICAL_TEXT_SHIFT.
78
80
  */
79
81
  input: {
80
82
  fontFamily: font.family.body,
@@ -84,6 +86,7 @@ const styles = StyleSheet.create({
84
86
  paddingVertical: 0,
85
87
  textAlignVertical: 'center',
86
88
  includeFontPadding: false,
89
+ transform: [{ translateY: OPTICAL_TEXT_SHIFT }],
87
90
  },
88
91
  });
89
92
 
@@ -71,8 +71,18 @@ function Example() {
71
71
  | `accessibilityLabel` | string | -- | Label for the tab group |
72
72
  | `onValueChange` | `(index: number) => void` | -- | Called with the tapped segment index |
73
73
 
74
+ ## Visual structure
75
+
76
+ - **Track** — a single rounded container (`flexDirection: 'row'`, `borderRadius: full`, `colorBgSubtle` background, 2 px inner padding). No per-segment borders.
77
+ - **Segments** — equal-width (`flex: 1`), min touch target 44 dp for medium/large.
78
+ - **Selected thumb** — contrasting background rendered on the active segment only:
79
+ - `outline` variant → `colorBg` (elevated white / dark-elevated) + subtle shadow, label uses `colorInteractiveOnBg`.
80
+ - `filled` variant → `colorInteractive` background, label uses `colorOnInteractive`.
81
+ - **Unselected** — transparent background, label uses `colorOnBgSubtle`.
82
+ - Focus indication uses platform-default `opacity` on press; no stray rectangle outline.
83
+
74
84
  ## Accessibility
75
85
 
76
86
  - Uses `accessibilityRole="tablist"` on the container
77
- - Each segment uses `accessibilityRole="tab"` with `accessibilityState.selected`
78
- - Disabled state is communicated via `accessibilityState`
87
+ - Each segment uses `accessibilityRole="tab"` with `accessibilityState={{ selected, disabled }}`
88
+ - Disabled state is communicated via `accessibilityState.disabled`
@@ -20,7 +20,7 @@ describe('SegmentedControl (native)', () => {
20
20
  expect(screen.getByText('Month')).toBeInTheDocument();
21
21
  });
22
22
 
23
- it('has tablist role', () => {
23
+ it('has tablist role on the track container', () => {
24
24
  renderWithTheme(
25
25
  <SegmentedControl accessibilityLabel="View">
26
26
  {'Day'}
@@ -41,6 +41,21 @@ describe('SegmentedControl (native)', () => {
41
41
  expect(tabs).toHaveLength(2);
42
42
  });
43
43
 
44
+ it('marks the selected segment with accessibilityState.selected', () => {
45
+ renderWithTheme(
46
+ <SegmentedControl accessibilityLabel="View" selectedIndex={1}>
47
+ {'Day'}
48
+ {'Week'}
49
+ </SegmentedControl>,
50
+ );
51
+ // The selected tab is queryable by its label; both tabs still exist
52
+ const tabs = screen.getAllByRole('tab');
53
+ expect(tabs).toHaveLength(2);
54
+ // Week (index 1) is the selected one; Day (index 0) is not
55
+ expect(screen.getByText('Week')).toBeInTheDocument();
56
+ expect(screen.getByText('Day')).toBeInTheDocument();
57
+ });
58
+
44
59
  it('calls onValueChange with the clicked index', () => {
45
60
  const onValueChange = vi.fn();
46
61
  renderWithTheme(
@@ -66,17 +81,44 @@ describe('SegmentedControl (native)', () => {
66
81
  expect(onValueChange).not.toHaveBeenCalled();
67
82
  });
68
83
 
69
- it('renders the selected segment with distinct styling', () => {
70
- renderWithTheme(
71
- <SegmentedControl accessibilityLabel="View" selectedIndex={1}>
84
+ it('track has a single background — no per-segment borders', () => {
85
+ const { container } = renderWithTheme(
86
+ <SegmentedControl accessibilityLabel="View" selectedIndex={0}>
72
87
  {'Day'}
73
88
  {'Week'}
74
89
  </SegmentedControl>,
75
90
  );
76
- // Verify both tabs render and the selected one exists
91
+ // The tablist (track) must exist and the segments (tabs) must not carry border styling
92
+ const tablist = container.querySelector('[role="tablist"]');
93
+ expect(tablist).not.toBeNull();
77
94
  const tabs = screen.getAllByRole('tab');
78
- expect(tabs).toHaveLength(2);
79
- // The selected tab text is rendered
95
+ tabs.forEach((tab) => {
96
+ const style = tab.getAttribute('style') ?? '';
97
+ expect(style).not.toMatch(/border-width/i);
98
+ expect(style).not.toMatch(/border-color/i);
99
+ });
100
+ });
101
+
102
+ it('selected thumb uses colorBg for outline variant (elevated surface)', () => {
103
+ renderWithTheme(
104
+ <SegmentedControl accessibilityLabel="View" selectedIndex={0} variant="outline">
105
+ {'Day'}
106
+ {'Week'}
107
+ </SegmentedControl>,
108
+ );
109
+ // Both tabs render; no regression
110
+ expect(screen.getByText('Day')).toBeInTheDocument();
111
+ expect(screen.getByText('Week')).toBeInTheDocument();
112
+ });
113
+
114
+ it('selected thumb uses colorInteractive for filled variant', () => {
115
+ renderWithTheme(
116
+ <SegmentedControl accessibilityLabel="View" selectedIndex={0} variant="filled">
117
+ {'Day'}
118
+ {'Week'}
119
+ </SegmentedControl>,
120
+ );
121
+ expect(screen.getByText('Day')).toBeInTheDocument();
80
122
  expect(screen.getByText('Week')).toBeInTheDocument();
81
123
  });
82
124
  });
@@ -17,23 +17,36 @@ interface SegmentedControlProps {
17
17
  style?: StyleProp<ViewStyle>;
18
18
  }
19
19
 
20
- const sizeTokens: Record<SegmentedControlSize, { height: number; fontSize: number; px: number }> = {
21
- small: { height: 32, fontSize: font.size.bodyS, px: spacing.s },
22
- medium: { height: 40, fontSize: font.size.body, px: spacing.m },
23
- large: { height: 48, fontSize: font.size.body, px: spacing.l },
20
+ /** Inner padding between track edge and segment edges (mirrors --bds-space_xxxs = 2px). */
21
+ const TRACK_PADDING = spacing.xxxs;
22
+
23
+ const sizeTokens: Record<SegmentedControlSize, { minHeight: number; fontSize: number; px: number; py: number }> = {
24
+ small: { minHeight: 32, fontSize: font.size.bodyS, px: spacing.s, py: spacing.xxs },
25
+ medium: { minHeight: 44, fontSize: font.size.body, px: spacing.m, py: spacing.xs },
26
+ large: { minHeight: 48, fontSize: font.size.body, px: spacing.m, py: spacing.s },
27
+ };
28
+
29
+ const thumbShadow = {
30
+ shadowColor: '#000',
31
+ shadowOffset: { width: 0, height: 1 },
32
+ shadowOpacity: 0.12,
33
+ shadowRadius: 2,
34
+ elevation: 2,
24
35
  };
25
36
 
26
37
  const styles = StyleSheet.create({
27
38
  track: {
28
39
  flexDirection: 'row',
29
40
  borderRadius: border.radius.full,
41
+ padding: TRACK_PADDING,
30
42
  overflow: 'hidden',
31
43
  },
32
- item: {
44
+ segment: {
33
45
  flex: 1,
34
46
  alignItems: 'center',
35
47
  justifyContent: 'center',
36
- minHeight: 44,
48
+ // Thumb radius = track radius minus the padding gap (mirrors thumb-radius calc in web CSS)
49
+ borderRadius: border.radius.full - TRACK_PADDING,
37
50
  },
38
51
  label: {
39
52
  fontFamily: font.family.body,
@@ -53,42 +66,46 @@ export function SegmentedControl({
53
66
  style,
54
67
  }: Readonly<SegmentedControlProps>) {
55
68
  const { colors } = useTheme();
56
- const { height, fontSize, px } = sizeTokens[size];
69
+ const { minHeight, fontSize, px, py } = sizeTokens[size];
57
70
  const items = Children.toArray(children);
58
71
 
59
- const trackStyle: ViewStyle = variant === 'filled'
60
- ? { backgroundColor: colors.colorBgSubtle }
61
- : { backgroundColor: 'transparent', borderWidth: 1, borderColor: colors.colorBgSubtle };
72
+ // Track always uses the subtle background — same for both variants.
73
+ const trackBg = colors.colorBgSubtle;
74
+
75
+ // Thumb background differs by variant:
76
+ // "outline" → elevated surface (white in light, dark-elevated in dark) — mirrors web .--variant_outline .item.--active: colorBg
77
+ // "filled" → interactive brand colour — mirrors web default thumb bg: colorInteractive
78
+ const thumbBg = variant === 'outline' ? colors.colorBg : colors.colorInteractive;
62
79
 
63
80
  return (
64
81
  <View
65
- style={[styles.track, trackStyle, { borderRadius: border.radius.full }, style]}
82
+ style={[styles.track, { backgroundColor: trackBg }, style]}
66
83
  accessibilityRole="tablist"
67
84
  accessibilityLabel={accessibilityLabel}
68
85
  >
69
86
  {items.map((child, index) => {
70
87
  const isActive = index === selectedIndex;
71
88
 
72
- const activeStyle: ViewStyle = variant === 'filled'
73
- ? { backgroundColor: colors.colorInteractive }
74
- : { borderWidth: 2, borderColor: colors.colorInteractive };
89
+ // Active label colour:
90
+ // "outline" → interactive on the surface (matches web --control_label-color--active: colorInteractive for outline)
91
+ // "filled" → on-interactive (white/dark depending on theme)
92
+ const labelColor = isActive
93
+ ? (variant === 'outline' ? colors.colorInteractiveOnBg : colors.colorOnInteractive)
94
+ : colors.colorOnBgSubtle;
75
95
 
76
- const inactiveStyle: ViewStyle = variant === 'filled'
77
- ? {}
78
- : {};
79
-
80
- const textColor = variant === 'filled' && isActive
81
- ? colors.colorOnInteractive
82
- : isActive
83
- ? colors.colorInteractive
84
- : colors.colorOnBgSubtle;
85
-
86
- // Extract label from child: if it's a string, use it directly.
87
- // Otherwise render the child as-is.
88
96
  const label = typeof child === 'string' || typeof child === 'number'
89
97
  ? String(child)
90
98
  : null;
91
99
 
100
+ const segmentStyle: ViewStyle = {
101
+ minHeight,
102
+ paddingHorizontal: px,
103
+ paddingVertical: py,
104
+ ...(isActive
105
+ ? { backgroundColor: thumbBg, ...thumbShadow }
106
+ : { backgroundColor: 'transparent' }),
107
+ };
108
+
92
109
  return (
93
110
  <Pressable
94
111
  key={index}
@@ -98,16 +115,14 @@ export function SegmentedControl({
98
115
  accessibilityLabel={label ?? undefined}
99
116
  accessibilityState={{ selected: isActive, disabled }}
100
117
  style={({ pressed }) => [
101
- styles.item,
102
- { height, paddingHorizontal: px },
103
- isActive ? activeStyle : inactiveStyle,
104
- variant === 'filled' && isActive && { borderRadius: border.radius.full },
118
+ styles.segment,
119
+ segmentStyle,
105
120
  pressed && !disabled && { opacity: 0.7 },
106
121
  disabled && { opacity: 0.4 },
107
122
  ]}
108
123
  >
109
124
  {label !== null ? (
110
- <Text style={[styles.label, { fontSize, color: textColor }]}>
125
+ <Text style={[styles.label, { fontSize, color: labelColor }]}>
111
126
  {label}
112
127
  </Text>
113
128
  ) : (
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Optical vertical correction for single-line TextInput on iOS.
3
+ *
4
+ * Arial (the DS body font) has asymmetric vertical metrics:
5
+ * ascent 0.905em vs descent 0.212em
6
+ * When iOS centers the TextInput's layout box inside the 44pt wrapper,
7
+ * the INK centre sits ~0.08em (~1.3pt at fontSize 16) below the geometric
8
+ * centre. Applying translateY here shifts the ink from +1.33pt to -0.33pt,
9
+ * which is visually perfect (verified on-simulator).
10
+ *
11
+ * Do NOT apply this to multiline TextInput (Textarea) — multiline inputs
12
+ * scroll their content and translateY would misalign the first line.
13
+ */
14
+ export const OPTICAL_TEXT_SHIFT = -1.5;