@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.
- package/CHANGELOG.md +44 -0
- package/README.md +35 -1
- package/dist/index.cjs +40 -27
- package/dist/index.d.cts +17 -1
- package/dist/index.d.ts +17 -1
- package/dist/index.js +40 -27
- package/package.json +1 -1
- package/src/components/interaction/Button/Button.mdx +13 -3
- package/src/components/interaction/Button/Button.spec.tsx +88 -2
- package/src/components/interaction/Button/Button.stories.tsx +14 -4
- package/src/components/interaction/Button/Button.tsx +9 -8
- package/src/components/interaction/form/Combobox/Combobox.spec.tsx +14 -1
- package/src/components/interaction/form/Combobox/Combobox.tsx +4 -3
- package/src/components/interaction/form/FileInput/FileInput.spec.tsx +13 -1
- package/src/components/interaction/form/FileInput/FileInput.tsx +3 -3
- package/src/components/interaction/form/FormInput/FormInput.spec.tsx +14 -1
- package/src/components/interaction/form/FormInput/FormInput.tsx +5 -4
- package/src/components/interaction/form/NumberInput/NumberInput.spec.tsx +14 -1
- package/src/components/interaction/form/NumberInput/NumberInput.tsx +7 -6
- package/src/components/interaction/form/Select/Select.mdx +1 -1
- package/src/components/interaction/form/Select/Select.spec.tsx +14 -1
- package/src/components/interaction/form/Select/Select.tsx +2 -2
- package/src/components/interaction/form/Textarea/Textarea.spec.tsx +13 -1
- package/src/components/interaction/form/Textarea/Textarea.tsx +3 -3
- package/src/components/layout/ButtonGroup/ButtonGroup.stories.tsx +3 -3
- package/src/index.ts +1 -1
- package/src/native/ThemeContext.spec.tsx +81 -1
- 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 {
|
|
26
|
-
|
|
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
|
-
//
|
|
2183
|
-
borderRadius: isIconOnly ?
|
|
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
|
-
...
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
4
|
+
import { spacing, font } from '../../../native/tokens';
|
|
5
5
|
import { useTheme } from '../../../native/ThemeContext';
|
|
6
6
|
|
|
7
|
-
type ButtonVariant = 'default' | 'outline' | 'ghost'; // 'ghost' is
|
|
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
|
-
//
|
|
73
|
-
borderRadius: isIconOnly ?
|
|
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
|
-
...(
|
|
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
|
|
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,
|
|
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 },
|