Add form field components: TextInputField, TextAreaField, SelectField (#37578)
This commit is contained in:
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)' />
|
||||
</>
|
||||
);
|
||||
Reference in new issue
Block a user