@boostdev/design-system-react-native 0.7.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 CHANGED
@@ -1,6 +1,23 @@
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
+
4
21
  ## [0.7.0] - 2026-09-29
5
22
  ### Added
6
23
  - Feat(button): ghost is outline without the border
package/dist/index.cjs CHANGED
@@ -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: import_native.spacing.xs,
3201
- borderWidth: 1,
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
@@ -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: spacing.xs,
3102
- borderWidth: 1,
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.7.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",
@@ -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` | Number of visible lines |
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: spacing.xs,
35
- borderWidth: 1,
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,