Move TextField to React Aria
Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user