@boostdev/design-system-react-native 0.6.0 → 0.8.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 +29 -0
- package/dist/index.cjs +14 -6
- package/dist/index.js +15 -7
- package/package.json +1 -1
- package/src/components/interaction/Button/Button.mdx +3 -1
- package/src/components/interaction/Button/Button.spec.tsx +26 -0
- package/src/components/interaction/Button/Button.stories.tsx +14 -4
- package/src/components/interaction/Button/Button.tsx +5 -4
- package/src/components/interaction/form/Textarea/Textarea.mdx +11 -1
- package/src/components/interaction/form/Textarea/Textarea.spec.tsx +81 -0
- package/src/components/interaction/form/Textarea/Textarea.stories.tsx +8 -0
- package/src/components/interaction/form/Textarea/Textarea.tsx +22 -4
- package/src/components/layout/ButtonGroup/ButtonGroup.stories.tsx +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
All notable changes to `@boostdev/design-system-react-native` are documented here.
|
|
4
|
+
## [0.8.0] - 2026-10-01
|
|
5
|
+
### Added
|
|
6
|
+
- Feat(textarea): height follows numberOfLines
|
|
7
|
+
|
|
8
|
+
React Native on iOS gives a multiline TextInput no height from
|
|
9
|
+
numberOfLines (Android does), so the field only had the fixed
|
|
10
|
+
minHeight of 100, about 3 lines at body size. The minimum height is now
|
|
11
|
+
numberOfLines * lineHeight.body + 2 * paddingVertical + 2 * border:
|
|
12
|
+
122 points for the default 4 lines, 148 for 5, 226 for 8, the same on
|
|
13
|
+
both platforms. The fixed 100 is gone.
|
|
14
|
+
|
|
15
|
+
The lines grow with the OS text size up to the ceiling of the input's
|
|
16
|
+
own text (maxFontScaleFor of the body font, 2), so 5 lines stay 5 lines
|
|
17
|
+
at large text. The padding and the border do not scale.
|
|
18
|
+
|
|
19
|
+
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> ([`b81043e`](https://gitlab.com/b00stdev/boostdev-design-system-react-native/-/commit/b81043e3f6eb3c0c5cc7bf4c41934e513a580de5))
|
|
20
|
+
|
|
21
|
+
## [0.7.0] - 2026-09-29
|
|
22
|
+
### Added
|
|
23
|
+
- Feat(button): ghost is outline without the border
|
|
24
|
+
|
|
25
|
+
`variant="ghost"` was a deprecated alias of `outline`. It is now its own
|
|
26
|
+
variant: a transparent background with the outline's text and icon
|
|
27
|
+
colour, and no border. For a quiet button, such as one beside a heading.
|
|
28
|
+
`outline` keeps its 1 px border. ButtonGroup stories that showed a
|
|
29
|
+
bordered second button now use `outline`.
|
|
30
|
+
|
|
31
|
+
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> ([`3be03dd`](https://gitlab.com/b00stdev/boostdev-design-system-react-native/-/commit/3be03dd7d56117bc37cb7d0323d1f533be7ae16f))
|
|
32
|
+
|
|
4
33
|
## [0.6.0] - 2026-09-29
|
|
5
34
|
### Added
|
|
6
35
|
- Feat(theme): radius overrides for buttons and fields
|
package/dist/index.cjs
CHANGED
|
@@ -2284,7 +2284,6 @@ function Button({
|
|
|
2284
2284
|
const primaryColor = colors2.colorInteractive;
|
|
2285
2285
|
const onPrimaryColor = colors2.colorOnInteractive;
|
|
2286
2286
|
const outlineTextColor = colors2.colorInteractiveOnBg;
|
|
2287
|
-
const isOutline = variant === "outline" || variant === "ghost";
|
|
2288
2287
|
const containerStyle = {
|
|
2289
2288
|
minHeight: height,
|
|
2290
2289
|
paddingHorizontal: isIconOnly ? import_native.spacing.xs : px,
|
|
@@ -2297,11 +2296,12 @@ function Button({
|
|
|
2297
2296
|
// instead, so a wrapped label grows the button from minHeight rather than clipping.
|
|
2298
2297
|
...isIconOnly ? { height } : { paddingVertical: import_native.spacing.xxs },
|
|
2299
2298
|
...variant === "default" && { backgroundColor: primaryColor },
|
|
2300
|
-
...
|
|
2299
|
+
...variant === "outline" && {
|
|
2301
2300
|
backgroundColor: "transparent",
|
|
2302
2301
|
borderWidth: 1,
|
|
2303
2302
|
borderColor: outlineTextColor
|
|
2304
|
-
}
|
|
2303
|
+
},
|
|
2304
|
+
...variant === "ghost" && { backgroundColor: "transparent" }
|
|
2305
2305
|
};
|
|
2306
2306
|
const textColor = variant === "default" ? onPrimaryColor : outlineTextColor;
|
|
2307
2307
|
return /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)(
|
|
@@ -3188,6 +3188,13 @@ function FormInput({
|
|
|
3188
3188
|
var import_react13 = require("react");
|
|
3189
3189
|
var import_react_native41 = require("react-native");
|
|
3190
3190
|
var import_jsx_runtime42 = require("react/jsx-runtime");
|
|
3191
|
+
var PADDING_VERTICAL = import_native.spacing.xs;
|
|
3192
|
+
var BORDER_WIDTH = 1;
|
|
3193
|
+
var FONT_SCALE_CEILING = maxFontScaleFor(import_native.font.size.body);
|
|
3194
|
+
function heightOfLines(numberOfLines, fontScale) {
|
|
3195
|
+
const lineScale = Math.min(fontScale, FONT_SCALE_CEILING);
|
|
3196
|
+
return numberOfLines * lineHeight.body * lineScale + 2 * PADDING_VERTICAL + 2 * BORDER_WIDTH;
|
|
3197
|
+
}
|
|
3191
3198
|
var styles37 = import_react_native41.StyleSheet.create({
|
|
3192
3199
|
container: {
|
|
3193
3200
|
gap: import_native.spacing.xxs
|
|
@@ -3197,9 +3204,8 @@ var styles37 = import_react_native41.StyleSheet.create({
|
|
|
3197
3204
|
fontSize: import_native.font.size.body,
|
|
3198
3205
|
lineHeight: lineHeight.body,
|
|
3199
3206
|
paddingHorizontal: import_native.spacing.s,
|
|
3200
|
-
paddingVertical:
|
|
3201
|
-
borderWidth:
|
|
3202
|
-
minHeight: 100,
|
|
3207
|
+
paddingVertical: PADDING_VERTICAL,
|
|
3208
|
+
borderWidth: BORDER_WIDTH,
|
|
3203
3209
|
textAlignVertical: "top"
|
|
3204
3210
|
}
|
|
3205
3211
|
});
|
|
@@ -3219,6 +3225,7 @@ function Textarea({
|
|
|
3219
3225
|
style
|
|
3220
3226
|
}) {
|
|
3221
3227
|
const { colors: colors2, radius } = useTheme();
|
|
3228
|
+
const { fontScale } = (0, import_react_native41.useWindowDimensions)();
|
|
3222
3229
|
const [focused, setFocused] = (0, import_react13.useState)(false);
|
|
3223
3230
|
const borderColor = error ? colors2.colorError : focused ? colors2.colorInteractive : colors2.colorOnBg;
|
|
3224
3231
|
return /* @__PURE__ */ (0, import_jsx_runtime42.jsxs)(import_react_native41.View, { style: [styles37.container, style], children: [
|
|
@@ -3248,6 +3255,7 @@ function Textarea({
|
|
|
3248
3255
|
style: [
|
|
3249
3256
|
styles37.textarea,
|
|
3250
3257
|
{
|
|
3258
|
+
minHeight: heightOfLines(numberOfLines, fontScale),
|
|
3251
3259
|
borderColor,
|
|
3252
3260
|
borderRadius: radius.field,
|
|
3253
3261
|
color: colors2.colorOnBg,
|
package/dist/index.js
CHANGED
|
@@ -2185,7 +2185,6 @@ function Button({
|
|
|
2185
2185
|
const primaryColor = colors2.colorInteractive;
|
|
2186
2186
|
const onPrimaryColor = colors2.colorOnInteractive;
|
|
2187
2187
|
const outlineTextColor = colors2.colorInteractiveOnBg;
|
|
2188
|
-
const isOutline = variant === "outline" || variant === "ghost";
|
|
2189
2188
|
const containerStyle = {
|
|
2190
2189
|
minHeight: height,
|
|
2191
2190
|
paddingHorizontal: isIconOnly ? spacing.xs : px,
|
|
@@ -2198,11 +2197,12 @@ function Button({
|
|
|
2198
2197
|
// instead, so a wrapped label grows the button from minHeight rather than clipping.
|
|
2199
2198
|
...isIconOnly ? { height } : { paddingVertical: spacing.xxs },
|
|
2200
2199
|
...variant === "default" && { backgroundColor: primaryColor },
|
|
2201
|
-
...
|
|
2200
|
+
...variant === "outline" && {
|
|
2202
2201
|
backgroundColor: "transparent",
|
|
2203
2202
|
borderWidth: 1,
|
|
2204
2203
|
borderColor: outlineTextColor
|
|
2205
|
-
}
|
|
2204
|
+
},
|
|
2205
|
+
...variant === "ghost" && { backgroundColor: "transparent" }
|
|
2206
2206
|
};
|
|
2207
2207
|
const textColor = variant === "default" ? onPrimaryColor : outlineTextColor;
|
|
2208
2208
|
return /* @__PURE__ */ jsxs16(
|
|
@@ -3087,8 +3087,15 @@ function FormInput({
|
|
|
3087
3087
|
|
|
3088
3088
|
// src/components/interaction/form/Textarea/Textarea.tsx
|
|
3089
3089
|
import { useState as useState9 } from "react";
|
|
3090
|
-
import { StyleSheet as StyleSheet38, View as View33 } from "react-native";
|
|
3090
|
+
import { StyleSheet as StyleSheet38, View as View33, useWindowDimensions } from "react-native";
|
|
3091
3091
|
import { jsx as jsx42, jsxs as jsxs25 } from "react/jsx-runtime";
|
|
3092
|
+
var PADDING_VERTICAL = spacing.xs;
|
|
3093
|
+
var BORDER_WIDTH = 1;
|
|
3094
|
+
var FONT_SCALE_CEILING = maxFontScaleFor(font.size.body);
|
|
3095
|
+
function heightOfLines(numberOfLines, fontScale) {
|
|
3096
|
+
const lineScale = Math.min(fontScale, FONT_SCALE_CEILING);
|
|
3097
|
+
return numberOfLines * lineHeight.body * lineScale + 2 * PADDING_VERTICAL + 2 * BORDER_WIDTH;
|
|
3098
|
+
}
|
|
3092
3099
|
var styles37 = StyleSheet38.create({
|
|
3093
3100
|
container: {
|
|
3094
3101
|
gap: spacing.xxs
|
|
@@ -3098,9 +3105,8 @@ var styles37 = StyleSheet38.create({
|
|
|
3098
3105
|
fontSize: font.size.body,
|
|
3099
3106
|
lineHeight: lineHeight.body,
|
|
3100
3107
|
paddingHorizontal: spacing.s,
|
|
3101
|
-
paddingVertical:
|
|
3102
|
-
borderWidth:
|
|
3103
|
-
minHeight: 100,
|
|
3108
|
+
paddingVertical: PADDING_VERTICAL,
|
|
3109
|
+
borderWidth: BORDER_WIDTH,
|
|
3104
3110
|
textAlignVertical: "top"
|
|
3105
3111
|
}
|
|
3106
3112
|
});
|
|
@@ -3120,6 +3126,7 @@ function Textarea({
|
|
|
3120
3126
|
style
|
|
3121
3127
|
}) {
|
|
3122
3128
|
const { colors: colors2, radius } = useTheme();
|
|
3129
|
+
const { fontScale } = useWindowDimensions();
|
|
3123
3130
|
const [focused, setFocused] = useState9(false);
|
|
3124
3131
|
const borderColor = error ? colors2.colorError : focused ? colors2.colorInteractive : colors2.colorOnBg;
|
|
3125
3132
|
return /* @__PURE__ */ jsxs25(View33, { style: [styles37.container, style], children: [
|
|
@@ -3149,6 +3156,7 @@ function Textarea({
|
|
|
3149
3156
|
style: [
|
|
3150
3157
|
styles37.textarea,
|
|
3151
3158
|
{
|
|
3159
|
+
minHeight: heightOfLines(numberOfLines, fontScale),
|
|
3152
3160
|
borderColor,
|
|
3153
3161
|
borderRadius: radius.field,
|
|
3154
3162
|
color: colors2.colorOnBg,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boostdev/design-system-react-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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
|
|
|
@@ -68,6 +69,7 @@ Set `isIconOnly` to render a 1:1 button (round by default, see [Shape](#shape))
|
|
|
68
69
|
|
|
69
70
|
<Canvas of={Stories.IconOnly} />
|
|
70
71
|
<Canvas of={Stories.IconOnlyOutline} />
|
|
72
|
+
<Canvas of={Stories.IconOnlyGhost} />
|
|
71
73
|
<Canvas of={Stories.AllIconOnly} />
|
|
72
74
|
|
|
73
75
|
## All variants × sizes
|
|
@@ -86,7 +88,7 @@ Set `isIconOnly` to render a 1:1 button (round by default, see [Shape](#shape))
|
|
|
86
88
|
|
|
87
89
|
| Prop | Type | Default | Description |
|
|
88
90
|
|------|------|---------|-------------|
|
|
89
|
-
| `variant` | `"default" \| "outline"` | `"default"` | Visual style
|
|
91
|
+
| `variant` | `"default" \| "outline" \| "ghost"` | `"default"` | Visual style: `ghost` is `outline` without the border |
|
|
90
92
|
| `size` | `"small" \| "medium" \| "large"` | `"medium"` | Height and horizontal padding |
|
|
91
93
|
| `disabled` | boolean | `false` | Prevents interaction, reduces opacity |
|
|
92
94
|
| `onPress` | `() => void` | — | Called when the button is pressed |
|
|
@@ -72,6 +72,32 @@ describe('Button (native)', () => {
|
|
|
72
72
|
expect(style).toMatch(/(^|[;\s])height:\s*56px/);
|
|
73
73
|
});
|
|
74
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
|
+
|
|
75
101
|
it.each(['small', 'medium', 'large'] as const)(
|
|
76
102
|
'gives a %s text button the medium border radius by default',
|
|
77
103
|
(size) => {
|
|
@@ -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
|
|
@@ -4,7 +4,7 @@ import { ScaledText as Text } from '../../../native/ScaledText';
|
|
|
4
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 {
|
|
@@ -59,13 +59,13 @@ export function Button({
|
|
|
59
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,
|
|
@@ -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;
|
|
@@ -37,6 +37,12 @@ function Example() {
|
|
|
37
37
|
### With placeholder
|
|
38
38
|
<Canvas of={Stories.WithPlaceholder} />
|
|
39
39
|
|
|
40
|
+
### Five lines
|
|
41
|
+
<Canvas of={Stories.FiveLines} />
|
|
42
|
+
|
|
43
|
+
### Eight lines
|
|
44
|
+
<Canvas of={Stories.EightLines} />
|
|
45
|
+
|
|
40
46
|
### With error
|
|
41
47
|
<Canvas of={Stories.WithError} />
|
|
42
48
|
|
|
@@ -62,13 +68,17 @@ function Example() {
|
|
|
62
68
|
| `value` | string | — | Controlled value |
|
|
63
69
|
| `defaultValue` | string | — | Uncontrolled default value |
|
|
64
70
|
| `placeholder` | string | — | Placeholder text |
|
|
65
|
-
| `numberOfLines` | number | `4` |
|
|
71
|
+
| `numberOfLines` | number | `4` | Height of the field in lines of text, the same on iOS and Android. It is the minimum height: the field still grows with longer text |
|
|
66
72
|
| `error` | string | — | Error message displayed below the input |
|
|
67
73
|
| `hint` | string | — | Hint text displayed below the input |
|
|
68
74
|
| `required` | boolean | `false` | Marks the field as required |
|
|
69
75
|
| `disabled` | boolean | `false` | Prevents interaction, reduces opacity |
|
|
70
76
|
| `onChange` | `(value: string) => void` | — | Called with the new text value |
|
|
71
77
|
|
|
78
|
+
## Height
|
|
79
|
+
|
|
80
|
+
The field is `numberOfLines` body lines tall, plus its vertical padding and border (at body size 4 lines are 122 points, 5 lines 148, 8 lines 226). The lines follow the OS text size up to the same ceiling as the text itself (`maxFontScaleFor`, 2 for body text), so 5 lines stay 5 lines at large text. The padding and the border do not scale.
|
|
81
|
+
|
|
72
82
|
## Accessibility
|
|
73
83
|
|
|
74
84
|
- The label is announced by screen readers via `accessibilityLabel`
|
|
@@ -3,6 +3,19 @@ import { ThemeProvider } from '../../../../native/ThemeContext';
|
|
|
3
3
|
import type { ThemeRadius } from '../../../../native/ThemeContext';
|
|
4
4
|
import { Textarea } from './Textarea';
|
|
5
5
|
import { cornerRadii } from '../../../../test/cornerRadii';
|
|
6
|
+
import { font, lineHeight, spacing } from '../../../../native/tokens';
|
|
7
|
+
import { maxFontScaleFor } from '../../../../native/fontScale';
|
|
8
|
+
|
|
9
|
+
// The OS text size, as `useWindowDimensions().fontScale` reports it; react-native-web always says 1.
|
|
10
|
+
const osText = vi.hoisted(() => ({ fontScale: 1 }));
|
|
11
|
+
|
|
12
|
+
vi.mock('react-native', async (importOriginal) => {
|
|
13
|
+
const actual = await importOriginal<typeof import('react-native')>();
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
useWindowDimensions: () => ({ ...actual.useWindowDimensions(), fontScale: osText.fontScale }),
|
|
17
|
+
};
|
|
18
|
+
});
|
|
6
19
|
|
|
7
20
|
const radius12: ThemeRadius = { button: 12, iconButton: 12, field: 12 };
|
|
8
21
|
|
|
@@ -10,7 +23,23 @@ function renderWithTheme(ui: React.ReactElement) {
|
|
|
10
23
|
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
|
11
24
|
}
|
|
12
25
|
|
|
26
|
+
const BORDER = 1;
|
|
27
|
+
|
|
28
|
+
/** `lines` body lines at the OS text size (capped like the input's own text), plus padding and border. */
|
|
29
|
+
function heightOfLines(lines: number, fontScale = 1): number {
|
|
30
|
+
const scale = Math.min(fontScale, maxFontScaleFor(font.size.body));
|
|
31
|
+
return lines * lineHeight.body * scale + 2 * spacing.xs + 2 * BORDER;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function minHeightOfInput(): number {
|
|
35
|
+
return Number.parseFloat(getComputedStyle(screen.getByRole('textbox')).minHeight);
|
|
36
|
+
}
|
|
37
|
+
|
|
13
38
|
describe('Textarea (native)', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
osText.fontScale = 1;
|
|
41
|
+
});
|
|
42
|
+
|
|
14
43
|
it('renders the label', () => {
|
|
15
44
|
renderWithTheme(<Textarea label="Message" name="message" />);
|
|
16
45
|
expect(screen.getByText('Message')).toBeInTheDocument();
|
|
@@ -65,4 +94,56 @@ describe('Textarea (native)', () => {
|
|
|
65
94
|
);
|
|
66
95
|
expect(cornerRadii(screen.getByRole('textbox'))).toEqual(['12px', '12px', '12px', '12px']);
|
|
67
96
|
});
|
|
97
|
+
|
|
98
|
+
describe('height', () => {
|
|
99
|
+
it('is 4 lines tall by default', () => {
|
|
100
|
+
renderWithTheme(<Textarea label="Message" name="message" />);
|
|
101
|
+
expect(minHeightOfInput()).toBe(heightOfLines(4));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('is 148 points tall for 5 lines at body size', () => {
|
|
105
|
+
renderWithTheme(<Textarea label="Message" name="message" numberOfLines={5} />);
|
|
106
|
+
expect(minHeightOfInput()).toBe(148);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('is as tall as the lines it shows, for 5 lines', () => {
|
|
110
|
+
renderWithTheme(<Textarea label="Message" name="message" numberOfLines={5} />);
|
|
111
|
+
expect(minHeightOfInput()).toBe(heightOfLines(5));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('is as tall as the lines it shows, for 8 lines', () => {
|
|
115
|
+
renderWithTheme(<Textarea label="Message" name="message" numberOfLines={8} />);
|
|
116
|
+
expect(minHeightOfInput()).toBe(heightOfLines(8));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('has no fixed floor, so a single line is one line tall', () => {
|
|
120
|
+
renderWithTheme(<Textarea label="Message" name="message" numberOfLines={1} />);
|
|
121
|
+
expect(minHeightOfInput()).toBe(heightOfLines(1));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('grows with the OS text size, so 5 lines stay 5 lines', () => {
|
|
125
|
+
osText.fontScale = 1.5;
|
|
126
|
+
renderWithTheme(<Textarea label="Message" name="message" numberOfLines={5} />);
|
|
127
|
+
expect(minHeightOfInput()).toBe(213);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('stops growing where the text stops, at the ceiling of the body font', () => {
|
|
131
|
+
osText.fontScale = 3;
|
|
132
|
+
renderWithTheme(<Textarea label="Message" name="message" numberOfLines={5} />);
|
|
133
|
+
expect(minHeightOfInput()).toBe(278);
|
|
134
|
+
expect(minHeightOfInput()).toBe(heightOfLines(5, maxFontScaleFor(font.size.body)));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('shrinks with a reduced OS text size', () => {
|
|
138
|
+
osText.fontScale = 0.8;
|
|
139
|
+
renderWithTheme(<Textarea label="Message" name="message" numberOfLines={5} />);
|
|
140
|
+
expect(minHeightOfInput()).toBeCloseTo(heightOfLines(5, 0.8), 5);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('does not scale the padding and the border', () => {
|
|
144
|
+
osText.fontScale = 2;
|
|
145
|
+
renderWithTheme(<Textarea label="Message" name="message" numberOfLines={1} />);
|
|
146
|
+
expect(minHeightOfInput()).toBe(lineHeight.body * 2 + 2 * spacing.xs + 2 * BORDER);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
68
149
|
});
|
|
@@ -22,6 +22,14 @@ export const WithPlaceholder: Story = {
|
|
|
22
22
|
args: { label: 'Bio', name: 'bio', placeholder: 'Tell us about yourself...' },
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
export const FiveLines: Story = {
|
|
26
|
+
args: { label: 'Reason', name: 'reason', numberOfLines: 5 },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const EightLines: Story = {
|
|
30
|
+
args: { label: 'Notes', name: 'notes', numberOfLines: 8 },
|
|
31
|
+
};
|
|
32
|
+
|
|
25
33
|
export const WithError: Story = {
|
|
26
34
|
args: { label: 'Message', name: 'message', error: 'Message is required.' },
|
|
27
35
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ReactNode, useState } from 'react';
|
|
2
|
-
import { StyleSheet, View, StyleProp, ViewStyle } from 'react-native';
|
|
2
|
+
import { StyleSheet, View, StyleProp, ViewStyle, useWindowDimensions } from 'react-native';
|
|
3
3
|
import { ScaledTextInput as TextInput } from '../../../../native/ScaledText';
|
|
4
|
+
import { maxFontScaleFor } from '../../../../native/fontScale';
|
|
4
5
|
import { spacing, font, lineHeight } from '../../../../native/tokens';
|
|
5
6
|
import { useTheme } from '../../../../native/ThemeContext';
|
|
6
7
|
import { Label } from '../atoms/Label';
|
|
@@ -22,6 +23,22 @@ interface TextareaProps {
|
|
|
22
23
|
style?: StyleProp<ViewStyle>;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
const PADDING_VERTICAL = spacing.xs;
|
|
27
|
+
const BORDER_WIDTH = 1;
|
|
28
|
+
/** The ceiling the input's own text gets (`ScaledTextInput`), from its font size. */
|
|
29
|
+
const FONT_SCALE_CEILING = maxFontScaleFor(font.size.body);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The height of `numberOfLines` lines in the field. React Native on iOS gives a multiline
|
|
33
|
+
* `TextInput` no height from `numberOfLines` (Android does), so the field sets it itself. The
|
|
34
|
+
* lines grow with the OS text size up to the same ceiling as the text, so they stay that many
|
|
35
|
+
* lines at large text; the padding and the border do not scale.
|
|
36
|
+
*/
|
|
37
|
+
function heightOfLines(numberOfLines: number, fontScale: number): number {
|
|
38
|
+
const lineScale = Math.min(fontScale, FONT_SCALE_CEILING);
|
|
39
|
+
return numberOfLines * lineHeight.body * lineScale + 2 * PADDING_VERTICAL + 2 * BORDER_WIDTH;
|
|
40
|
+
}
|
|
41
|
+
|
|
25
42
|
const styles = StyleSheet.create({
|
|
26
43
|
container: {
|
|
27
44
|
gap: spacing.xxs,
|
|
@@ -31,9 +48,8 @@ const styles = StyleSheet.create({
|
|
|
31
48
|
fontSize: font.size.body,
|
|
32
49
|
lineHeight: lineHeight.body,
|
|
33
50
|
paddingHorizontal: spacing.s,
|
|
34
|
-
paddingVertical:
|
|
35
|
-
borderWidth:
|
|
36
|
-
minHeight: 100,
|
|
51
|
+
paddingVertical: PADDING_VERTICAL,
|
|
52
|
+
borderWidth: BORDER_WIDTH,
|
|
37
53
|
textAlignVertical: 'top',
|
|
38
54
|
},
|
|
39
55
|
});
|
|
@@ -54,6 +70,7 @@ export function Textarea({
|
|
|
54
70
|
style,
|
|
55
71
|
}: Readonly<TextareaProps>) {
|
|
56
72
|
const { colors, radius } = useTheme();
|
|
73
|
+
const { fontScale } = useWindowDimensions();
|
|
57
74
|
const [focused, setFocused] = useState(false);
|
|
58
75
|
|
|
59
76
|
const borderColor = error
|
|
@@ -88,6 +105,7 @@ export function Textarea({
|
|
|
88
105
|
style={[
|
|
89
106
|
styles.textarea,
|
|
90
107
|
{
|
|
108
|
+
minHeight: heightOfLines(numberOfLines, fontScale),
|
|
91
109
|
borderColor,
|
|
92
110
|
borderRadius: radius.field,
|
|
93
111
|
color: colors.colorOnBg,
|
|
@@ -22,7 +22,7 @@ export const Flow: Story = {
|
|
|
22
22
|
render: (args) => (
|
|
23
23
|
<ButtonGroup {...args} variant="flow">
|
|
24
24
|
<Button>Primary</Button>
|
|
25
|
-
<Button variant="
|
|
25
|
+
<Button variant="outline">Secondary</Button>
|
|
26
26
|
</ButtonGroup>
|
|
27
27
|
),
|
|
28
28
|
};
|
|
@@ -30,7 +30,7 @@ export const Card: Story = {
|
|
|
30
30
|
render: (args) => (
|
|
31
31
|
<ButtonGroup {...args} variant="card">
|
|
32
32
|
<Button>Confirm</Button>
|
|
33
|
-
<Button variant="
|
|
33
|
+
<Button variant="outline">Cancel</Button>
|
|
34
34
|
</ButtonGroup>
|
|
35
35
|
),
|
|
36
36
|
};
|
|
@@ -38,7 +38,7 @@ export const Modal: Story = {
|
|
|
38
38
|
render: (args) => (
|
|
39
39
|
<ButtonGroup {...args} variant="modal">
|
|
40
40
|
<Button>Save</Button>
|
|
41
|
-
<Button variant="
|
|
41
|
+
<Button variant="outline">Discard</Button>
|
|
42
42
|
</ButtonGroup>
|
|
43
43
|
),
|
|
44
44
|
};
|