Add new FieldLabel component

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-06-16 23:06:34 +01:00
parent 75b06e0756
commit 35fd51dc33
21 changed files with 1258 additions and 1753 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.
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
.canon-TextFieldLabelWrapper {
margin-bottom: var(--canon-space-3);
gap: var(--canon-space-1);
flex-direction: column;
display: flex;
}
.canon-TextFieldLabel {
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-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);
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);
}
+22 -18
View File
@@ -16,7 +16,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';
@@ -26,6 +25,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 } 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';
@@ -354,6 +354,18 @@ export type EnumPropDef<T> = {
required?: boolean;
};
// @public (undocumented)
export const FieldLabel: ForwardRefExoticComponent<
FieldLabelProps & RefAttributes<HTMLDivElement>
>;
// @public (undocumented)
export interface FieldLabelProps {
description?: string | null;
label?: string | null;
secondaryLabel?: string | null;
}
// @public (undocumented)
export const Flex: ForwardRefExoticComponent<
FlexProps & RefAttributes<HTMLDivElement>
@@ -408,6 +420,14 @@ export interface FlexProps extends SpaceProps {
// @public (undocumented)
export type FlexWrap = 'wrap' | 'nowrap' | 'wrap-reverse';
// @public (undocumented)
export interface FormInputProps
extends Omit<TextFieldProps, 'size'>,
FieldLabelProps {
icon?: ReactNode;
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
}
// @public (undocumented)
export const gapPropDefs: {
gap: {
@@ -1276,25 +1296,9 @@ export { Text_2 as Text };
// @public (undocumented)
export const TextField: ForwardRefExoticComponent<
TextFieldProps & RefAttributes<HTMLDivElement>
FormInputProps & RefAttributes<HTMLDivElement>
>;
// @public (undocumented)
export interface TextFieldProps
extends Omit<React.ComponentPropsWithoutRef<'input'>, 'size'> {
className?: string;
description?: string;
error?: string | null;
hideLabelAndDescription?: boolean;
icon?: ReactNode;
label?: string;
labelSize?: 'small' | 'medium';
name: string;
onClear?: MouseEventHandler<HTMLButtonElement>;
secondaryLabel?: string;
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
}
// @public (undocumented)
export interface TextProps
extends Omit<useRender.ComponentProps<'p'>, 'color'> {
@@ -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-TextFieldLabelWrapper {
display: flex;
flex-direction: column;
margin-bottom: var(--canon-space-3);
gap: var(--canon-space-1);
}
.canon-TextFieldLabel {
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-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);
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 } = props;
if (!label) return null;
return (
<div className="canon-TextFieldLabelWrapper" ref={ref}>
{label && (
<Label className="canon-TextFieldLabel">
{label}
{secondaryLabel && (
<span
aria-hidden="true"
className="canon-TextFieldSecondaryLabel"
>
({secondaryLabel})
</span>
)}
</Label>
)}
{description && (
<div className="canon-TextFieldDescription">{description}</div>
)}
</div>
);
},
);
FieldLabel.displayName = 'FieldLabel';
@@ -14,5 +14,5 @@
* limitations under the License.
*/
export * from './TextField';
export type { TextFieldProps } from './types';
export * from './FieldLabel';
export * from './types';
@@ -14,20 +14,20 @@
* limitations under the License.
*/
import { InputProps } from 'react-aria-components';
import { ReactNode } from 'react';
import type { Breakpoint } from '../../types';
/** @public */
export interface FormInputProps extends Omit<InputProps, 'size'> {
export interface FieldLabelProps {
/**
* An icon to render before the input
* The label of the text field
*/
icon?: ReactNode;
label?: string | null;
/**
* The size of the text field
* @defaultValue 'medium'
* The secondary label of the text field
*/
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
secondaryLabel?: string | null;
/**
* The description of the text field
*/
description?: string | null;
}
@@ -1,87 +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 type { Meta, StoryObj } from '@storybook/react';
import { FormInput } from './FormInput';
import { Icon } from '../Icon';
import { Flex } from '../Flex';
const meta = {
title: 'Forms/FormInput',
component: FormInput,
argTypes: {
required: {
control: 'boolean',
},
icon: {
control: 'object',
},
},
} satisfies Meta<typeof FormInput>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
name: 'url',
placeholder: 'Enter a URL',
style: {
maxWidth: '300px',
},
},
};
export const Sizes: Story = {
args: {
...Default.args,
},
render: args => (
<Flex direction="row" gap="4" style={{ width: '100%', maxWidth: '600px' }}>
<FormInput {...args} size="small" icon={<Icon name="sparkling" />} />
<FormInput {...args} size="medium" icon={<Icon name="sparkling" />} />
</Flex>
),
};
export const Filled: Story = {
args: {
...Default.args,
defaultValue: 'https://example.com',
},
};
export const Disabled: Story = {
args: {
...Default.args,
disabled: true,
},
};
export const WithIcon: Story = {
args: {
...Default.args,
placeholder: 'Search...',
icon: <Icon name="search" />,
},
};
export const DisabledWithIcon: Story = {
args: {
...WithIcon.args,
disabled: true,
},
};
@@ -1,91 +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.
*/
.canon-FormInput {
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);
}
.canon-FormInputIcon {
margin-right: var(--canon-space-1);
color: var(--canon-fg-primary);
flex-shrink: 0;
}
.canon-FormInputIcon[data-size='small'],
.canon-FormInputIcon[data-size='small'] svg {
width: 1rem;
height: 1rem;
}
.canon-FormInputIcon[data-size='medium'],
.canon-FormInputIcon[data-size='medium'] svg {
width: 1.25rem;
height: 1.25rem;
}
.canon-Input {
border: none;
background: none;
font-size: var(--canon-font-size-3);
font-family: var(--canon-font-regular);
font-weight: var(--canon-font-weight-regular);
color: var(--canon-fg-primary);
transition: border-color 0.2s ease-in-out, outline-color 0.2s ease-in-out;
width: 100%;
height: 100%;
cursor: inherit;
padding: 0;
}
.canon-Input::placeholder {
color: var(--canon-fg-secondary);
}
.canon-FormInput[data-size='small'] {
height: 2rem;
}
.canon-FormInput[data-size='medium'] {
height: 2.5rem;
}
.canon-Input[data-focused] {
outline-color: var(--canon-border-pressed);
outline-width: 0px;
}
.canon-FormInput:has(> .canon-Input:hover) {
border-color: var(--canon-border-hover);
}
.canon-FormInput[data-focused] {
border-color: var(--canon-border-pressed);
}
.canon-FormInput[data-invalid] {
border-color: var(--canon-fg-danger);
}
.canon-FormInput[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
border: 1px solid var(--canon-border-disabled);
}
@@ -1,48 +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 { forwardRef } from 'react';
import { Input } from 'react-aria-components';
import { useResponsiveValue } from '../../hooks/useResponsiveValue';
import clsx from 'clsx';
import type { FormInputProps } from './types';
/** @public */
export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
(props: FormInputProps, ref) => {
const { className, icon, size = 'small', ...rest } = props;
// Get the responsive value for the variant
const responsiveSize = useResponsiveValue(size);
return (
<div
className={clsx('canon-FormInput', className)}
data-size={responsiveSize}
>
{icon && (
<div className="canon-FormInputIcon" aria-hidden="true">
{icon}
</div>
)}
<Input className="canon-Input" {...rest} ref={ref} />
</div>
);
},
);
FormInput.displayName = 'FormInput';
@@ -21,34 +21,6 @@
width: 100%;
}
.canon-TextFieldLabelWrapper {
display: flex;
flex-direction: column;
margin-bottom: var(--canon-space-3);
gap: var(--canon-space-1);
}
.canon-TextFieldLabel {
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-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);
font-size: var(--canon-font-size-2);
color: var(--canon-fg-secondary);
margin: 0;
}
.canon-TextFieldInputWrapper {
position: relative;
}
@@ -18,11 +18,11 @@ 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 { FieldLabel } from '../FieldLabel';
import type { FormInputProps } from './types';
@@ -64,26 +64,11 @@ export const TextField = forwardRef<HTMLDivElement, FormInputProps>(
{...rest}
ref={ref}
>
{label && (
<div className="canon-TextFieldLabelWrapper">
{label && (
<Label className="canon-TextFieldLabel">
{label}
{secondaryLabelText && (
<span
aria-hidden="true"
className="canon-TextFieldSecondaryLabel"
>
({secondaryLabelText})
</span>
)}
</Label>
)}
{description && (
<div className="canon-TextFieldDescription">{description}</div>
)}
</div>
)}
<FieldLabel
label={label}
secondaryLabel={secondaryLabelText}
description={description}
/>
<div className="canon-TextFieldInputWrapper" data-size={responsiveSize}>
{icon && (
<div
@@ -15,4 +15,4 @@
*/
export * from './TextField';
export type { TextFieldProps } from './types';
export * from './types';
@@ -17,9 +17,12 @@
import type { TextFieldProps } from 'react-aria-components';
import { ReactNode } from 'react';
import type { Breakpoint } from '../../types';
import type { FieldLabelProps } from '../FieldLabel/types';
/** @public */
export interface FormInputProps extends Omit<TextFieldProps, 'size'> {
export interface FormInputProps
extends Omit<TextFieldProps, 'size'>,
FieldLabelProps {
/**
* An icon to render before the input
*/
@@ -30,24 +33,4 @@ export interface FormInputProps extends Omit<TextFieldProps, 'size'> {
* @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 description of the text field
*/
description?: string;
/**
* Whether the field is required
*/
isRequired?: boolean;
}
+7 -7
View File
@@ -17,15 +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/FormInput/FormInput.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';
@@ -33,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: 'Forms/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>
);
},
};