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