Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### New Features

- Add `Tabs` primitive ([#74652](https://github.com/WordPress/gutenberg/pull/74652)).
- Add `Textarea` primitive ([#74707](https://github.com/WordPress/gutenberg/pull/74707)).

### Enhancements

Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/form/primitives/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * as Fieldset from './fieldset';
export { Input } from './input';
export { InputLayout } from './input-layout';
export * as Select from './select';
export { Textarea } from './textarea';
1 change: 1 addition & 0 deletions packages/ui/src/form/primitives/textarea/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Textarea } from './textarea';

Copy link
Copy Markdown
Contributor

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?

Copy link
Copy Markdown
Member Author

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.

Copy link
Copy Markdown
Contributor

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/onValueChange convention?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all components in the package should follow the same

Aside from a minor deviation for checkboxes and switches (checked instead of value). I guess a JSDoc code snippet would be most straightforward, when accounting for this kind of difference.

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,
},
};
22 changes: 22 additions & 0 deletions packages/ui/src/form/primitives/textarea/style.module.css
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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs decisions on #74393 and #74556 if we were to make this more robust.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 */
}
}
143 changes: 143 additions & 0 deletions packages/ui/src/form/primitives/textarea/test/index.test.tsx

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we test disabled / rows attributes, or do we trust we don't have to test for those?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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' );
} );
} );
51 changes: 51 additions & 0 deletions packages/ui/src/form/primitives/textarea/textarea.tsx
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 >(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add a JSDoc comment describing the component?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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 *Control versions are ready, so I can point to them in the description.

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 }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 className, ref, and restProps. Guessing rows could be a problem with the base component being typed for HTMLInputElement. Is there any way we can make it generic? Just trying to see if there's an option to avoid the wrapping logic.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically yes. It's either this or we make the props for Input generic, which isn't inherently simpler, and also just less type safe. ref, rows, and restProps all need to be of a Textarea type. This is probably the only place where we'll be doing something like this, if that makes it more palatable 😅

className is not about types, but just that the classes on the render element go to a different element (<textarea> itself) from the top-level class (a wrapper div).

) }
value={ value }
defaultValue={ defaultValue }
onValueChange={ onValueChange }
disabled={ disabled }
/>
);
}
);
18 changes: 18 additions & 0 deletions packages/ui/src/form/primitives/textarea/types.ts
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' ];
};
Loading