Add components TextInput, TextArea, and FormStack (#37705)

This commit is contained in:
diondiondion
2026-02-03 11:02:13 +01:00
committed by GitHub
parent c1272c4b68
commit 218ca36653
9 changed files with 218 additions and 109 deletions

View File

@@ -0,0 +1,7 @@
.stack {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 25px;
padding: 16px;
}

View File

@@ -0,0 +1,23 @@
import classNames from 'classnames';
import { polymorphicForwardRef } from '@/types/polymorphic';
import classes from './form_stack.module.scss';
/**
* A simple wrapper for providing consistent spacing to a group of form fields.
*/
export const FormStack = polymorphicForwardRef<'div'>(
({ as: Element = 'div', children, className, ...otherProps }, ref) => (
<Element
ref={ref}
{...otherProps}
className={classNames(className, classes.stack)}
>
{children}
</Element>
),
);
FormStack.displayName = 'FormStack';

View File

@@ -1,5 +1,6 @@
export { TextInputField } from './text_input_field';
export { TextAreaField } from './text_area_field';
export { FormStack } from './form_stack';
export { TextInputField, TextInput } from './text_input_field';
export { TextAreaField, TextArea } from './text_area_field';
export { CheckboxField, Checkbox } from './checkbox_field';
export { ToggleField, Toggle } from './toggle_field';
export { SelectField, Select } from './select_field';

View File

@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { TextAreaField } from './text_area_field';
import { TextAreaField, TextArea } from './text_area_field';
const meta = {
title: 'Components/Form Fields/TextAreaField',
@@ -9,14 +9,6 @@ const meta = {
label: 'Label',
hint: 'This is a description of this form field',
},
render(args) {
// Component styles require a wrapper class at the moment
return (
<div className='simple_form'>
<TextAreaField {...args} />
</div>
);
},
} satisfies Meta<typeof TextAreaField>;
export default meta;
@@ -49,3 +41,17 @@ export const WithError: Story = {
hasError: true,
},
};
export const Plain: Story = {
render(args) {
return <TextArea {...args} />;
},
};
export const Disabled: Story = {
...Plain,
args: {
disabled: true,
defaultValue: "This value can't be changed",
},
};

View File

@@ -1,8 +1,11 @@
import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef } from 'react';
import classNames from 'classnames';
import { FormFieldWrapper } from './form_field_wrapper';
import type { CommonFieldWrapperProps } from './form_field_wrapper';
import classes from './text_input.module.scss';
interface Props
extends ComponentPropsWithoutRef<'textarea'>, CommonFieldWrapperProps {}
@@ -23,9 +26,22 @@ export const TextAreaField = forwardRef<HTMLTextAreaElement, Props>(
hasError={hasError}
inputId={id}
>
{(inputProps) => <textarea {...otherProps} {...inputProps} ref={ref} />}
{(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper>
),
);
TextAreaField.displayName = 'TextAreaField';
export const TextArea = forwardRef<
HTMLTextAreaElement,
ComponentPropsWithoutRef<'textarea'>
>(({ className, ...otherProps }, ref) => (
<textarea
{...otherProps}
className={classNames(className, classes.input)}
ref={ref}
/>
));
TextArea.displayName = 'TextArea';

View File

@@ -0,0 +1,42 @@
.input {
box-sizing: border-box;
display: block;
resize: vertical;
width: 100%;
padding: 10px 16px;
font-family: inherit;
font-size: 14px;
line-height: 20px;
color: var(--color-text-primary);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-primary);
border-radius: 4px;
outline: var(--outline-focus-default);
outline-color: transparent;
outline-offset: -1px;
transition: outline-color 0.15s ease-out;
@media screen and (width <= 600px) {
font-size: 16px;
}
&:focus {
outline-color: var(--color-text-brand);
}
&:focus:user-invalid,
&:required:user-invalid,
[data-has-error='true'] & {
outline-color: var(--color-text-error);
}
&:required:user-valid {
outline-color: var(--color-text-success);
}
&:disabled {
color: var(--color-text-disabled);
border-color: transparent;
cursor: not-allowed;
}
}

View File

@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { TextInputField } from './text_input_field';
import { TextInputField, TextInput } from './text_input_field';
const meta = {
title: 'Components/Form Fields/TextInputField',
@@ -9,14 +9,6 @@ const meta = {
label: 'Label',
hint: 'This is a description of this form field',
},
render(args) {
// Component styles require a wrapper class at the moment
return (
<div className='simple_form'>
<TextInputField {...args} />
</div>
);
},
} satisfies Meta<typeof TextInputField>;
export default meta;
@@ -49,3 +41,17 @@ export const WithError: Story = {
hasError: true,
},
};
export const Plain: Story = {
render(args) {
return <TextInput {...args} />;
},
};
export const Disabled: Story = {
...Plain,
args: {
disabled: true,
defaultValue: "This value can't be changed",
},
};

View File

@@ -1,8 +1,11 @@
import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef } from 'react';
import classNames from 'classnames';
import { FormFieldWrapper } from './form_field_wrapper';
import type { CommonFieldWrapperProps } from './form_field_wrapper';
import classes from './text_input.module.scss';
interface Props
extends ComponentPropsWithoutRef<'input'>, CommonFieldWrapperProps {}
@@ -15,10 +18,7 @@ interface Props
*/
export const TextInputField = forwardRef<HTMLInputElement, Props>(
(
{ id, label, hint, hasError, required, type = 'text', ...otherProps },
ref,
) => (
({ id, label, hint, hasError, required, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
@@ -26,11 +26,23 @@ export const TextInputField = forwardRef<HTMLInputElement, Props>(
hasError={hasError}
inputId={id}
>
{(inputProps) => (
<input type={type} {...otherProps} {...inputProps} ref={ref} />
)}
{(inputProps) => <TextInput {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper>
),
);
TextInputField.displayName = 'TextInputField';
export const TextInput = forwardRef<
HTMLInputElement,
ComponentPropsWithoutRef<'input'>
>(({ type = 'text', className, ...otherProps }, ref) => (
<input
type={type}
{...otherProps}
className={classNames(className, classes.input)}
ref={ref}
/>
));
TextInput.displayName = 'TextInput';

View File

@@ -16,7 +16,11 @@ import type {
import { Button } from 'mastodon/components/button';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { CheckboxField, TextAreaField } from 'mastodon/components/form_fields';
import {
CheckboxField,
FormStack,
TextAreaField,
} from 'mastodon/components/form_fields';
import { TextInputField } from 'mastodon/components/form_fields/text_input_field';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import {
@@ -129,88 +133,80 @@ const CollectionSettings: React.FC<{
);
return (
<form className='simple_form app-form' onSubmit={handleSubmit}>
<div className='fields-group'>
<TextInputField
required
label={
<FormattedMessage
id='collections.collection_name'
defaultMessage='Name'
/>
}
hint={
<FormattedMessage
id='collections.name_length_hint'
defaultMessage='40 characters limit'
/>
}
value={name}
onChange={handleNameChange}
maxLength={40}
/>
</div>
<FormStack as='form' onSubmit={handleSubmit}>
<TextInputField
required
label={
<FormattedMessage
id='collections.collection_name'
defaultMessage='Name'
/>
}
hint={
<FormattedMessage
id='collections.name_length_hint'
defaultMessage='40 characters limit'
/>
}
value={name}
onChange={handleNameChange}
maxLength={40}
/>
<div className='fields-group'>
<TextAreaField
required
label={
<FormattedMessage
id='collections.collection_description'
defaultMessage='Description'
/>
}
hint={
<FormattedMessage
id='collections.description_length_hint'
defaultMessage='100 characters limit'
/>
}
value={description}
onChange={handleDescriptionChange}
maxLength={100}
/>
</div>
<TextAreaField
required
label={
<FormattedMessage
id='collections.collection_description'
defaultMessage='Description'
/>
}
hint={
<FormattedMessage
id='collections.description_length_hint'
defaultMessage='100 characters limit'
/>
}
value={description}
onChange={handleDescriptionChange}
maxLength={100}
/>
<div className='fields-group'>
<TextInputField
required={false}
label={
<FormattedMessage
id='collections.collection_topic'
defaultMessage='Topic'
/>
}
hint={
<FormattedMessage
id='collections.topic_hint'
defaultMessage='Add a hashtag that helps others understand the main topic of this collection.'
/>
}
value={topic}
onChange={handleTopicChange}
maxLength={40}
/>
</div>
<TextInputField
required={false}
label={
<FormattedMessage
id='collections.collection_topic'
defaultMessage='Topic'
/>
}
hint={
<FormattedMessage
id='collections.topic_hint'
defaultMessage='Add a hashtag that helps others understand the main topic of this collection.'
/>
}
value={topic}
onChange={handleTopicChange}
maxLength={40}
/>
<div className='fields-group'>
<CheckboxField
label={
<FormattedMessage
id='collections.mark_as_sensitive'
defaultMessage='Mark as sensitive'
/>
}
hint={
<FormattedMessage
id='collections.mark_as_sensitive_hint'
defaultMessage="Hides the collection's description and accounts behind a content warning. The collection name will still be visible."
/>
}
checked={sensitive}
onChange={handleSensitiveChange}
/>
</div>
<CheckboxField
label={
<FormattedMessage
id='collections.mark_as_sensitive'
defaultMessage='Mark as sensitive'
/>
}
hint={
<FormattedMessage
id='collections.mark_as_sensitive_hint'
defaultMessage="Hides the collection's description and accounts behind a content warning. The collection name will still be visible."
/>
}
checked={sensitive}
onChange={handleSensitiveChange}
/>
<div className='actions'>
<Button type='submit'>
@@ -221,7 +217,7 @@ const CollectionSettings: React.FC<{
)}
</Button>
</div>
</form>
</FormStack>
);
};