From 75b06e07567abac4d06c9266530049d378f3e028 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 16 Jun 2025 22:42:18 +0100 Subject: [PATCH] Move TextField to React Aria Signed-off-by: Charles de Dreuille --- .../TextField/TextField.stories.tsx | 117 +++++----------- .../components/TextField/TextField.styles.css | 125 +++++------------- .../src/components/TextField/TextField.tsx | 89 +++++-------- .../canon/src/components/TextField/types.ts | 40 ++---- packages/canon/src/stories/Form.stories.tsx | 2 +- 5 files changed, 115 insertions(+), 258 deletions(-) diff --git a/packages/canon/src/components/TextField/TextField.stories.tsx b/packages/canon/src/components/TextField/TextField.stories.tsx index f052377acb..17097e993c 100644 --- a/packages/canon/src/components/TextField/TextField.stories.tsx +++ b/packages/canon/src/components/TextField/TextField.stories.tsx @@ -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; @@ -38,14 +39,26 @@ type Story = StoryObj; 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 => ( + + } /> + } /> + + ), +}; + +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 => ( - - - - - ), -}; - -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 => ( - - } /> - } /> - - ), -}; - -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: , }, }; @@ -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 => ( +
+ + + ), }; -export const DisabledWithOnClear: Story = { +export const Validation: Story = { args: { - ...WithOnClear.args, - defaultValue: 'Testing', - disabled: true, + ...WithLabel.args, + validate: value => (value === 'admin' ? 'Nice try!' : null), }, }; diff --git a/packages/canon/src/components/TextField/TextField.styles.css b/packages/canon/src/components/TextField/TextField.styles.css index d7bc147ba1..aeb49cb8fc 100644 --- a/packages/canon/src/components/TextField/TextField.styles.css +++ b/packages/canon/src/components/TextField/TextField.styles.css @@ -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); } diff --git a/packages/canon/src/components/TextField/TextField.tsx b/packages/canon/src/components/TextField/TextField.tsx index 07efc47100..62bc5633cd 100644 --- a/packages/canon/src/components/TextField/TextField.tsx +++ b/packages/canon/src/components/TextField/TextField.tsx @@ -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( - (props: TextFieldProps, ref) => { +export const TextField = forwardRef( + (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 ( - {label && ( -
+
{label && ( - + + )} {description && ( - - {description} - +
{description}
)}
)} @@ -91,33 +88,19 @@ export const TextField = forwardRef( {icon && ( )} - - {onClear && ( - - )}
- {error && ( - - {error} - - )} -
+ + ); }, ); diff --git a/packages/canon/src/components/TextField/types.ts b/packages/canon/src/components/TextField/types.ts index 8a81492f2f..11ba2dbb51 100644 --- a/packages/canon/src/components/TextField/types.ts +++ b/packages/canon/src/components/TextField/types.ts @@ -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, 'size'> { +export interface FormInputProps extends Omit { /** - * 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; + isRequired?: boolean; } diff --git a/packages/canon/src/stories/Form.stories.tsx b/packages/canon/src/stories/Form.stories.tsx index 3bffca6554..97e28faaee 100644 --- a/packages/canon/src/stories/Form.stories.tsx +++ b/packages/canon/src/stories/Form.stories.tsx @@ -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;