Improve TextField

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-04-04 13:07:35 +01:00
parent e28bc02fc9
commit eb9a514c17
8 changed files with 159 additions and 96 deletions
+4 -1
View File
@@ -9,7 +9,10 @@ function getAbsolutePath(value: string): any {
return dirname(require.resolve(join(value, 'package.json')));
}
const config: StorybookConfig = {
stories: ['../src/components/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
stories: [
'../src/components/**/*.stories.@(js|jsx|mjs|ts|tsx)',
'../src/stories/**/*.stories.@(js|jsx|mjs|ts|tsx)',
],
staticDirs: ['../static'],
addons: [
getAbsolutePath('@storybook/addon-webpack5-compiler-swc'),
+1
View File
@@ -66,6 +66,7 @@
"mini-css-extract-plugin": "^2.9.2",
"react": "^18.0.2",
"react-dom": "^18.0.2",
"react-hook-form": "^7.55.0",
"react-router-dom": "^6.3.0",
"storybook": "^8.6.8"
},
@@ -14,11 +14,9 @@
* limitations under the License.
*/
import React, { useState } from 'react';
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { TextField } from './TextField';
import { Form } from '@base-ui-components/react/form';
import { Button } from '../Button';
import { Flex } from '../Flex';
const meta = {
@@ -57,6 +55,13 @@ export const WithDescription: Story = {
},
};
export const Required: Story = {
args: {
...WithLabel.args,
required: true,
},
};
export const Disabled: Story = {
args: {
...WithLabel.args,
@@ -88,76 +93,17 @@ export const Responsive: Story = {
},
};
export const ShowErrorOnSubmit: Story = {
export const withError: Story = {
args: {
...WithLabel.args,
pattern: 'https?://.*',
type: 'url',
required: true,
label: 'Homepage',
name: 'url',
value: 'https://backstage-fake-site.com',
error: 'Invalid URL',
},
};
export const withErrorAndDescription: Story = {
args: {
...WithLabel.args,
error: 'Invalid URL',
description: 'Description',
},
decorators: [
Story => {
const [errors, setErrors] = useState<Record<string, string> | undefined>(
undefined,
);
const [loading, setLoading] = useState(false);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
setLoading(true);
await new Promise(resolve => {
setTimeout(resolve, 200);
});
try {
const url = new URL(formData.get('url') as string);
const allowedHosts = [
'backstage.io',
'beta.backstage.io',
'www.backstage.io',
];
if (!allowedHosts.includes(url.hostname)) {
setErrors({ url: 'The example domain is not allowed' });
setLoading(false);
return;
}
setErrors(undefined);
setLoading(false);
return;
} catch {
setErrors({ url: 'This is not a valid URL' });
setLoading(false);
}
};
return (
<Form
errors={errors}
onClearErrors={() => setErrors(undefined)}
onSubmit={handleSubmit}
>
<Story />
<Button
type="submit"
disabled={loading}
size="small"
style={{ marginTop: '0.75rem' }}
>
Submit
</Button>
</Form>
);
},
],
};
@@ -14,21 +14,21 @@
* limitations under the License.
*/
.canon-FieldRoot {
.canon-TextField {
display: flex;
flex-direction: column;
font-family: var(--canon-font-regular);
width: 100%;
}
.canon-FieldLabel {
.canon-TextField--label {
font-size: var(--canon-font-size-2);
font-weight: var(--canon-font-weight-regular);
color: var(--canon-fg-primary);
margin-bottom: var(--canon-space-1_5);
}
.canon-FieldDescription {
.canon-TextField--description {
font-size: var(--canon-font-size-2);
font-weight: var(--canon-font-weight-regular);
color: var(--canon-fg-secondary);
@@ -36,7 +36,7 @@
padding-top: var(--canon-space-1_5);
}
.canon-FieldError {
.canon-TextField--error {
font-size: var(--canon-font-size-2);
font-weight: var(--canon-font-weight-regular);
color: var(--canon-fg-danger);
@@ -44,7 +44,7 @@
padding-top: var(--canon-space-1_5);
}
.canon-Input {
.canon-TextField--input {
border-radius: var(--canon-radius-3);
border: 1px solid var(--canon-border);
padding: 0 var(--canon-space-4);
@@ -57,34 +57,41 @@
width: 100%;
}
.canon-Input::placeholder {
.canon-TextField--input::placeholder {
color: var(--canon-fg-secondary);
}
.canon-Input:hover {
.canon-TextField--input:hover {
border-color: var(--canon-border-hover);
}
.canon-Input:focus-visible {
.canon-TextField--input:focus-visible {
outline-color: var(--canon-border-pressed);
outline-width: 0px;
border-color: var(--canon-border-pressed);
}
.canon-Input[data-invalid] {
.canon-TextField--input[data-invalid] {
border-color: var(--canon-fg-danger);
}
.canon-Input[data-disabled] {
.canon-TextField--input[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
border: 1px solid var(--canon-border-disabled);
}
.canon-Input--size-small {
.canon-TextField--input-size-small {
height: 2rem;
}
.canon-Input--size-medium {
.canon-TextField--input-size-medium {
height: 2.5rem;
}
.canon-TextField--required {
color: var(--canon-fg-danger);
font-size: var(--canon-font-size-3);
font-weight: var(--canon-font-weight-regular);
margin-left: var(--canon-space-1);
}
@@ -15,7 +15,6 @@
*/
import React, { forwardRef } from 'react';
import { Field } from '@base-ui-components/react/field';
import { Input } from '@base-ui-components/react/input';
import { useResponsiveValue } from '../../hooks/useResponsiveValue';
import clsx from 'clsx';
@@ -23,7 +22,7 @@ import clsx from 'clsx';
import type { TextFieldProps } from './types';
/** @public */
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
export const TextField = forwardRef<HTMLDivElement, TextFieldProps>(
(props: TextFieldProps, ref) => {
const {
className,
@@ -31,30 +30,66 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
label,
description,
name,
error,
required,
disabled,
...rest
} = props;
// Get the responsive value for the variant
const responsiveSize = useResponsiveValue(size);
// Generate unique IDs for accessibility
const inputId = `textfield-${name}`;
const descriptionId = `${inputId}-description`;
const errorId = `${inputId}-error`;
return (
<Field.Root className={clsx('canon-FieldRoot', className)} name={name}>
<div
className={clsx('canon-TextField', className)}
ref={ref}
role="group"
aria-labelledby={label ? inputId : undefined}
aria-describedby={clsx({
[descriptionId]: description,
[errorId]: error,
})}
>
{label && (
<Field.Label className="canon-FieldLabel">{label}</Field.Label>
<label
className="canon-TextField--label"
htmlFor={inputId}
id={inputId}
>
{label}
{required && (
<span aria-hidden="true" className="canon-TextField--required">
*
</span>
)}
</label>
)}
<Input
ref={ref}
id={inputId}
type={name}
className={clsx('canon-Input', `canon-Input--size-${responsiveSize}`)}
className={clsx('canon-TextField--input', {
'canon-TextField--input-size-small': responsiveSize === 'small',
'canon-TextField--input-size-medium': responsiveSize === 'medium',
})}
data-invalid={error}
{...rest}
/>
{description && (
<Field.Description className="canon-FieldDescription">
<p className="canon-TextField--description" id={descriptionId}>
{description}
</Field.Description>
</p>
)}
<Field.Error className="canon-FieldError" />
</Field.Root>
{error && (
<p className="canon-TextField--error" id={errorId} role="alert">
{error}
</p>
)}
</div>
);
},
);
@@ -44,4 +44,6 @@ export interface TextFieldProps
* The name of the text field
*/
name: string;
error?: string | null;
}
@@ -0,0 +1,68 @@
/*
* 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 React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { TextField } from '../components/TextField';
import { useForm, SubmitHandler } from 'react-hook-form';
const meta = {
title: 'Global/Form',
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
type Inputs = {
example: string;
exampleRequired: string;
};
export const Default: Story = {
render: () => {
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<Inputs>();
const onSubmit: SubmitHandler<Inputs> = data => console.log(data);
console.log(watch('example')); // watch input value by passing the name of it
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* register your input into the hook by invoking the "register" function */}
<input defaultValue="test" {...register('example')} />
{/* include validation with required or other standard HTML validation rules */}
{/* <input {...register('exampleRequired', { required: true })} /> */}
<TextField
label="Example Required"
description="This is an example required field"
{...register('exampleRequired', { required: true })}
error={errors.exampleRequired?.message}
/>
{/* errors will return when field validation fails */}
{errors.exampleRequired && <span>This field is required</span>}
<input type="submit" />
</form>
);
},
};
+2 -1
View File
@@ -3800,6 +3800,7 @@ __metadata:
mini-css-extract-plugin: "npm:^2.9.2"
react: "npm:^18.0.2"
react-dom: "npm:^18.0.2"
react-hook-form: "npm:^7.55.0"
react-router-dom: "npm:^6.3.0"
storybook: "npm:^8.6.8"
peerDependencies:
@@ -41500,7 +41501,7 @@ __metadata:
languageName: node
linkType: hard
"react-hook-form@npm:^7.12.2":
"react-hook-form@npm:^7.12.2, react-hook-form@npm:^7.55.0":
version: 7.55.0
resolution: "react-hook-form@npm:7.55.0"
peerDependencies: