@boostdev/design-system-react-native 0.5.1 → 0.7.0

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.
Files changed (28) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +35 -1
  3. package/dist/index.cjs +40 -27
  4. package/dist/index.d.cts +17 -1
  5. package/dist/index.d.ts +17 -1
  6. package/dist/index.js +40 -27
  7. package/package.json +1 -1
  8. package/src/components/interaction/Button/Button.mdx +13 -3
  9. package/src/components/interaction/Button/Button.spec.tsx +88 -2
  10. package/src/components/interaction/Button/Button.stories.tsx +14 -4
  11. package/src/components/interaction/Button/Button.tsx +9 -8
  12. package/src/components/interaction/form/Combobox/Combobox.spec.tsx +14 -1
  13. package/src/components/interaction/form/Combobox/Combobox.tsx +4 -3
  14. package/src/components/interaction/form/FileInput/FileInput.spec.tsx +13 -1
  15. package/src/components/interaction/form/FileInput/FileInput.tsx +3 -3
  16. package/src/components/interaction/form/FormInput/FormInput.spec.tsx +14 -1
  17. package/src/components/interaction/form/FormInput/FormInput.tsx +5 -4
  18. package/src/components/interaction/form/NumberInput/NumberInput.spec.tsx +14 -1
  19. package/src/components/interaction/form/NumberInput/NumberInput.tsx +7 -6
  20. package/src/components/interaction/form/Select/Select.mdx +1 -1
  21. package/src/components/interaction/form/Select/Select.spec.tsx +14 -1
  22. package/src/components/interaction/form/Select/Select.tsx +2 -2
  23. package/src/components/interaction/form/Textarea/Textarea.spec.tsx +13 -1
  24. package/src/components/interaction/form/Textarea/Textarea.tsx +3 -3
  25. package/src/components/layout/ButtonGroup/ButtonGroup.stories.tsx +3 -3
  26. package/src/index.ts +1 -1
  27. package/src/native/ThemeContext.spec.tsx +81 -1
  28. package/src/native/ThemeContext.tsx +32 -3
package/dist/index.js CHANGED
@@ -15,15 +15,25 @@ var lineHeight = {
15
15
 
16
16
  // src/native/ThemeContext.tsx
17
17
  import { jsx } from "react/jsx-runtime";
18
+ var defaultRadius = {
19
+ button: border.radius.m,
20
+ iconButton: border.radius.full,
21
+ field: border.radius.m
22
+ };
18
23
  var ThemeContext = createContext(null);
19
24
  function ThemeProvider({ children, colorScheme, tokens }) {
20
25
  const systemScheme = useColorScheme();
21
26
  const isDark = (colorScheme ?? systemScheme) === "dark";
27
+ const parentRadius = useContext(ThemeContext)?.radius ?? defaultRadius;
22
28
  const value = useMemo(() => {
23
29
  const base = isDark ? colors.dark : colors.light;
24
30
  const overrides = isDark ? tokens?.dark : tokens?.light;
25
- return { colors: { ...base, ...overrides }, isDark };
26
- }, [isDark, tokens]);
31
+ return {
32
+ colors: { ...base, ...overrides },
33
+ isDark,
34
+ radius: { ...parentRadius, ...tokens?.radius }
35
+ };
36
+ }, [isDark, tokens, parentRadius]);
27
37
  return /* @__PURE__ */ jsx(ThemeContext.Provider, { value, children });
28
38
  }
29
39
  function useTheme() {
@@ -2170,17 +2180,16 @@ function Button({
2170
2180
  accessibilityLabel,
2171
2181
  style
2172
2182
  }) {
2173
- const { colors: colors2 } = useTheme();
2183
+ const { colors: colors2, radius } = useTheme();
2174
2184
  const { height, px, fontSize } = sizeTokens[size];
2175
2185
  const primaryColor = colors2.colorInteractive;
2176
2186
  const onPrimaryColor = colors2.colorOnInteractive;
2177
2187
  const outlineTextColor = colors2.colorInteractiveOnBg;
2178
- const isOutline = variant === "outline" || variant === "ghost";
2179
2188
  const containerStyle = {
2180
2189
  minHeight: height,
2181
2190
  paddingHorizontal: isIconOnly ? spacing.xs : px,
2182
- // A text button has medium corners on every size; an icon-only button stays round.
2183
- borderRadius: isIconOnly ? border.radius.full : border.radius.m,
2191
+ // Corners come from the theme (`tokens.radius`); a borderRadius in `style` still wins.
2192
+ borderRadius: isIconOnly ? radius.iconButton : radius.button,
2184
2193
  opacity: disabled ? 0.4 : 1,
2185
2194
  // Icon-only: square with equal xs padding on both axes.
2186
2195
  ...isIconOnly && { aspectRatio: 1, paddingVertical: spacing.xs },
@@ -2188,11 +2197,12 @@ function Button({
2188
2197
  // instead, so a wrapped label grows the button from minHeight rather than clipping.
2189
2198
  ...isIconOnly ? { height } : { paddingVertical: spacing.xxs },
2190
2199
  ...variant === "default" && { backgroundColor: primaryColor },
2191
- ...isOutline && {
2200
+ ...variant === "outline" && {
2192
2201
  backgroundColor: "transparent",
2193
2202
  borderWidth: 1,
2194
2203
  borderColor: outlineTextColor
2195
- }
2204
+ },
2205
+ ...variant === "ghost" && { backgroundColor: "transparent" }
2196
2206
  };
2197
2207
  const textColor = variant === "default" ? onPrimaryColor : outlineTextColor;
2198
2208
  return /* @__PURE__ */ jsxs16(
@@ -2584,7 +2594,6 @@ var styles33 = StyleSheet34.create({
2584
2594
  paddingHorizontal: spacing.m,
2585
2595
  borderWidth: 2,
2586
2596
  borderStyle: "dashed",
2587
- borderRadius: border.radius.m,
2588
2597
  minHeight: 120
2589
2598
  },
2590
2599
  icon: {
@@ -2625,7 +2634,7 @@ function FileInput({
2625
2634
  fileNames = [],
2626
2635
  style
2627
2636
  }) {
2628
- const { colors: colors2 } = useTheme();
2637
+ const { colors: colors2, radius } = useTheme();
2629
2638
  const borderColor = error ? colors2.colorError : colors2.colorBgSubtle;
2630
2639
  return /* @__PURE__ */ jsxs22(View29, { style: [styles33.container, style], testID: name, children: [
2631
2640
  /* @__PURE__ */ jsx38(
@@ -2646,6 +2655,7 @@ function FileInput({
2646
2655
  styles33.dropZone,
2647
2656
  {
2648
2657
  borderColor,
2658
+ borderRadius: radius.field,
2649
2659
  backgroundColor: "transparent"
2650
2660
  },
2651
2661
  pressed && { opacity: 0.7 },
@@ -2680,7 +2690,8 @@ var styles34 = StyleSheet35.create({
2680
2690
  gap: spacing.xxs
2681
2691
  },
2682
2692
  /**
2683
- * Draws the rounded border and provides the fixed 44pt touch-target height.
2693
+ * Draws the rounded border (`radius.field` from the theme) and provides the fixed
2694
+ * 44pt touch-target height.
2684
2695
  * justifyContent: 'center' vertically centers the inner TextInput.
2685
2696
  * position: 'relative' is required for the dropdown's absolute positioning.
2686
2697
  */
@@ -2688,8 +2699,7 @@ var styles34 = StyleSheet35.create({
2688
2699
  position: "relative",
2689
2700
  height: INPUT_HEIGHT,
2690
2701
  justifyContent: "center",
2691
- borderWidth: 1,
2692
- borderRadius: border.radius.m
2702
+ borderWidth: 1
2693
2703
  },
2694
2704
  /**
2695
2705
  * No vertical padding: iOS centers text automatically inside the wrapper height.
@@ -2742,7 +2752,7 @@ function Combobox({
2742
2752
  hint,
2743
2753
  style
2744
2754
  }) {
2745
- const { colors: colors2 } = useTheme();
2755
+ const { colors: colors2, radius } = useTheme();
2746
2756
  const selectedOption = useMemo2(
2747
2757
  () => options.find((o) => o.value === value),
2748
2758
  [options, value]
@@ -2780,6 +2790,7 @@ function Combobox({
2780
2790
  styles34.inputWrapper,
2781
2791
  {
2782
2792
  borderColor,
2793
+ borderRadius: radius.field,
2783
2794
  backgroundColor: colors2.colorBg
2784
2795
  },
2785
2796
  disabled && { opacity: 0.4 }
@@ -2980,14 +2991,14 @@ var styles36 = StyleSheet37.create({
2980
2991
  gap: spacing.xxs
2981
2992
  },
2982
2993
  /**
2983
- * The wrapper draws the rounded border and provides at least 44pt touch-target height.
2994
+ * The wrapper draws the rounded border (`radius.field` from the theme) and provides
2995
+ * at least 44pt touch-target height.
2984
2996
  * justifyContent: 'center' vertically centers the inner TextInput on both iOS and Android.
2985
2997
  */
2986
2998
  inputWrapper: {
2987
2999
  minHeight: INPUT_HEIGHT2,
2988
3000
  justifyContent: "center",
2989
- borderWidth: 1,
2990
- borderRadius: border.radius.m
3001
+ borderWidth: 1
2991
3002
  },
2992
3003
  /**
2993
3004
  * No vertical padding: iOS centers text automatically inside the wrapper height.
@@ -3020,7 +3031,7 @@ function FormInput({
3020
3031
  onFocus,
3021
3032
  style
3022
3033
  }) {
3023
- const { colors: colors2 } = useTheme();
3034
+ const { colors: colors2, radius } = useTheme();
3024
3035
  const [focused, setFocused] = useState8(false);
3025
3036
  const borderColor = error ? colors2.colorError : focused ? colors2.colorInteractive : colors2.colorOnBg;
3026
3037
  return /* @__PURE__ */ jsxs24(View32, { style: [styles36.container, style], children: [
@@ -3032,6 +3043,7 @@ function FormInput({
3032
3043
  styles36.inputWrapper,
3033
3044
  {
3034
3045
  borderColor,
3046
+ borderRadius: radius.field,
3035
3047
  backgroundColor: colors2.colorBg
3036
3048
  },
3037
3049
  disabled && { opacity: 0.4 }
@@ -3088,7 +3100,6 @@ var styles37 = StyleSheet38.create({
3088
3100
  paddingHorizontal: spacing.s,
3089
3101
  paddingVertical: spacing.xs,
3090
3102
  borderWidth: 1,
3091
- borderRadius: border.radius.m,
3092
3103
  minHeight: 100,
3093
3104
  textAlignVertical: "top"
3094
3105
  }
@@ -3108,7 +3119,7 @@ function Textarea({
3108
3119
  onFocus,
3109
3120
  style
3110
3121
  }) {
3111
- const { colors: colors2 } = useTheme();
3122
+ const { colors: colors2, radius } = useTheme();
3112
3123
  const [focused, setFocused] = useState9(false);
3113
3124
  const borderColor = error ? colors2.colorError : focused ? colors2.colorInteractive : colors2.colorOnBg;
3114
3125
  return /* @__PURE__ */ jsxs25(View33, { style: [styles37.container, style], children: [
@@ -3139,6 +3150,7 @@ function Textarea({
3139
3150
  styles37.textarea,
3140
3151
  {
3141
3152
  borderColor,
3153
+ borderRadius: radius.field,
3142
3154
  color: colors2.colorOnBg,
3143
3155
  backgroundColor: colors2.colorBg
3144
3156
  },
@@ -3166,7 +3178,6 @@ var styles38 = StyleSheet39.create({
3166
3178
  paddingHorizontal: spacing.s,
3167
3179
  paddingVertical: spacing.xs,
3168
3180
  borderWidth: 1,
3169
- borderRadius: border.radius.m,
3170
3181
  minHeight: 44
3171
3182
  },
3172
3183
  triggerText: {
@@ -3231,7 +3242,7 @@ function Select({
3231
3242
  onChange,
3232
3243
  style
3233
3244
  }) {
3234
- const { colors: colors2 } = useTheme();
3245
+ const { colors: colors2, radius } = useTheme();
3235
3246
  const [open, setOpen] = useState10(false);
3236
3247
  const selectedOption = options.find((o) => o.value === value);
3237
3248
  const displayText = selectedOption?.label ?? placeholder;
@@ -3256,6 +3267,7 @@ function Select({
3256
3267
  styles38.trigger,
3257
3268
  {
3258
3269
  borderColor,
3270
+ borderRadius: radius.field,
3259
3271
  backgroundColor: colors2.colorBg
3260
3272
  },
3261
3273
  disabled && { opacity: 0.4 }
@@ -3551,14 +3563,11 @@ var styles40 = StyleSheet41.create({
3551
3563
  justifyContent: "center",
3552
3564
  borderWidth: 1
3553
3565
  },
3566
+ // The steppers round only the field's outer corners, with `radius.field` from the theme.
3554
3567
  stepperStart: {
3555
- borderTopStartRadius: border.radius.m,
3556
- borderBottomStartRadius: border.radius.m,
3557
3568
  borderEndWidth: 0
3558
3569
  },
3559
3570
  stepperEnd: {
3560
- borderTopEndRadius: border.radius.m,
3561
- borderBottomEndRadius: border.radius.m,
3562
3571
  borderStartWidth: 0
3563
3572
  },
3564
3573
  stepperText: {
@@ -3609,7 +3618,7 @@ function NumberInput({
3609
3618
  onChange,
3610
3619
  style
3611
3620
  }) {
3612
- const { colors: colors2 } = useTheme();
3621
+ const { colors: colors2, radius } = useTheme();
3613
3622
  const isControlled = value !== void 0;
3614
3623
  const [internalValue, setInternalValue] = useState12(defaultValue ?? 0);
3615
3624
  const currentValue = isControlled ? value : internalValue;
@@ -3641,6 +3650,8 @@ function NumberInput({
3641
3650
  styles40.stepperStart,
3642
3651
  {
3643
3652
  borderColor,
3653
+ borderTopStartRadius: radius.field,
3654
+ borderBottomStartRadius: radius.field,
3644
3655
  backgroundColor: colors2.colorBgSubtle
3645
3656
  },
3646
3657
  pressed && { opacity: 0.7 },
@@ -3706,6 +3717,8 @@ function NumberInput({
3706
3717
  styles40.stepperEnd,
3707
3718
  {
3708
3719
  borderColor,
3720
+ borderTopEndRadius: radius.field,
3721
+ borderBottomEndRadius: radius.field,
3709
3722
  backgroundColor: colors2.colorBgSubtle
3710
3723
  },
3711
3724
  pressed && { opacity: 0.7 },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boostdev/design-system-react-native",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
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",
@@ -38,6 +38,7 @@ export default function App() {
38
38
 
39
39
  <Canvas of={Stories.Default} />
40
40
  <Canvas of={Stories.Outline} />
41
+ <Canvas of={Stories.Ghost} />
41
42
 
42
43
  ## Sizes
43
44
 
@@ -47,7 +48,15 @@ export default function App() {
47
48
 
48
49
  ## Shape
49
50
 
50
- A button with a text label has `border.radius.m` corners on every size, the same as the form fields. An icon-only button (`isIconOnly`) stays round (`border.radius.full`). A `borderRadius` in `style` overrides both.
51
+ The corner radius comes from the theme, not from the button: a button with a text label uses `radius.button` on every size, an icon-only button (`isIconOnly`) uses `radius.iconButton`. By default these are `border.radius.m` (the same as the form fields) and `border.radius.full` (round). Set them per app through `ThemeProvider`, the native equivalent of the web `--button_radius`:
52
+
53
+ ```tsx
54
+ <ThemeProvider tokens={{ radius: { button: border.radius.s, iconButton: border.radius.s } }}>
55
+ <App />
56
+ </ThemeProvider>
57
+ ```
58
+
59
+ A nested `ThemeProvider` keeps its parent's radii unless it sets its own. A `borderRadius` in `style` overrides the theme.
51
60
 
52
61
  ## Icons
53
62
 
@@ -56,10 +65,11 @@ A button with a text label has `border.radius.m` corners on every size, the same
56
65
 
57
66
  ### Icon-only (1:1 aspect ratio)
58
67
 
59
- Set `isIconOnly` to render a round 1:1 button with equal `spacing.xs` padding on both axes. Composable with any `variant`. Always pair with `accessibilityLabel` for assistive tech.
68
+ Set `isIconOnly` to render a 1:1 button (round by default, see [Shape](#shape)) with equal `spacing.xs` padding on both axes. Composable with any `variant`. Always pair with `accessibilityLabel` for assistive tech.
60
69
 
61
70
  <Canvas of={Stories.IconOnly} />
62
71
  <Canvas of={Stories.IconOnlyOutline} />
72
+ <Canvas of={Stories.IconOnlyGhost} />
63
73
  <Canvas of={Stories.AllIconOnly} />
64
74
 
65
75
  ## All variants × sizes
@@ -78,7 +88,7 @@ Set `isIconOnly` to render a round 1:1 button with equal `spacing.xs` padding on
78
88
 
79
89
  | Prop | Type | Default | Description |
80
90
  |------|------|---------|-------------|
81
- | `variant` | `"default" \| "outline"` | `"default"` | Visual style (`"ghost"` is a deprecated alias for `"outline"`) |
91
+ | `variant` | `"default" \| "outline" \| "ghost"` | `"default"` | Visual style: `ghost` is `outline` without the border |
82
92
  | `size` | `"small" \| "medium" \| "large"` | `"medium"` | Height and horizontal padding |
83
93
  | `disabled` | boolean | `false` | Prevents interaction, reduces opacity |
84
94
  | `onPress` | `() => void` | — | Called when the button is pressed |
@@ -1,5 +1,6 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import { ThemeProvider } from '../../../native/ThemeContext';
3
+ import type { ThemeRadius } from '../../../native/ThemeContext';
3
4
  import { Button } from './Button';
4
5
  import { cornerRadii } from '../../../test/cornerRadii';
5
6
 
@@ -71,15 +72,41 @@ describe('Button (native)', () => {
71
72
  expect(style).toMatch(/(^|[;\s])height:\s*56px/);
72
73
  });
73
74
 
75
+ it('draws a ghost button without a border', () => {
76
+ renderWithTheme(<Button variant="ghost">Meer</Button>);
77
+ const style = screen.getByRole('button').getAttribute('style') ?? '';
78
+ expect(style).not.toMatch(/border-(top-|right-|bottom-|left-)?width:\s*[1-9]/);
79
+ expect(style).not.toMatch(/border-(top-|right-|bottom-|left-)?color:/);
80
+ expect(style).toMatch(/background-color:\s*rgba\(0, 0, 0, 0\)/);
81
+ });
82
+
83
+ it('draws an icon-only ghost button without a border', () => {
84
+ renderWithTheme(
85
+ <Button isIconOnly variant="ghost" accessibilityLabel="Meer">
86
+ <span>✓</span>
87
+ </Button>,
88
+ );
89
+ const style = screen.getByRole('button', { name: 'Meer' }).getAttribute('style') ?? '';
90
+ expect(style).not.toMatch(/border-(top-|right-|bottom-|left-)?width:\s*[1-9]/);
91
+ expect(style).not.toMatch(/border-(top-|right-|bottom-|left-)?color:/);
92
+ expect(style).toMatch(/background-color:\s*rgba\(0, 0, 0, 0\)/);
93
+ });
94
+
95
+ it('keeps the border on an outline button', () => {
96
+ renderWithTheme(<Button variant="outline">Meer</Button>);
97
+ const style = screen.getByRole('button').getAttribute('style') ?? '';
98
+ expect(style).toMatch(/border-(top-|right-|bottom-|left-)?width:\s*1px/);
99
+ });
100
+
74
101
  it.each(['small', 'medium', 'large'] as const)(
75
- 'gives a %s text button the medium border radius',
102
+ 'gives a %s text button the medium border radius by default',
76
103
  (size) => {
77
104
  renderWithTheme(<Button size={size}>Save</Button>);
78
105
  expect(cornerRadii(screen.getByRole('button'))).toEqual(['16px', '16px', '16px', '16px']);
79
106
  },
80
107
  );
81
108
 
82
- it('keeps an icon-only button round', () => {
109
+ it('keeps an icon-only button round by default', () => {
83
110
  renderWithTheme(
84
111
  <Button isIconOnly accessibilityLabel="Confirm">
85
112
  <span>✓</span>
@@ -92,4 +119,63 @@ describe('Button (native)', () => {
92
119
  renderWithTheme(<Button style={{ borderRadius: 4 }}>Save</Button>);
93
120
  expect(cornerRadii(screen.getByRole('button'))).toEqual(['4px', '4px', '4px', '4px']);
94
121
  });
122
+
123
+ describe('with a theme radius', () => {
124
+ const radius12: ThemeRadius = { button: 12, iconButton: 12, field: 12 };
125
+
126
+ it('gives a text button the theme button radius', () => {
127
+ render(
128
+ <ThemeProvider tokens={{ radius: radius12 }}>
129
+ <Button>Save</Button>
130
+ </ThemeProvider>,
131
+ );
132
+ expect(cornerRadii(screen.getByRole('button'))).toEqual(['12px', '12px', '12px', '12px']);
133
+ });
134
+
135
+ it('gives an icon-only button the theme icon-button radius', () => {
136
+ render(
137
+ <ThemeProvider tokens={{ radius: radius12 }}>
138
+ <Button isIconOnly accessibilityLabel="Confirm">
139
+ <span>✓</span>
140
+ </Button>
141
+ </ThemeProvider>,
142
+ );
143
+ expect(cornerRadii(screen.getByRole('button'))).toEqual(['12px', '12px', '12px', '12px']);
144
+ });
145
+
146
+ it('changes only the text button with a partial override', () => {
147
+ render(
148
+ <ThemeProvider tokens={{ radius: { button: 4 } }}>
149
+ <Button>Save</Button>
150
+ <Button isIconOnly accessibilityLabel="Confirm">
151
+ <span>✓</span>
152
+ </Button>
153
+ </ThemeProvider>,
154
+ );
155
+ expect(cornerRadii(screen.getByRole('button', { name: 'Save' }))).toEqual(['4px', '4px', '4px', '4px']);
156
+ expect(cornerRadii(screen.getByRole('button', { name: 'Confirm' }))).toEqual([
157
+ '9999px', '9999px', '9999px', '9999px',
158
+ ]);
159
+ });
160
+
161
+ it("keeps the app's radius inside a nested provider that only overrides colours", () => {
162
+ render(
163
+ <ThemeProvider tokens={{ radius: { button: 12 } }}>
164
+ <ThemeProvider tokens={{ light: { colorInteractive: '#00c853' } }}>
165
+ <Button>Save</Button>
166
+ </ThemeProvider>
167
+ </ThemeProvider>,
168
+ );
169
+ expect(cornerRadii(screen.getByRole('button'))).toEqual(['12px', '12px', '12px', '12px']);
170
+ });
171
+
172
+ it("lets the caller's style override the theme radius", () => {
173
+ render(
174
+ <ThemeProvider tokens={{ radius: radius12 }}>
175
+ <Button style={{ borderRadius: 4 }}>Save</Button>
176
+ </ThemeProvider>,
177
+ );
178
+ expect(cornerRadii(screen.getByRole('button'))).toEqual(['4px', '4px', '4px', '4px']);
179
+ });
180
+ });
95
181
  });
@@ -37,8 +37,8 @@ const meta: Meta<ButtonStoryArgs> = {
37
37
  argTypes: {
38
38
  variant: {
39
39
  control: 'select',
40
- options: ['default', 'outline'],
41
- description: 'Visual style. `default` is filled; `outline` is transparent with a border.',
40
+ options: ['default', 'outline', 'ghost'],
41
+ description: 'Visual style. `default` is filled; `outline` is transparent with a border; `ghost` is transparent without one.',
42
42
  table: { defaultValue: { summary: 'default' } },
43
43
  },
44
44
  size: {
@@ -79,6 +79,7 @@ export const Playground: Story = {
79
79
 
80
80
  export const Default: Story = { args: { children: 'Default' } };
81
81
  export const Outline: Story = { args: { children: 'Outline', variant: 'outline' } };
82
+ export const Ghost: Story = { args: { children: 'Ghost', variant: 'ghost' } };
82
83
  export const Small: Story = { args: { children: 'Small', size: 'small' } };
83
84
  export const Medium: Story = { args: { children: 'Medium', size: 'medium' } };
84
85
  export const Large: Story = { args: { children: 'Large', size: 'large' } };
@@ -104,10 +105,19 @@ export const IconOnlyOutline: Story = {
104
105
  },
105
106
  };
106
107
 
108
+ export const IconOnlyGhost: Story = {
109
+ args: {
110
+ variant: 'ghost',
111
+ isIconOnly: true,
112
+ accessibilityLabel: 'Confirm',
113
+ children: CheckIcon,
114
+ },
115
+ };
116
+
107
117
  export const AllVariants: Story = {
108
118
  render: () => (
109
119
  <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
110
- {(['default', 'outline'] as const).map(v => (
120
+ {(['default', 'outline', 'ghost'] as const).map(v => (
111
121
  <div key={v} style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
112
122
  {(['small', 'medium', 'large'] as const).map(s => (
113
123
  <Button key={s} variant={v} size={s}>{v} {s}</Button>
@@ -121,7 +131,7 @@ export const AllVariants: Story = {
121
131
  export const AllIconOnly: Story = {
122
132
  render: () => (
123
133
  <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
124
- {(['default', 'outline'] as const).map(v => (
134
+ {(['default', 'outline', 'ghost'] as const).map(v => (
125
135
  <div key={v} style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
126
136
  {(['small', 'medium', 'large'] as const).map(s => (
127
137
  <Button
@@ -1,10 +1,10 @@
1
1
  import { ReactNode } from 'react';
2
2
  import { StyleSheet, Pressable, View, StyleProp, ViewStyle } from 'react-native';
3
3
  import { ScaledText as Text } from '../../../native/ScaledText';
4
- import { spacing, font, border } from '../../../native/tokens';
4
+ import { spacing, font } from '../../../native/tokens';
5
5
  import { useTheme } from '../../../native/ThemeContext';
6
6
 
7
- type ButtonVariant = 'default' | 'outline' | 'ghost'; // 'ghost' is deprecated, use 'outline'
7
+ type ButtonVariant = 'default' | 'outline' | 'ghost'; // 'ghost' is 'outline' without the border
8
8
  type ButtonSize = 'small' | 'medium' | 'large';
9
9
 
10
10
  export interface ButtonProps {
@@ -56,21 +56,21 @@ export function Button({
56
56
  accessibilityLabel,
57
57
  style,
58
58
  }: Readonly<ButtonProps>) {
59
- const { colors } = useTheme();
59
+ const { colors, radius } = useTheme();
60
60
  const { height, px, fontSize } = sizeTokens[size];
61
61
 
62
- // Color model: filled = interactive bg, outline = transparent bg + interactive_on-bg border/text
62
+ // Color model: filled = interactive bg, outline = transparent bg + interactive_on-bg border/text,
63
+ // ghost = outline without the border, for a quiet button such as one beside a heading
63
64
  const primaryColor = colors.colorInteractive;
64
65
  const onPrimaryColor = colors.colorOnInteractive;
65
66
  // Outline uses colorInteractiveOnBg for text/border — colorInteractive may not meet 3:1 on white
66
67
  const outlineTextColor = colors.colorInteractiveOnBg;
67
68
 
68
- const isOutline = variant === 'outline' || variant === 'ghost';
69
69
  const containerStyle: ViewStyle = {
70
70
  minHeight: height,
71
71
  paddingHorizontal: isIconOnly ? spacing.xs : px,
72
- // A text button has medium corners on every size; an icon-only button stays round.
73
- borderRadius: isIconOnly ? border.radius.full : border.radius.m,
72
+ // Corners come from the theme (`tokens.radius`); a borderRadius in `style` still wins.
73
+ borderRadius: isIconOnly ? radius.iconButton : radius.button,
74
74
  opacity: disabled ? 0.4 : 1,
75
75
  // Icon-only: square with equal xs padding on both axes.
76
76
  ...(isIconOnly && { aspectRatio: 1, paddingVertical: spacing.xs }),
@@ -78,11 +78,12 @@ export function Button({
78
78
  // instead, so a wrapped label grows the button from minHeight rather than clipping.
79
79
  ...(isIconOnly ? { height } : { paddingVertical: spacing.xxs }),
80
80
  ...(variant === 'default' && { backgroundColor: primaryColor }),
81
- ...(isOutline && {
81
+ ...(variant === 'outline' && {
82
82
  backgroundColor: 'transparent',
83
83
  borderWidth: 1,
84
84
  borderColor: outlineTextColor,
85
85
  }),
86
+ ...(variant === 'ghost' && { backgroundColor: 'transparent' }),
86
87
  };
87
88
 
88
89
  const textColor = variant === 'default' ? onPrimaryColor : outlineTextColor;
@@ -1,10 +1,13 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import { ThemeProvider } from '../../../../native/ThemeContext';
3
+ import type { ThemeRadius } from '../../../../native/ThemeContext';
3
4
  import { Combobox } from './Combobox';
4
5
  import type { ComboboxOption } from './Combobox';
5
6
  import { OPTICAL_TEXT_SHIFT } from '../atoms/inputHelpers';
6
7
  import { cornerRadii } from '../../../../test/cornerRadii';
7
8
 
9
+ const radius12: ThemeRadius = { button: 12, iconButton: 12, field: 12 };
10
+
8
11
  function renderWithTheme(ui: React.ReactElement) {
9
12
  return render(<ThemeProvider>{ui}</ThemeProvider>);
10
13
  }
@@ -102,11 +105,21 @@ describe('Combobox (native)', () => {
102
105
  expect(transform).toMatch(/(-1\.5|-1\.50)/);
103
106
  });
104
107
 
105
- it('gives the bordered field box the medium border radius', () => {
108
+ it('gives the bordered field box the medium border radius by default', () => {
106
109
  renderWithTheme(
107
110
  <Combobox label="Fruit" name="fruit" options={fruits} />,
108
111
  );
109
112
  const wrapper = screen.getByRole('combobox').parentElement as HTMLElement;
110
113
  expect(cornerRadii(wrapper)).toEqual(['16px', '16px', '16px', '16px']);
111
114
  });
115
+
116
+ it('takes the border radius of the field box from the theme', () => {
117
+ render(
118
+ <ThemeProvider tokens={{ radius: radius12 }}>
119
+ <Combobox label="Fruit" name="fruit" options={fruits} />
120
+ </ThemeProvider>,
121
+ );
122
+ const wrapper = screen.getByRole('combobox').parentElement as HTMLElement;
123
+ expect(cornerRadii(wrapper)).toEqual(['12px', '12px', '12px', '12px']);
124
+ });
112
125
  });
@@ -36,7 +36,8 @@ const styles = StyleSheet.create({
36
36
  gap: spacing.xxs,
37
37
  },
38
38
  /**
39
- * Draws the rounded border and provides the fixed 44pt touch-target height.
39
+ * Draws the rounded border (`radius.field` from the theme) and provides the fixed
40
+ * 44pt touch-target height.
40
41
  * justifyContent: 'center' vertically centers the inner TextInput.
41
42
  * position: 'relative' is required for the dropdown's absolute positioning.
42
43
  */
@@ -45,7 +46,6 @@ const styles = StyleSheet.create({
45
46
  height: INPUT_HEIGHT,
46
47
  justifyContent: 'center',
47
48
  borderWidth: 1,
48
- borderRadius: border.radius.m,
49
49
  },
50
50
  /**
51
51
  * No vertical padding: iOS centers text automatically inside the wrapper height.
@@ -99,7 +99,7 @@ export function Combobox({
99
99
  hint,
100
100
  style,
101
101
  }: Readonly<ComboboxProps>) {
102
- const { colors } = useTheme();
102
+ const { colors, radius } = useTheme();
103
103
 
104
104
  const selectedOption = useMemo(
105
105
  () => options.find((o) => o.value === value),
@@ -147,6 +147,7 @@ export function Combobox({
147
147
  styles.inputWrapper,
148
148
  {
149
149
  borderColor,
150
+ borderRadius: radius.field,
150
151
  backgroundColor: colors.colorBg,
151
152
  },
152
153
  disabled && { opacity: 0.4 },
@@ -1,8 +1,11 @@
1
1
  import { render, screen, fireEvent } from '@testing-library/react';
2
2
  import { ThemeProvider } from '../../../../native/ThemeContext';
3
+ import type { ThemeRadius } from '../../../../native/ThemeContext';
3
4
  import { FileInput } from './FileInput';
4
5
  import { cornerRadii } from '../../../../test/cornerRadii';
5
6
 
7
+ const radius12: ThemeRadius = { button: 12, iconButton: 12, field: 12 };
8
+
6
9
  function renderWithTheme(ui: React.ReactElement) {
7
10
  return render(<ThemeProvider>{ui}</ThemeProvider>);
8
11
  }
@@ -72,8 +75,17 @@ describe('FileInput (native)', () => {
72
75
  expect(screen.getByText('Resume *')).toBeInTheDocument();
73
76
  });
74
77
 
75
- it('gives the drop zone the medium border radius', () => {
78
+ it('gives the drop zone the medium border radius by default', () => {
76
79
  renderWithTheme(<FileInput label="Upload" name="upload" />);
77
80
  expect(cornerRadii(screen.getByRole('button'))).toEqual(['16px', '16px', '16px', '16px']);
78
81
  });
82
+
83
+ it('takes the border radius of the drop zone from the theme', () => {
84
+ render(
85
+ <ThemeProvider tokens={{ radius: radius12 }}>
86
+ <FileInput label="Upload" name="upload" />
87
+ </ThemeProvider>,
88
+ );
89
+ expect(cornerRadii(screen.getByRole('button'))).toEqual(['12px', '12px', '12px', '12px']);
90
+ });
79
91
  });
@@ -1,6 +1,6 @@
1
1
  import { StyleSheet, View, Pressable, StyleProp, ViewStyle } from 'react-native';
2
2
  import { ScaledText as Text } from '../../../../native/ScaledText';
3
- import { spacing, font, border, lineHeight } from '../../../../native/tokens';
3
+ import { spacing, font, lineHeight } from '../../../../native/tokens';
4
4
  import { useTheme } from '../../../../native/ThemeContext';
5
5
  import { Label } from '../atoms/Label';
6
6
  import { Message } from '../atoms/Message';
@@ -33,7 +33,6 @@ const styles = StyleSheet.create({
33
33
  paddingHorizontal: spacing.m,
34
34
  borderWidth: 2,
35
35
  borderStyle: 'dashed',
36
- borderRadius: border.radius.m,
37
36
  minHeight: 120,
38
37
  },
39
38
  icon: {
@@ -75,7 +74,7 @@ export function FileInput({
75
74
  fileNames = [],
76
75
  style,
77
76
  }: Readonly<FileInputProps>) {
78
- const { colors } = useTheme();
77
+ const { colors, radius } = useTheme();
79
78
 
80
79
  const borderColor = error
81
80
  ? colors.colorError
@@ -96,6 +95,7 @@ export function FileInput({
96
95
  styles.dropZone,
97
96
  {
98
97
  borderColor,
98
+ borderRadius: radius.field,
99
99
  backgroundColor: 'transparent',
100
100
  },
101
101
  pressed && { opacity: 0.7 },