Add form field components: TextInputField, TextAreaField, SelectField (#37578)

This commit is contained in:
diondiondion authored and GitHub committed 2026-01-22 16:08:57 +00:00
1 parent 1809048105
commit 0924171c0f
11 files changed
+418 -96

No files matched your search

@@ -0,0 +1,3 @@
export { TextInputField } from './text_input_field';
export { TextAreaField } from './text_area_field';
export { SelectField } from './select_field';
@@ -0,0 +1,55 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { SelectField } from './select_field';
const meta = {
title: 'Components/Form Fields/SelectField',
component: SelectField,
args: {
label: 'Fruit preference',
hint: 'Select your favourite fruit or not. Up to you.',
},
render(args) {
// Component styles require a wrapper class at the moment
return (
<div className='simple_form'>
<SelectField {...args}>
<option>Apple</option>
<option>Banana</option>
<option>Kiwi</option>
<option>Lemon</option>
<option>Mango</option>
<option>Orange</option>
<option>Pomelo</option>
<option>Strawberries</option>
<option>Something else</option>
</SelectField>
</div>
);
},
} satisfies Meta<typeof SelectField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {};
export const Required: Story = {
args: {
required: true,
},
};
export const Optional: Story = {
args: {
required: false,
},
};
export const WithError: Story = {
args: {
required: false,
hasError: true,
},
};
@@ -0,0 +1,38 @@
import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef } from 'react';
import { FormFieldWrapper } from './wrapper';
import type { CommonFieldWrapperProps } from './wrapper';
interface Props
extends ComponentPropsWithoutRef<'select'>, CommonFieldWrapperProps {}
/**
* A simple form field for single-item selections.
* Provide selectable items via nested `<option>` elements.
*
* Accepts an optional `hint` and can be marked as required
* or optional (by explicitly setting `required={false}`)
*/
export const SelectField = forwardRef<HTMLSelectElement, Props>(
({ id, label, hint, required, hasError, children, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
>
{(inputProps) => (
<div className='select-wrapper'>
<select {...otherProps} {...inputProps} ref={ref}>
{children}
</select>
</div>
)}
</FormFieldWrapper>
),
);
SelectField.displayName = 'SelectField';
@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { TextAreaField } from './text_area_field';
const meta = {
title: 'Components/Form Fields/TextAreaField',
component: TextAreaField,
args: {
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;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {};
export const Required: Story = {
args: {
required: true,
},
};
export const Optional: Story = {
args: {
required: false,
},
};
export const WithError: Story = {
args: {
required: false,
hasError: true,
},
};
@@ -0,0 +1,31 @@
import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef } from 'react';
import { FormFieldWrapper } from './wrapper';
import type { CommonFieldWrapperProps } from './wrapper';
interface Props
extends ComponentPropsWithoutRef<'textarea'>, CommonFieldWrapperProps {}
/**
* A simple form field for multi-line text.
*
* Accepts an optional `hint` and can be marked as required
* or optional (by explicitly setting `required={false}`)
*/
export const TextAreaField = forwardRef<HTMLTextAreaElement, Props>(
({ id, label, hint, required, hasError, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
>
{(inputProps) => <textarea {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper>
),
);
TextAreaField.displayName = 'TextAreaField';
@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { TextInputField } from './text_input_field';
const meta = {
title: 'Components/Form Fields/TextInputField',
component: TextInputField,
args: {
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;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {};
export const Required: Story = {
args: {
required: true,
},
};
export const Optional: Story = {
args: {
required: false,
},
};
export const WithError: Story = {
args: {
required: false,
hasError: true,
},
};
@@ -0,0 +1,36 @@
import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef } from 'react';
import { FormFieldWrapper } from './wrapper';
import type { CommonFieldWrapperProps } from './wrapper';
interface Props
extends ComponentPropsWithoutRef<'input'>, CommonFieldWrapperProps {}
/**
* A simple form field for single-line text.
*
* Accepts an optional `hint` and can be marked as required
* or optional (by explicitly setting `required={false}`)
*/
export const TextInputField = forwardRef<HTMLInputElement, Props>(
(
{ id, label, hint, hasError, required, type = 'text', ...otherProps },
ref,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
>
{(inputProps) => (
<input type={type} {...otherProps} {...inputProps} ref={ref} />
)}
</FormFieldWrapper>
),
);
TextInputField.displayName = 'TextInputField';
@@ -0,0 +1,100 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
import type { ReactNode, FC } from 'react';
import { useId } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
interface InputProps {
id: string;
required?: boolean;
'aria-describedby'?: string;
}
interface FieldWrapperProps {
label: ReactNode;
hint?: ReactNode;
required?: boolean;
hasError?: boolean;
inputId?: string;
children: (inputProps: InputProps) => ReactNode;
}
/**
* These types can be extended when creating individual field components.
*/
export type CommonFieldWrapperProps = Pick<
FieldWrapperProps,
'label' | 'hint' | 'hasError'
>;
/**
* A simple form field wrapper for adding a label and hint to enclosed components.
* Accepts an optional `hint` and can be marked as required
* or optional (by explicitly setting `required={false}`)
*/
export const FormFieldWrapper: FC<FieldWrapperProps> = ({
inputId: inputIdProp,
label,
hint,
required,
hasError,
children,
}) => {
const uniqueId = useId();
const inputId = inputIdProp || `${uniqueId}-input`;
const hintId = `${inputIdProp || uniqueId}-hint`;
const hasHint = !!hint;
const inputProps: InputProps = {
required,
id: inputId,
};
if (hasHint) {
inputProps['aria-describedby'] = hintId;
}
return (
<div
className={classNames('input with_block_label', {
field_with_errors: hasError,
})}
>
<div className='label_input'>
<label htmlFor={inputId}>
{label}
{required !== undefined && <RequiredMark required={required} />}
</label>
{hasHint && (
<span className='hint' id={hintId}>
{hint}
</span>
)}
<div className='label_input__wrapper'>{children(inputProps)}</div>
</div>
</div>
);
};
/**
* If `required` is explicitly set to `false` rather than `undefined`,
* the field will be visually marked as "optional".
*/
const RequiredMark: FC<{ required?: boolean }> = ({ required }) =>
required ? (
<>
{' '}
<abbr aria-hidden='true'>*</abbr>
</>
) : (
<>
{' '}
<FormattedMessage id='form_field.optional' defaultMessage='(optional)' />
</>
);