Repository navigation
UI: Add Textarea primitive
#74707
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
UI: Add Textarea primitive
#74707
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { Textarea } from './textarea'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import type { Meta, StoryObj } from '@storybook/react-vite'; | ||
| import { Textarea } from '../index'; | ||
|
|
||
| const meta: Meta< typeof Textarea > = { | ||
| title: 'Design System/Components/Form/Primitives/Textarea', | ||
| component: Textarea, | ||
| }; | ||
| export default meta; | ||
|
|
||
| type Story = StoryObj< typeof Textarea >; | ||
|
|
||
| export const Default: Story = { | ||
| args: { | ||
| placeholder: 'Placeholder', | ||
| }, | ||
| }; | ||
|
|
||
| export const Disabled: Story = { | ||
| args: { | ||
| ...Default.args, | ||
| disabled: true, | ||
| }, | ||
| }; | ||
|
|
||
| export const WithOverflow: Story = { | ||
| args: { | ||
| ...Default.args, | ||
| defaultValue: `Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`, | ||
| }, | ||
| }; | ||
|
|
||
| /** | ||
| * When `rows` is set to `1`, the textarea will have the same footprint as a default `Input`. | ||
| */ | ||
| export const WithOneRow: Story = { | ||
| args: { | ||
| ...Default.args, | ||
| rows: 1, | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| @layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides; | ||
|
|
||
| @layer wp-ui-components { | ||
| .wrapper { | ||
| --wp-ui-textarea-min-height: 40px; | ||
| } | ||
|
|
||
| .textarea { | ||
| /* Prevents users from resizing the textarea below this height. */ | ||
| min-height: calc(var(--wp-ui-textarea-min-height) - 2px); | ||
| resize: block; | ||
| } | ||
| } | ||
|
|
||
| @layer wp-ui-compositions { | ||
| .wrapper { | ||
| --wp-ui-input-padding-block: 9.9px; | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm still thinking unit-less line height is more versatile, and it would allow us to have just a few values that can be combined with all font sizes if / when necessary. If we want to use line height to establish an element's height, I'd be almost more inclined to use a size/height token for the line-height CSS property to really show what's the intent. |
||
|
|
||
| height: auto; | ||
| line-height: 1.4; /* TODO: Use variable */ | ||
| } | ||
| } | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm adding more tests than the other primitives here, since we're doing a good amount of integration in the implementation.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we test
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in d70c2ca |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| import { render, screen } from '@testing-library/react'; | ||
| import userEvent from '@testing-library/user-event'; | ||
| import { createRef, useState } from '@wordpress/element'; | ||
| import { Textarea } from '../index'; | ||
|
|
||
| describe( 'Textarea', () => { | ||
| it( 'forwards ref', () => { | ||
| const ref = createRef< HTMLTextAreaElement >(); | ||
|
|
||
| render( <Textarea ref={ ref } /> ); | ||
|
|
||
| expect( ref.current ).toBeInstanceOf( HTMLTextAreaElement ); | ||
| } ); | ||
|
|
||
| describe( 'value prop', () => { | ||
| it( 'renders with controlled value', () => { | ||
| render( <Textarea value="Hello, world!" /> ); | ||
|
|
||
| const textarea = screen.getByRole( 'textbox' ); | ||
| expect( textarea ).toHaveValue( 'Hello, world!' ); | ||
| } ); | ||
| } ); | ||
|
|
||
| describe( 'defaultValue prop', () => { | ||
| it( 'renders with default value', () => { | ||
| render( <Textarea defaultValue="Default content" /> ); | ||
|
|
||
| const textarea = screen.getByRole( 'textbox' ); | ||
| expect( textarea ).toHaveValue( 'Default content' ); | ||
| } ); | ||
|
|
||
| it( 'allows user to modify uncontrolled value', async () => { | ||
| const user = userEvent.setup(); | ||
| render( <Textarea defaultValue="Default content" /> ); | ||
|
|
||
| const textarea = screen.getByRole( 'textbox' ); | ||
| expect( textarea ).toHaveValue( 'Default content' ); | ||
|
|
||
| // Clear and type new content | ||
| await user.clear( textarea ); | ||
| await user.type( textarea, 'New content' ); | ||
|
|
||
| expect( textarea ).toHaveValue( 'New content' ); | ||
| } ); | ||
| } ); | ||
|
|
||
| describe( 'onValueChange prop', () => { | ||
| it( 'calls onValueChange when user types', async () => { | ||
| const user = userEvent.setup(); | ||
| const handleValueChange = jest.fn(); | ||
|
|
||
| render( | ||
| <Textarea defaultValue="" onValueChange={ handleValueChange } /> | ||
| ); | ||
|
|
||
| const textarea = screen.getByRole( 'textbox' ); | ||
|
|
||
| await user.type( textarea, 'Hello' ); | ||
|
|
||
| expect( handleValueChange ).toHaveBeenLastCalledWith( | ||
| 'Hello', | ||
| expect.any( Object ) | ||
| ); | ||
|
|
||
| await user.clear( textarea ); | ||
|
|
||
| expect( handleValueChange ).toHaveBeenLastCalledWith( | ||
| '', | ||
| expect.any( Object ) | ||
| ); | ||
| } ); | ||
|
|
||
| it( 'works with controlled component pattern', async () => { | ||
| const user = userEvent.setup(); | ||
| const handleValueChange = jest.fn(); | ||
|
|
||
| const ControlledTextarea = () => { | ||
| const [ value, setValue ] = useState( 'Initial' ); | ||
|
|
||
| return ( | ||
| <Textarea | ||
| value={ value } | ||
| onValueChange={ ( newValue ) => { | ||
| handleValueChange( newValue ); | ||
| setValue( newValue ); | ||
| } } | ||
| /> | ||
| ); | ||
| }; | ||
|
|
||
| render( <ControlledTextarea /> ); | ||
|
|
||
| const textarea = screen.getByRole( 'textbox' ); | ||
|
|
||
| await user.clear( textarea ); | ||
| await user.type( textarea, 'Updated' ); | ||
|
|
||
| expect( handleValueChange ).toHaveBeenLastCalledWith( 'Updated' ); | ||
| } ); | ||
| } ); | ||
|
|
||
| describe( 'render prop', () => { | ||
| it( 'correctly merges props with custom render function', () => { | ||
| render( | ||
| <Textarea | ||
| render={ ( props ) => ( | ||
| <div data-testid="my-render" { ...props } /> | ||
| ) } | ||
| data-my-attribute | ||
| /> | ||
| ); | ||
|
|
||
| expect( screen.getByTestId( 'my-render' ) ).toHaveAttribute( | ||
| 'data-my-attribute' | ||
| ); | ||
| } ); | ||
|
|
||
| it( 'correctly merges props with custom render element', () => { | ||
| render( | ||
| <Textarea | ||
| render={ <div data-testid="my-render" /> } | ||
| data-my-attribute | ||
| /> | ||
| ); | ||
|
|
||
| expect( screen.getByTestId( 'my-render' ) ).toHaveAttribute( | ||
| 'data-my-attribute' | ||
| ); | ||
| } ); | ||
| } ); | ||
|
|
||
| it( 'disables the textarea when disabled prop is true', () => { | ||
| render( <Textarea disabled /> ); | ||
|
|
||
| expect( screen.getByRole( 'textbox' ) ).toBeDisabled(); | ||
| } ); | ||
|
|
||
| it( 'applies custom rows value', () => { | ||
| render( <Textarea rows={ 10 } /> ); | ||
|
|
||
| expect( screen.getByRole( 'textbox' ) ).toHaveAttribute( 'rows', '10' ); | ||
| } ); | ||
| } ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import { mergeProps } from '@base-ui/react'; | ||
| import clsx from 'clsx'; | ||
| import { cloneElement, forwardRef } from '@wordpress/element'; | ||
| import styles from './style.module.css'; | ||
| import type { TextareaProps } from './types'; | ||
| import { Input } from '../input'; | ||
|
|
||
| const wrappedRender = ( | ||
| render: NonNullable< TextareaProps[ 'render' ] >, | ||
| restProps: TextareaProps & { ref: React.Ref< HTMLTextAreaElement > } | ||
| ) => { | ||
| return function Render( | ||
| props: React.HTMLAttributes< HTMLTextAreaElement > | ||
| ) { | ||
| return typeof render === 'function' | ||
| ? render( mergeProps( props, restProps ) ) | ||
| : cloneElement( render, mergeProps( props, restProps ) ); | ||
| }; | ||
| }; | ||
|
|
||
| export const Textarea = forwardRef< HTMLTextAreaElement, TextareaProps >( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we add a JSDoc comment describing the component?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I'll go in and add them for all the form primitives once the |
||
| function Textarea( | ||
| { | ||
| className, | ||
| defaultValue, | ||
| disabled, | ||
| onValueChange, | ||
| render, | ||
| rows = 4, | ||
| style, | ||
| value, | ||
| ...restProps | ||
| }, | ||
| ref | ||
| ) { | ||
| return ( | ||
| <Input | ||
| className={ clsx( styles.wrapper, className ) } | ||
| style={ style } | ||
| render={ wrappedRender( | ||
| render || ( ( props ) => <textarea { ...props } /> ), | ||
| { className: styles.textarea, ref, rows, ...restProps } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do these need to be applied here vs. the top-level component? Particularly
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Basically yes. It's either this or we make the props for
|
||
| ) } | ||
| value={ value } | ||
| defaultValue={ defaultValue } | ||
| onValueChange={ onValueChange } | ||
| disabled={ disabled } | ||
| /> | ||
| ); | ||
| } | ||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import type { InputProps } from '../input/types'; | ||
| import type { ComponentProps } from '../../../utils/types'; | ||
|
|
||
| export type TextareaProps = Omit< | ||
| ComponentProps< 'textarea' >, | ||
| 'disabled' | 'rows' | 'value' | 'defaultValue' | ||
| > & | ||
| Pick< | ||
| InputProps, | ||
| 'value' | 'defaultValue' | 'onValueChange' | 'disabled' | ||
| > & { | ||
| /** | ||
| * The number of rows the textarea should contain. | ||
| * | ||
| * @default 4 | ||
| */ | ||
| rows?: React.ComponentProps< 'textarea' >[ 'rows' ]; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the deal with controlled components? Should we add an example?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know 🤔 It would get tedious to add controlled code samples for all components. Maybe we can have the code snippet in the JSDoc descriptions to all be controlled examples? Seems like that's what we do in
@wordpress/components.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah. Or otherwise have a MDX document at the top of the folder to explain controlled vs uncontrolled — should be feasible since all components in the package should follow the same
value/defaultValue/onValueChangeconvention?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Aside from a minor deviation for checkboxes and switches (
checkedinstead ofvalue). I guess a JSDoc code snippet would be most straightforward, when accounting for this kind of difference.