Skip to content

Commit 4fe04dc

Browse files
feat(components): implement TextArea component (#204)
## Summary - Multi-line text input with auto-grow, label, description, indicator, helper message, invalid, disabled, and read-only states - Character count display — shows `n` or `n / maxLength`; announces to screen readers when nearing the limit - Resize handle indicator in the bottom-right corner - Storybook default density changed from spacious to comfortable to match Figma designs (spacious still available via the settings toggle) ## Token mapping | Figma variable | Token | |---|---| | `--eds-color-bg-input` | `token.colors.bg.input` | | `--eds-color-bg-canvas` (danger) | `token.colors.bg.danger.canvas` | | `--eds-color-border-subtle` | `token.colors.border.neutral.subtle` | | `--eds-color-border-strong` | `token.colors.border.neutral.strong` | | `--eds-color-border-focus` | `token.colors.border.neutral.strong` (focused) | | `--eds-color-border-subtle` (danger) | `token.colors.border.danger.subtle` | | `--eds-color-border-strong` (danger) | `token.colors.border.danger.strong` | | `--eds-container-space-vertical` (12px) | `token.spacing.spacing.inset.lg.verticalSquished` | | `--eds-selectable-space-horizontal` (8px) | `token.spacing.spacing.inset.sm.horizontal` | ## Notes **`minHeight: 54` has no token equivalent** — the 54px minimum height is a hardcoded constant. No existing sizing token maps to a multi-line container height. This is flagged for the design alignment meeting alongside the `text-box-trim` / lineHeight visual gap discussion (Figma trims cap height; React Native doesn't — both issues are the same root cause). **`maxRows` deferred** — attempted but `onContentSizeChange` on iOS does not give reliable pixel-accurate measurements for row capping without knowing the precise rendered line height. Needs platform-native investigation or a design conversation about whether the feature is needed on mobile at all. ## Test plan - [x] Light and dark mode - [x] Comfortable and spacious density - [x] All states: default, focused, filled, invalid, read-only, disabled - [x] Character count with and without maxLength - [x] Controlled value reset syncs character count - [x] Grows freely as user types Closes #131
1 parent 7eaef66 commit 4fe04dc

8 files changed

Lines changed: 530 additions & 4 deletions

File tree

apps/storybook/app/(tabs)/components/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const sections = [
2626
data: [
2727
{ name: "Input", route: "input" },
2828
{ name: "Selection Controls", route: "selectioncontrols" },
29+
{ name: "TextArea", route: "textarea" },
2930
{ name: "TextField", route: "textfield" },
3031
],
3132
},
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { Section } from "@/components/Section";
2+
import { Surface } from "@/components/Surface";
3+
import { TextArea, Typography } from "@equinor/eds-mobile-components";
4+
import { useState } from "react";
5+
import { ScrollView } from "react-native";
6+
7+
export default function TextAreaScreen() {
8+
const [value, setValue] = useState("");
9+
const [charCountValue, setCharCountValue] = useState("");
10+
11+
return (
12+
<ScrollView contentInsetAdjustmentBehavior="automatic" keyboardShouldPersistTaps="handled">
13+
<Section>
14+
<Typography>
15+
TextArea is a multi-line text input. It grows automatically
16+
as the user types.
17+
</Typography>
18+
</Section>
19+
20+
<Section title="Basic" />
21+
<Surface>
22+
<TextArea
23+
label="Title"
24+
placeholder="Placeholder"
25+
value={value}
26+
onChangeText={setValue}
27+
/>
28+
</Surface>
29+
30+
<Section title="With description" />
31+
<Surface>
32+
<TextArea
33+
label="Title"
34+
description="Help with more details"
35+
placeholder="Placeholder"
36+
/>
37+
</Surface>
38+
39+
<Section title="With helper message" />
40+
<Surface>
41+
<TextArea
42+
label="Title"
43+
placeholder="Placeholder"
44+
helperMessage="Helper message"
45+
/>
46+
</Surface>
47+
48+
<Section title="Character count" />
49+
<Surface>
50+
<TextArea
51+
label="Title"
52+
placeholder="Placeholder"
53+
showCharacterCount
54+
maxLength={200}
55+
value={charCountValue}
56+
onChangeText={setCharCountValue}
57+
/>
58+
</Surface>
59+
60+
<Section title="Indicator" />
61+
<Surface>
62+
<TextArea
63+
label="Comments"
64+
indicator="(Optional)"
65+
placeholder="Placeholder"
66+
/>
67+
</Surface>
68+
69+
<Section title="Invalid" />
70+
<Surface>
71+
<TextArea
72+
label="Title"
73+
description="Help with more details"
74+
placeholder="Placeholder"
75+
helperMessage="This field is required"
76+
invalid
77+
/>
78+
</Surface>
79+
80+
<Section title="Read-only" />
81+
<Surface>
82+
<TextArea
83+
label="Title"
84+
value="This is a read-only value. Long-press to select and copy this text."
85+
readOnly
86+
/>
87+
</Surface>
88+
89+
<Section title="Disabled" />
90+
<Surface>
91+
<TextArea
92+
label="Title"
93+
description="Help with more details"
94+
placeholder="Placeholder"
95+
helperMessage="Helper message"
96+
disabled
97+
/>
98+
</Surface>
99+
</ScrollView>
100+
);
101+
}

apps/storybook/app/(tabs)/components/textfield.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ export default function TextFieldScreen() {
99
const [errorValue, setErrorValue] = useState("");
1010

1111
return (
12-
<ScrollView contentInsetAdjustmentBehavior="automatic" keyboardShouldPersistTaps="handled">
12+
<ScrollView
13+
contentInsetAdjustmentBehavior="automatic"
14+
keyboardShouldPersistTaps="handled"
15+
>
1316
<Section>
1417
<Typography>
1518
TextField combines a label, an input, and a helper message
@@ -77,8 +80,8 @@ export default function TextFieldScreen() {
7780

7881
<Section title="Indicator">
7982
<Typography>
80-
Use the indicator prop to show "(Required)" or "(Optional)"
81-
inline after the label.
83+
Use the indicator prop to show &quot;(Required)&quot; or
84+
&quot;(Optional)&quot; inline after the label.
8285
</Typography>
8386
</Section>
8487
<Surface>

apps/storybook/lib/store.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@ type AppStoreState = {
1111
export const useAppStore = create<AppStoreState>((set) => ({
1212
scheme: null as "light" | "dark" | null,
1313
setScheme: (scheme: "light" | "dark" | null) => set({ scheme }),
14-
density: "spacious",
14+
density: "comfortable",
1515
setDensity: (density: Density) => set({ density }),
1616
}));
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
## Features
2+
3+
- Multi-line text input that grows automatically as the user types.
4+
- Optional label, description, indicator, and helper message.
5+
- Character count display — shows `n` or `n / maxLength` when a limit is set.
6+
- Invalid state with error border and background.
7+
- Read-only state for non-editable values that can still be selected and copied.
8+
9+
## Usage
10+
11+
```tsx
12+
import { TextArea } from '@equinor/eds-mobile-components'
13+
14+
const [value, setValue] = useState('')
15+
16+
<TextArea
17+
label="Comments"
18+
placeholder="Enter your comments"
19+
value={value}
20+
onChangeText={setValue}
21+
/>
22+
```
23+
24+
## Examples
25+
26+
### With description and helper message
27+
28+
```tsx
29+
<TextArea
30+
label="Observations"
31+
description="Describe what you observed on site."
32+
helperMessage="Required before submitting"
33+
placeholder="Enter observations"
34+
value={value}
35+
onChangeText={setValue}
36+
/>
37+
```
38+
39+
### Character count
40+
41+
Pass `showCharacterCount` to show a live count below the field. Combine with `maxLength` to show `n / max`.
42+
43+
```tsx
44+
<TextArea
45+
label="Notes"
46+
showCharacterCount
47+
maxLength={500}
48+
value={value}
49+
onChangeText={setValue}
50+
/>
51+
```
52+
53+
### Invalid
54+
55+
```tsx
56+
<TextArea
57+
label="Description"
58+
invalid
59+
helperMessage="This field is required"
60+
value={value}
61+
onChangeText={setValue}
62+
/>
63+
```
64+
65+
### Read-only
66+
67+
```tsx
68+
<TextArea
69+
label="Submitted notes"
70+
value="These notes were submitted and cannot be edited."
71+
readOnly
72+
/>
73+
```
74+
75+
### Disabled
76+
77+
```tsx
78+
<TextArea
79+
label="Comments"
80+
placeholder="Not available"
81+
disabled
82+
/>
83+
```
84+
85+
## Props
86+
87+
| Prop | Type | Default | Description |
88+
|---|---|---|---|
89+
| `label` | `string` || Label displayed above the field |
90+
| `indicator` | `string` || Inline text after the label, e.g. `"(Optional)"` |
91+
| `description` | `string` || Descriptive text below the label |
92+
| `helperMessage` | `string` || Message shown below the field |
93+
| `invalid` | `boolean` | `false` | Error state — red border and background |
94+
| `disabled` | `boolean` | `false` | Disables all interaction |
95+
| `readOnly` | `boolean` | `false` | Prevents editing; value can still be selected and copied |
96+
| `showCharacterCount` | `boolean` | `false` | Shows a character count below the field |
97+
| `maxLength` | `number` || Maximum character count; shows `n / max` when combined with `showCharacterCount` |
98+
99+
Also accepts all React Native [`TextInputProps`](https://reactnative.dev/docs/textinput#props), excluding `multiline`, `editable`, `readOnly`, and `scrollEnabled` which are managed internally.
100+
101+
## Accessibility
102+
103+
- Reports `disabled` state to assistive technology via `accessibilityState`.
104+
- `accessibilityLabel` defaults to the `label` prop when not provided.
105+
- `accessibilityHint` defaults to a concatenation of `description` and `helperMessage` when not provided.
106+
- When `showCharacterCount` and `maxLength` are both set, the count is read aloud by screen readers once the user reaches 80% of the limit.
107+
- Always provide a `label` or `accessibilityLabel`.
108+
109+
## Related components
110+
111+
- `TextField` — single-line labelled input with the same field structure.
112+
- `Input` — bare single-line input without label or helper message.

0 commit comments

Comments
 (0)