Move TextField to React Aria

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-06-16 22:42:18 +01:00
parent 76a58b9009
commit 75b06e0756
5 changed files with 115 additions and 258 deletions
@@ -16,19 +16,20 @@
import type { Meta, StoryObj } from '@storybook/react';
import { TextField } from './TextField';
import { Flex } from '../Flex';
import { Form } from 'react-aria-components';
import { Icon } from '../Icon';
import { Flex } from '../Flex';
const meta = {
title: 'Components/TextField',
title: 'Forms/TextField',
component: TextField,
argTypes: {
secondaryLabel: {
control: 'text',
},
required: {
isRequired: {
control: 'boolean',
},
icon: {
control: 'object',
},
},
} satisfies Meta<typeof TextField>;
@@ -38,14 +39,26 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
name: 'url',
placeholder: 'Enter a URL',
defaultValue: 'Enter a URL',
style: {
maxWidth: '300px',
},
},
};
export const Filled: Story = {
export const Sizes: Story = {
args: {
...Default.args,
},
render: args => (
<Flex direction="row" gap="4" style={{ width: '100%', maxWidth: '600px' }}>
<TextField {...args} size="small" icon={<Icon name="sparkling" />} />
<TextField {...args} size="medium" icon={<Icon name="sparkling" />} />
</Flex>
),
};
export const DefaultValue: Story = {
args: {
...Default.args,
defaultValue: 'https://example.com',
@@ -69,82 +82,21 @@ export const WithDescription: Story = {
export const Required: Story = {
args: {
...WithLabel.args,
required: true,
},
};
export const LabelSizes: Story = {
args: {
...Default.args,
label: 'Label',
description: 'Description',
required: true,
},
render: args => (
<Flex direction="row" gap="4" style={{ width: '100%', maxWidth: '600px' }}>
<TextField {...args} labelSize="small" />
<TextField {...args} labelSize="medium" />
</Flex>
),
};
export const HideLabelAndDescription: Story = {
args: {
...WithLabel.args,
hideLabelAndDescription: true,
isRequired: true,
},
};
export const Disabled: Story = {
args: {
...WithLabel.args,
disabled: true,
},
};
export const Sizes: Story = {
args: {
...Default.args,
label: 'Label',
description: 'Description',
},
render: args => (
<Flex direction="row" gap="4" style={{ width: '100%', maxWidth: '600px' }}>
<TextField {...args} size="small" icon={<Icon name="sparkling" />} />
<TextField {...args} size="medium" icon={<Icon name="sparkling" />} />
</Flex>
),
};
export const Responsive: Story = {
args: {
...WithLabel.args,
size: {
initial: 'small',
sm: 'medium',
},
},
};
export const WithError: Story = {
args: {
...WithLabel.args,
error: 'Invalid URL',
},
};
export const WithErrorAndDescription: Story = {
args: {
...WithLabel.args,
error: 'Invalid URL',
description: 'Description',
isDisabled: true,
},
};
export const WithIcon: Story = {
args: {
...WithLabel.args,
placeholder: 'Search...',
...Default.args,
defaultValue: 'Search...',
icon: <Icon name="search" />,
},
};
@@ -152,23 +104,24 @@ export const WithIcon: Story = {
export const DisabledWithIcon: Story = {
args: {
...WithIcon.args,
disabled: true,
isDisabled: true,
},
};
export const WithOnClear: Story = {
export const ShowError: Story = {
args: {
...WithLabel.args,
placeholder: 'Search...',
type: 'search',
onClear: () => console.log('Cleared!'),
},
render: args => (
<Form validationErrors={{ url: 'Invalid URL' }}>
<TextField {...args} />
</Form>
),
};
export const DisabledWithOnClear: Story = {
export const Validation: Story = {
args: {
...WithOnClear.args,
defaultValue: 'Testing',
disabled: true,
...WithLabel.args,
validate: value => (value === 'admin' ? 'Nice try!' : null),
},
};
@@ -28,38 +28,14 @@
gap: var(--canon-space-1);
}
.canon-TextFieldLabelWrapper[data-hidden] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.canon-TextFieldLabel {
color: var(--canon-fg-primary);
margin-right: auto;
cursor: pointer;
}
.canon-TextFieldLabel[data-size='small'] {
font-weight: var(--canon-font-weight-regular);
font-size: var(--canon-font-size-2);
}
.canon-TextFieldLabel[data-size='medium'] {
font-weight: var(--canon-font-weight-bold);
font-size: var(--canon-font-size-3);
}
.canon-TextFieldLabel[data-disabled] {
cursor: default;
}
.canon-TextFieldSecondaryLabel {
color: var(--canon-fg-secondary);
font-weight: var(--canon-font-weight-regular);
@@ -68,36 +44,28 @@
.canon-TextFieldDescription {
font-weight: var(--canon-font-weight-regular);
font-size: var(--canon-font-size-2);
color: var(--canon-fg-secondary);
margin: 0;
}
.canon-TextFieldDescription[data-size='small'] {
font-size: var(--canon-font-size-2);
}
.canon-TextFieldDescription[data-size='medium'] {
font-size: var(--canon-font-size-3);
}
.canon-TextFieldError {
font-size: var(--canon-font-size-2);
font-weight: var(--canon-font-weight-regular);
color: var(--canon-fg-danger);
margin: 0;
padding-top: var(--canon-space-1_5);
}
.canon-TextFieldInputWrapper {
display: flex;
align-items: center;
padding: 0 var(--canon-space-3);
border-radius: var(--canon-radius-3);
border: 1px solid var(--canon-border);
background-color: var(--canon-bg-surface-1);
position: relative;
}
.canon-TextFieldInputWrapper[data-size='small'] {
height: 2rem;
}
.canon-TextFieldInputWrapper[data-size='medium'] {
height: 2.5rem;
}
.canon-TextFieldIcon {
position: absolute;
left: var(--canon-space-3);
top: 50%;
transform: translateY(-50%);
margin-right: var(--canon-space-1);
color: var(--canon-fg-primary);
flex-shrink: 0;
@@ -116,8 +84,13 @@
}
.canon-TextFieldInput {
border: none;
background: none;
display: flex;
align-items: center;
padding: 0 var(--canon-space-3);
border-radius: var(--canon-radius-3);
border: 1px solid var(--canon-border);
background-color: var(--canon-bg-surface-1);
font-size: var(--canon-font-size-3);
font-family: var(--canon-font-regular);
font-weight: var(--canon-font-weight-regular);
@@ -126,71 +99,43 @@
width: 100%;
height: 100%;
cursor: inherit;
padding: 0;
}
.canon-TextFieldInput:not([data-filled]):has(+ .canon-TextFieldClearButton) {
padding-right: 1.25rem;
}
.canon-TextFieldInput[type='search']::-webkit-search-cancel-button,
.canon-TextFieldInput[type='search']::-webkit-search-decoration {
appearance: none;
}
.canon-TextFieldClearButton {
display: none;
padding: 0;
margin-left: var(--canon-space-1);
background: none;
border: none;
vertical-align: middle;
color: var(--canon-fg-primary);
}
.canon-TextFieldInput[data-filled] + .canon-TextFieldClearButton {
display: inline-block;
}
.canon-TextFieldClearButtonIcon {
display: block;
}
.canon-TextFieldInput::placeholder {
color: var(--canon-fg-secondary);
}
.canon-TextFieldInput[data-icon] {
padding-left: var(--canon-space-8);
}
.canon-TextFieldInput[data-focused] {
outline-color: var(--canon-border-pressed);
outline-width: 0px;
}
.canon-TextFieldInputWrapper:has(> .canon-TextFieldInput:hover) {
.canon-TextFieldInput[data-hovered] {
border-color: var(--canon-border-hover);
}
.canon-TextField[data-focused] .canon-TextFieldInputWrapper {
.canon-TextFieldInput[data-focused] {
border-color: var(--canon-border-pressed);
outline-width: 0px;
}
.canon-TextField[data-invalid] .canon-TextFieldInputWrapper {
.canon-TextFieldInput[data-invalid] {
border-color: var(--canon-fg-danger);
}
.canon-TextField[data-disabled] .canon-TextFieldInputWrapper {
.canon-TextFieldInput[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
border: 1px solid var(--canon-border-disabled);
}
.canon-TextField[data-disabled] .canon-TextFieldClearButton {
cursor: inherit;
}
.canon-TextFieldInputWrapper[data-size='small'] {
height: 2rem;
}
.canon-TextFieldInputWrapper[data-size='medium'] {
height: 2.5rem;
.canon-TextFieldError {
color: var(--canon-fg-danger);
font-size: var(--canon-font-size-2);
font-weight: var(--canon-font-weight-regular);
margin-top: var(--canon-space-2);
}
@@ -14,58 +14,60 @@
* limitations under the License.
*/
import { Field } from '@base-ui-components/react/field';
import { forwardRef } from 'react';
import { forwardRef, useEffect } from 'react';
import {
Input,
TextField as AriaTextField,
Label,
FieldError,
} from 'react-aria-components';
import { useResponsiveValue } from '../../hooks/useResponsiveValue';
import clsx from 'clsx';
import type { TextFieldProps } from './types';
import { Icon } from '../Icon';
import type { FormInputProps } from './types';
/** @public */
export const TextField = forwardRef<HTMLDivElement, TextFieldProps>(
(props: TextFieldProps, ref) => {
export const TextField = forwardRef<HTMLDivElement, FormInputProps>(
(props: FormInputProps, ref) => {
const {
className,
icon,
size = 'small',
label,
labelSize = 'small',
secondaryLabel,
description,
hideLabelAndDescription,
error,
required,
style,
disabled,
icon,
onClear,
isRequired,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
...rest
} = props;
useEffect(() => {
if (!label && !ariaLabel && !ariaLabelledBy) {
console.warn(
'If you do not provide a visible label, you must specify an aria-label or aria-labelledby attribute for accessibility',
);
}
}, [label, ariaLabel, ariaLabelledBy]);
// Get the responsive value for the variant
const responsiveSize = useResponsiveValue(size);
// If a secondary label is provided, use it. Otherwise, use 'Required' if the field is required.
const secondaryLabelText = secondaryLabel || (required ? 'Required' : null);
const secondaryLabelText =
secondaryLabel || (isRequired ? 'Required' : null);
return (
<Field.Root
<AriaTextField
className={clsx('canon-TextField', className)}
disabled={disabled}
invalid={!!error}
style={style}
data-size={responsiveSize}
{...rest}
ref={ref}
>
{label && (
<div
className="canon-TextFieldLabelWrapper"
data-hidden={hideLabelAndDescription}
>
<div className="canon-TextFieldLabelWrapper">
{label && (
<Field.Label
className="canon-TextFieldLabel"
data-size={labelSize}
>
<Label className="canon-TextFieldLabel">
{label}
{secondaryLabelText && (
<span
@@ -75,15 +77,10 @@ export const TextField = forwardRef<HTMLDivElement, TextFieldProps>(
({secondaryLabelText})
</span>
)}
</Field.Label>
</Label>
)}
{description && (
<Field.Description
className="canon-TextFieldDescription"
data-size={labelSize}
>
{description}
</Field.Description>
<div className="canon-TextFieldDescription">{description}</div>
)}
</div>
)}
@@ -91,33 +88,19 @@ export const TextField = forwardRef<HTMLDivElement, TextFieldProps>(
{icon && (
<div
className="canon-TextFieldIcon"
aria-hidden="true"
data-size={responsiveSize}
aria-hidden="true"
>
{icon}
</div>
)}
<Field.Control
<Input
className="canon-TextFieldInput"
required={required}
{...rest}
{...(icon && { 'data-icon': true })}
/>
{onClear && (
<button
className="canon-TextFieldClearButton"
disabled={disabled}
onClick={onClear}
>
<Icon className="canon-TextFieldClearButtonIcon" name="close" />
</button>
)}
</div>
{error && (
<Field.Error className="canon-TextFieldError" role="alert" forceShow>
{error}
</Field.Error>
)}
</Field.Root>
<FieldError className="canon-TextFieldError" />
</AriaTextField>
);
},
);
@@ -14,15 +14,16 @@
* limitations under the License.
*/
import type { TextFieldProps } from 'react-aria-components';
import { ReactNode } from 'react';
import type { Breakpoint } from '../../types';
import type { ReactNode, MouseEventHandler } from 'react';
/** @public */
export interface TextFieldProps
extends Omit<React.ComponentPropsWithoutRef<'input'>, 'size'> {
export interface FormInputProps extends Omit<TextFieldProps, 'size'> {
/**
* The class name of the text field
* An icon to render before the input
*/
className?: string;
icon?: ReactNode;
/**
* The size of the text field
@@ -40,38 +41,13 @@ export interface TextFieldProps
*/
secondaryLabel?: string;
/**
* The size of the label and description
*/
labelSize?: 'small' | 'medium';
/**
* Hide the label and description but still visible to screen readers
*/
hideLabelAndDescription?: boolean;
/**
* The description of the text field
*/
description?: string;
/**
* The name of the text field
* Whether the field is required
*/
name: string;
/**
* The error message of the text field
*/
error?: string | null;
/**
* An icon to render before the input
*/
icon?: ReactNode;
/**
* Handler to call when the clear button is pressed
*/
onClear?: MouseEventHandler<HTMLButtonElement>;
isRequired?: boolean;
}
+1 -1
View File
@@ -22,7 +22,7 @@ import { Button } from '../components/Button';
import { Select } from '../components/Select';
const meta = {
title: 'Examples/Form',
title: 'Forms/Form',
} satisfies Meta;
export default meta;