@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.
@@ -12,6 +12,9 @@ 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,16 +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
- // No lineHeight on single-line TextInput: iOS clips descenders/ascenders when
51
- // lineHeight is set explicitly. minHeight: 44 satisfies the touch-target requirement.
52
68
  paddingHorizontal: spacing.s,
53
- paddingVertical: spacing.xs,
54
- borderWidth: 1,
55
- borderRadius: border.radius.full,
56
- minHeight: 44,
69
+ paddingVertical: 0,
70
+ textAlignVertical: 'center',
71
+ includeFontPadding: false,
57
72
  },
58
73
  });
59
74
 
@@ -84,38 +99,44 @@ export function FormInput({
84
99
  return (
85
100
  <View style={[styles.container, style]}>
86
101
  <Label label={label} />
87
- <TextInput
88
- accessibilityLabel={typeof label === 'string' ? label : name}
89
- accessibilityState={{ disabled }}
90
- accessibilityHint={hint}
91
- value={value}
92
- defaultValue={defaultValue}
93
- placeholder={placeholder}
94
- placeholderTextColor={colors.colorOnBgSubtle}
95
- keyboardType={keyboardTypeMap[type]}
96
- secureTextEntry={type === 'password'}
97
- autoCapitalize={type === 'email' || type === 'url' ? 'none' : 'sentences'}
98
- autoComplete={type === 'email' ? 'email' : type === 'tel' ? 'tel' : undefined}
99
- editable={!disabled}
100
- onChangeText={onChange}
101
- onFocus={() => {
102
- setFocused(true);
103
- onFocus?.();
104
- }}
105
- onBlur={() => {
106
- setFocused(false);
107
- onBlur?.();
108
- }}
102
+ <View
109
103
  style={[
110
- styles.input,
104
+ styles.inputWrapper,
111
105
  {
112
106
  borderColor,
113
- color: colors.colorOnBg,
114
107
  backgroundColor: colors.colorBg,
115
108
  },
116
109
  disabled && { opacity: 0.4 },
117
110
  ]}
118
- />
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>
119
140
  <Message type="error" message={error} />
120
141
  <Message type="hint" message={hint} />
121
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
  });
@@ -60,18 +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
- // No lineHeight on single-line TextInput: iOS clips descenders/ascenders when
68
- // lineHeight is set explicitly. minHeight: STEPPER_SIZE (44) covers the touch target.
69
82
  textAlign: 'center',
70
83
  paddingHorizontal: spacing.xs,
71
- paddingVertical: spacing.xs,
72
- borderTopWidth: 1,
73
- borderBottomWidth: 1,
74
- minHeight: STEPPER_SIZE,
84
+ paddingVertical: 0,
85
+ textAlignVertical: 'center',
86
+ includeFontPadding: false,
75
87
  },
76
88
  });
77
89
 
@@ -140,32 +152,38 @@ export function NumberInput({
140
152
  </Text>
141
153
  </Pressable>
142
154
 
143
- <TextInput
144
- accessibilityLabel={label}
145
- accessibilityState={{ disabled }}
146
- accessibilityValue={{
147
- min,
148
- max,
149
- now: currentValue,
150
- text: String(currentValue),
151
- }}
152
- keyboardType="numeric"
153
- value={String(currentValue)}
154
- editable={!disabled}
155
- onChangeText={(text) => {
156
- const parsed = parseFloat(text);
157
- const safe = isNaN(parsed) ? 0 : parsed;
158
- update(safe);
159
- }}
155
+ <View
160
156
  style={[
161
- styles.input,
157
+ styles.inputWrapper,
162
158
  {
163
159
  borderColor,
164
- color: colors.colorOnBg,
165
160
  backgroundColor: colors.colorBg,
166
161
  },
167
162
  ]}
168
- />
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>
169
187
 
170
188
  <Pressable
171
189
  accessibilityRole="button"
@@ -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
  ) : (