Merge pull request #30286 from backstage/canon-forms

Canon - Update TextField to use React Aria
This commit is contained in:
Charles de Dreuille
2025-06-17 12:38:49 +02:00
committed by GitHub
20 changed files with 1431 additions and 1761 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': minor
---
Move TextField component to use react Aria under the hood. Introducing a new FieldLabel component to help build custom fields.
+1 -4
View File
@@ -9,10 +9,7 @@ function getAbsolutePath(value: string): string {
return dirname(require.resolve(join(value, 'package.json')));
}
const config: StorybookConfig = {
stories: [
'../src/components/**/*.stories.@(js|jsx|mjs|ts|tsx)',
'../src/stories/**/*.stories.@(js|jsx|mjs|ts|tsx)',
],
stories: ['../src/components/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
staticDirs: ['../static'],
addons: [
getAbsolutePath('@storybook/addon-webpack5-compiler-swc'),
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
.canon-FieldLabel {
margin-bottom: var(--canon-space-3);
gap: var(--canon-space-1);
flex-direction: column;
display: flex;
}
.canon-FieldLabelLabel {
color: var(--canon-fg-primary);
cursor: pointer;
font-weight: var(--canon-font-weight-regular);
font-size: var(--canon-font-size-2);
margin-right: auto;
}
.canon-FieldLabelSecondaryLabel {
color: var(--canon-fg-secondary);
font-weight: var(--canon-font-weight-regular);
margin-left: var(--canon-space-1);
}
.canon-FieldLabelDescription {
font-weight: var(--canon-font-weight-regular);
font-size: var(--canon-font-size-2);
color: var(--canon-fg-secondary);
margin: 0;
}
File diff suppressed because it is too large Load Diff
+33 -116
View File
@@ -5,86 +5,26 @@
display: flex;
}
.canon-TextFieldLabelWrapper {
margin-bottom: var(--canon-space-3);
gap: var(--canon-space-1);
flex-direction: column;
display: flex;
}
.canon-TextFieldLabelWrapper[data-hidden] {
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
position: absolute;
overflow: hidden;
}
.canon-TextFieldLabel {
color: var(--canon-fg-primary);
cursor: pointer;
margin-right: auto;
}
.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);
margin-left: var(--canon-space-1);
}
.canon-TextFieldDescription {
font-weight: var(--canon-font-weight-regular);
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);
padding-top: var(--canon-space-1_5);
margin: 0;
}
.canon-TextFieldInputWrapper {
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);
align-items: center;
display: flex;
position: relative;
}
.canon-TextFieldInputWrapper[data-size="small"] {
height: 2rem;
}
.canon-TextFieldInputWrapper[data-size="medium"] {
height: 2.5rem;
}
.canon-TextFieldIcon {
left: var(--canon-space-3);
margin-right: var(--canon-space-1);
color: var(--canon-fg-primary);
flex-shrink: 0;
position: absolute;
top: 50%;
transform: translateY(-50%);
}
.canon-TextFieldIcon[data-size="small"], .canon-TextFieldIcon[data-size="small"] svg {
@@ -98,6 +38,10 @@
}
.canon-TextFieldInput {
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);
@@ -105,73 +49,46 @@
width: 100%;
height: 100%;
cursor: inherit;
background: none;
border: none;
padding: 0;
align-items: center;
transition: border-color .2s ease-in-out, outline-color .2s ease-in-out;
}
.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 {
margin-left: var(--canon-space-1);
vertical-align: middle;
color: var(--canon-fg-primary);
background: none;
border: none;
padding: 0;
display: none;
}
.canon-TextFieldInput[data-filled] + .canon-TextFieldClearButton {
display: inline-block;
}
.canon-TextFieldClearButtonIcon {
display: block;
display: flex;
}
.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: 0;
}
.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: 0;
}
.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: .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);
}
+17 -11
View File
@@ -18,7 +18,6 @@ import { ForwardRefExoticComponent } from 'react';
import { HTMLAttributes } from 'react';
import { JSX as JSX_2 } from 'react/jsx-runtime';
import { Menu as Menu_2 } from '@base-ui-components/react/menu';
import type { MouseEventHandler } from 'react';
import { ReactElement } from 'react';
import { ReactNode } from 'react';
import { RefAttributes } from 'react';
@@ -28,6 +27,7 @@ import type { SwitchProps as SwitchProps_2 } from 'react-aria-components';
import { Table as Table_2 } from '@tanstack/react-table';
import { Tabs as Tabs_2 } from '@base-ui-components/react/tabs';
import { TdHTMLAttributes } from 'react';
import type { TextFieldProps as TextFieldProps_2 } from 'react-aria-components';
import { ThHTMLAttributes } from 'react';
import { Tooltip as Tooltip_2 } from '@base-ui-components/react/tooltip';
import type { useRender } from '@base-ui-components/react/use-render';
@@ -356,6 +356,20 @@ export type EnumPropDef<T> = {
required?: boolean;
};
// @public (undocumented)
export const FieldLabel: ForwardRefExoticComponent<
FieldLabelProps & RefAttributes<HTMLDivElement>
>;
// @public (undocumented)
export interface FieldLabelProps {
description?: string | null;
htmlFor?: string;
id?: string;
label?: string | null;
secondaryLabel?: string | null;
}
// @public (undocumented)
export const Flex: ForwardRefExoticComponent<
FlexProps & RefAttributes<HTMLDivElement>
@@ -1286,17 +1300,9 @@ export const TextField: ForwardRefExoticComponent<
// @public (undocumented)
export interface TextFieldProps
extends Omit<React.ComponentPropsWithoutRef<'input'>, 'size'> {
className?: string;
description?: string;
error?: string | null;
hideLabelAndDescription?: boolean;
extends TextFieldProps_2,
Omit<FieldLabelProps, 'htmlFor' | 'id'> {
icon?: ReactNode;
label?: string;
labelSize?: 'small' | 'medium';
name: string;
onClear?: MouseEventHandler<HTMLButtonElement>;
secondaryLabel?: string;
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
}
@@ -0,0 +1,65 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta, StoryObj } from '@storybook/react';
import { FieldLabel } from './FieldLabel';
const meta = {
title: 'Forms/FieldLabel',
component: FieldLabel,
argTypes: {
label: {
control: 'text',
},
secondaryLabel: {
control: 'text',
},
description: {
control: 'text',
},
},
} satisfies Meta<typeof FieldLabel>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
label: 'Label',
},
};
export const WithSecondaryLabel: Story = {
args: {
...Default.args,
secondaryLabel: 'Secondary Label',
},
};
export const WithDescription: Story = {
args: {
...Default.args,
description: 'Description',
},
};
export const WithAllFields: Story = {
args: {
...Default.args,
secondaryLabel: 'Secondary Label',
description: 'Description',
},
};
@@ -0,0 +1,27 @@
.canon-FieldLabel {
display: flex;
flex-direction: column;
margin-bottom: var(--canon-space-3);
gap: var(--canon-space-1);
}
.canon-FieldLabelLabel {
color: var(--canon-fg-primary);
margin-right: auto;
cursor: pointer;
font-weight: var(--canon-font-weight-regular);
font-size: var(--canon-font-size-2);
}
.canon-FieldLabelSecondaryLabel {
color: var(--canon-fg-secondary);
font-weight: var(--canon-font-weight-regular);
margin-left: var(--canon-space-1);
}
.canon-FieldLabelDescription {
font-weight: var(--canon-font-weight-regular);
font-size: var(--canon-font-size-2);
color: var(--canon-fg-secondary);
margin: 0;
}
@@ -0,0 +1,50 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Label } from 'react-aria-components';
import { forwardRef } from 'react';
import type { FieldLabelProps } from './types';
/** @public */
export const FieldLabel = forwardRef<HTMLDivElement, FieldLabelProps>(
(props: FieldLabelProps, ref) => {
const { label, secondaryLabel, description, htmlFor, id, ...rest } = props;
if (!label) return null;
return (
<div className="canon-FieldLabel" {...rest} ref={ref}>
{label && (
<Label className="canon-FieldLabelLabel" htmlFor={htmlFor} id={id}>
{label}
{secondaryLabel && (
<span
aria-hidden="true"
className="canon-FieldLabelSecondaryLabel"
>
({secondaryLabel})
</span>
)}
</Label>
)}
{description && (
<div className="canon-FieldLabelDescription">{description}</div>
)}
</div>
);
},
);
FieldLabel.displayName = 'FieldLabel';
@@ -0,0 +1,18 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './FieldLabel';
export * from './types';
@@ -0,0 +1,43 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @public */
export interface FieldLabelProps {
/**
* The label of the text field
*/
label?: string | null;
/**
* The secondary label of the text field
*/
secondaryLabel?: string | null;
/**
* The description of the text field
*/
description?: string | null;
/**
* The HTML for attribute of the text field
*/
htmlFor?: string;
/**
* The id of the text field
*/
id?: string;
}
@@ -16,19 +16,21 @@
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';
import { FieldLabel } from '../FieldLabel';
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 +40,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 +83,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 +105,42 @@ 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 Validation: Story = {
args: {
...WithLabel.args,
validate: value => (value === 'admin' ? 'Nice try!' : null),
},
};
export const DisabledWithOnClear: Story = {
args: {
...WithOnClear.args,
defaultValue: 'Testing',
disabled: true,
},
export const CustomField: Story = {
render: () => (
<>
<FieldLabel
htmlFor="custom-field"
id="custom-field-label"
label="Custom Field"
/>
<TextField
id="custom-field"
aria-labelledby="custom-field-label"
name="custom-field"
defaultValue="Custom Field"
/>
</>
),
};
@@ -21,83 +21,23 @@
width: 100%;
}
.canon-TextFieldLabelWrapper {
display: flex;
flex-direction: column;
margin-bottom: var(--canon-space-3);
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);
margin-left: var(--canon-space-1);
}
.canon-TextFieldDescription {
font-weight: var(--canon-font-weight-regular);
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 +56,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 +71,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,110 +14,80 @@
* 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,
FieldError,
} from 'react-aria-components';
import { useResponsiveValue } from '../../hooks/useResponsiveValue';
import clsx from 'clsx';
import { FieldLabel } from '../FieldLabel';
import type { TextFieldProps } from './types';
import { Icon } from '../Icon';
/** @public */
export const TextField = forwardRef<HTMLDivElement, TextFieldProps>(
(props: TextFieldProps, ref) => {
(props, 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(
'TextField requires either a visible label, aria-label, or aria-labelledby 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}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
{...rest}
ref={ref}
>
{label && (
<div
className="canon-TextFieldLabelWrapper"
data-hidden={hideLabelAndDescription}
>
{label && (
<Field.Label
className="canon-TextFieldLabel"
data-size={labelSize}
>
{label}
{secondaryLabelText && (
<span
aria-hidden="true"
className="canon-TextFieldSecondaryLabel"
>
({secondaryLabelText})
</span>
)}
</Field.Label>
)}
{description && (
<Field.Description
className="canon-TextFieldDescription"
data-size={labelSize}
>
{description}
</Field.Description>
)}
</div>
)}
<FieldLabel
label={label}
secondaryLabel={secondaryLabelText}
description={description}
/>
<div className="canon-TextFieldInputWrapper" data-size={responsiveSize}>
{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>
);
},
);
@@ -15,4 +15,4 @@
*/
export * from './TextField';
export type { TextFieldProps } from './types';
export * from './types';
@@ -14,64 +14,23 @@
* limitations under the License.
*/
import type { TextFieldProps as AriaTextFieldProps } from 'react-aria-components';
import { ReactNode } from 'react';
import type { Breakpoint } from '../../types';
import type { ReactNode, MouseEventHandler } from 'react';
import type { FieldLabelProps } from '../FieldLabel/types';
/** @public */
export interface TextFieldProps
extends Omit<React.ComponentPropsWithoutRef<'input'>, 'size'> {
/**
* The class name of the text field
*/
className?: string;
/**
* The size of the text field
* @defaultValue 'medium'
*/
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
/**
* The label of the text field
*/
label?: string;
/**
* The secondary label of the text field
*/
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
*/
name: string;
/**
* The error message of the text field
*/
error?: string | null;
extends AriaTextFieldProps,
Omit<FieldLabelProps, 'htmlFor' | 'id'> {
/**
* An icon to render before the input
*/
icon?: ReactNode;
/**
* Handler to call when the clear button is pressed
* The size of the text field
* @defaultValue 'medium'
*/
onClear?: MouseEventHandler<HTMLButtonElement>;
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
}
+7 -6
View File
@@ -17,14 +17,19 @@
@import '../components/Avatar/Avatar.styles.css';
@import '../components/Box/styles.css';
@import '../components/Button/styles.css';
@import '../components/Checkbox/styles.css';
@import '../components/Collapsible/Collapsible.styles.css';
@import '../components/Container/styles.css';
@import '../components/DataTable/Root/DataTableRoot.styles.css';
@import '../components/DataTable/Pagination/DataTablePagination.styles.css';
@import '../components/FieldLabel/FieldLabel.styles.css';
@import '../components/Flex/styles.css';
@import '../components/Grid/styles.css';
@import '../components/Container/styles.css';
@import '../components/Heading/styles.css';
@import '../components/Icon/styles.css';
@import '../components/Checkbox/styles.css';
@import '../components/IconButton/styles.css';
@import '../components/Link/styles.css';
@import '../components/Menu/Menu.styles.css';
@import '../components/Table/styles.css';
@import '../components/Table/TableCell/TableCell.styles.css';
@import '../components/Table/TableCellText/TableCellText.styles.css';
@@ -32,11 +37,7 @@
@import '../components/Table/TableCellProfile/TableCellProfile.styles.css';
@import '../components/Tabs/Tabs.styles.css';
@import '../components/Text/styles.css';
@import '../components/Heading/styles.css';
@import '../components/IconButton/styles.css';
@import '../components/TextField/TextField.styles.css';
@import '../components/Menu/Menu.styles.css';
@import '../components/Link/styles.css';
@import '../components/Tooltip/Tooltip.styles.css';
@import '../components/ScrollArea/ScrollArea.styles.css';
@import '../components/Select/Select.styles.css';
+1
View File
@@ -36,6 +36,7 @@ export * from './components/Avatar';
export * from './components/Button';
export * from './components/Collapsible';
export * from './components/DataTable';
export * from './components/FieldLabel';
export * from './components/Icon';
export * from './components/IconButton';
export * from './components/Checkbox';
-195
View File
@@ -1,195 +0,0 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { useForm, SubmitHandler, Controller } from 'react-hook-form';
import { TextField } from '../components/TextField';
import { Button } from '../components/Button';
import { Select } from '../components/Select';
const meta = {
title: 'Examples/Form',
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
type Inputs = {
firstname: string;
lastname: string;
city: string;
};
export const Uncontrolled: Story = {
render: () => {
const {
register,
handleSubmit,
control,
formState: { errors },
} = useForm<Inputs>();
const onSubmit: SubmitHandler<Inputs> = data => console.log(data);
return (
<form
onSubmit={handleSubmit(onSubmit)}
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
alignItems: 'flex-start',
}}
>
<TextField
label="First Name"
{...register('firstname', {
required: 'First name is required',
maxLength: { value: 80, message: 'Max length is 80 characters' },
})}
error={errors.firstname?.message}
/>
<TextField
label="Last Name"
{...register('lastname', {
required: 'Last name is required',
maxLength: { value: 100, message: 'Max length is 100 characters' },
})}
error={errors.lastname?.message}
/>
<Controller
name="city"
control={control}
rules={{ required: 'New city is required' }}
render={({ field }) => {
return (
<Select
label="New City"
options={[
{ value: 'london', label: 'London' },
{ value: 'paris', label: 'Paris' },
{ value: 'new-york', label: 'New York' },
]}
name={field.name}
onValueChange={field.onChange}
error={errors.city?.message}
/>
);
}}
/>
<Button type="submit">Submit</Button>
</form>
);
},
};
export const Controlled: Story = {
render: () => {
const {
handleSubmit,
control,
formState: { errors },
} = useForm<Inputs>();
const [firstname, setFirstname] = useState('John');
const [lastname, setLastname] = useState('Doe');
const [city, setCity] = useState('london');
const onSubmit: SubmitHandler<Inputs> = data => {
console.log('data', data);
setFirstname(data.firstname);
setLastname(data.lastname);
setCity(data.city);
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
alignItems: 'flex-start',
}}
>
<Controller
name="firstname"
control={control}
defaultValue={firstname}
rules={{ required: 'First name is required' }}
render={({ field }) => {
return (
<TextField
label="First Name"
name={field.name}
value={firstname}
onChange={e => {
field.onChange(e);
setFirstname(e.target.value);
}}
error={errors.firstname?.message}
/>
);
}}
/>
<Controller
name="lastname"
control={control}
defaultValue={lastname}
rules={{ required: 'Last name is required' }}
render={({ field }) => {
return (
<TextField
label="Last Name"
name={field.name}
value={lastname}
onChange={e => {
field.onChange(e);
setLastname(e.target.value);
}}
error={errors.lastname?.message}
/>
);
}}
/>
<Controller
name="city"
control={control}
defaultValue={city}
rules={{ required: 'New city is required' }}
render={({ field }) => {
return (
<Select
label="New City"
options={[
{ value: 'london', label: 'London' },
{ value: 'paris', label: 'Paris' },
{ value: 'new-york', label: 'New York' },
]}
name={field.name}
value={city}
onValueChange={field.onChange}
error={errors.city?.message}
/>
);
}}
/>
<Button type="submit">Submit</Button>
</form>
);
},
};