From eb9a514c17008ee0becda2a927f146b80120e001 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Fri, 4 Apr 2025 13:07:35 +0100 Subject: [PATCH 1/7] Improve TextField Signed-off-by: Charles de Dreuille --- packages/canon/.storybook/main.ts | 5 +- packages/canon/package.json | 1 + .../TextField/TextField.stories.tsx | 90 ++++--------------- .../components/TextField/TextField.styles.css | 31 ++++--- .../src/components/TextField/TextField.tsx | 55 +++++++++--- .../canon/src/components/TextField/types.ts | 2 + packages/canon/src/stories/Form.stories.tsx | 68 ++++++++++++++ yarn.lock | 3 +- 8 files changed, 159 insertions(+), 96 deletions(-) create mode 100644 packages/canon/src/stories/Form.stories.tsx diff --git a/packages/canon/.storybook/main.ts b/packages/canon/.storybook/main.ts index bb00568cb0..8bd28db556 100644 --- a/packages/canon/.storybook/main.ts +++ b/packages/canon/.storybook/main.ts @@ -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'), diff --git a/packages/canon/package.json b/packages/canon/package.json index b5d2d715f6..e0febe544d 100644 --- a/packages/canon/package.json +++ b/packages/canon/package.json @@ -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" }, diff --git a/packages/canon/src/components/TextField/TextField.stories.tsx b/packages/canon/src/components/TextField/TextField.stories.tsx index dd5558437b..39f6d1926f 100644 --- a/packages/canon/src/components/TextField/TextField.stories.tsx +++ b/packages/canon/src/components/TextField/TextField.stories.tsx @@ -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 | undefined>( - undefined, - ); - const [loading, setLoading] = useState(false); - - const handleSubmit = async (event: React.FormEvent) => { - 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 ( -
setErrors(undefined)} - onSubmit={handleSubmit} - > - - - - ); - }, - ], }; diff --git a/packages/canon/src/components/TextField/TextField.styles.css b/packages/canon/src/components/TextField/TextField.styles.css index bd36661b23..5ffae8aa16 100644 --- a/packages/canon/src/components/TextField/TextField.styles.css +++ b/packages/canon/src/components/TextField/TextField.styles.css @@ -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); +} diff --git a/packages/canon/src/components/TextField/TextField.tsx b/packages/canon/src/components/TextField/TextField.tsx index ccea9e8c65..2b96bfe555 100644 --- a/packages/canon/src/components/TextField/TextField.tsx +++ b/packages/canon/src/components/TextField/TextField.tsx @@ -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( +export const TextField = forwardRef( (props: TextFieldProps, ref) => { const { className, @@ -31,30 +30,66 @@ export const TextField = forwardRef( 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 ( - +
{label && ( - {label} + )} {description && ( - +

{description} - +

)} - - + {error && ( + + )} +
); }, ); diff --git a/packages/canon/src/components/TextField/types.ts b/packages/canon/src/components/TextField/types.ts index 100d5fcb17..fbb1c0393d 100644 --- a/packages/canon/src/components/TextField/types.ts +++ b/packages/canon/src/components/TextField/types.ts @@ -44,4 +44,6 @@ export interface TextFieldProps * The name of the text field */ name: string; + + error?: string | null; } diff --git a/packages/canon/src/stories/Form.stories.tsx b/packages/canon/src/stories/Form.stories.tsx new file mode 100644 index 0000000000..76710d30dc --- /dev/null +++ b/packages/canon/src/stories/Form.stories.tsx @@ -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; + +type Inputs = { + example: string; + exampleRequired: string; +}; + +export const Default: Story = { + render: () => { + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm(); + const onSubmit: SubmitHandler = data => console.log(data); + + console.log(watch('example')); // watch input value by passing the name of it + + return ( +
+ {/* register your input into the hook by invoking the "register" function */} + + + {/* include validation with required or other standard HTML validation rules */} + {/* */} + + + + {/* errors will return when field validation fails */} + {errors.exampleRequired && This field is required} + + + + ); + }, +}; diff --git a/yarn.lock b/yarn.lock index 9b199ff03b..839ccaefe9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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: From 7b544e19d5f57209a962390572595a4d3fe61f27 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Fri, 4 Apr 2025 13:32:46 +0100 Subject: [PATCH 2/7] Improve a11y Signed-off-by: Charles de Dreuille --- packages/canon/package.json | 1 - packages/canon/report.api.md | 3 +- .../components/TextField/TextField.styles.css | 4 +-- .../src/components/TextField/TextField.tsx | 32 +++++++------------ .../canon/src/components/TextField/types.ts | 3 ++ yarn.lock | 3 +- 6 files changed, 20 insertions(+), 26 deletions(-) diff --git a/packages/canon/package.json b/packages/canon/package.json index e0febe544d..b5d2d715f6 100644 --- a/packages/canon/package.json +++ b/packages/canon/package.json @@ -66,7 +66,6 @@ "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" }, diff --git a/packages/canon/report.api.md b/packages/canon/report.api.md index 766b40febd..c4cfbefcc2 100644 --- a/packages/canon/report.api.md +++ b/packages/canon/report.api.md @@ -1055,7 +1055,7 @@ export { Text_2 as Text }; // @public (undocumented) export const TextField: React_2.ForwardRefExoticComponent< - TextFieldProps & React_2.RefAttributes + TextFieldProps & React_2.RefAttributes >; // @public (undocumented) @@ -1063,6 +1063,7 @@ export interface TextFieldProps extends Omit, 'size'> { className?: string; description?: string; + error?: string | null; label?: string; name: string; size?: 'small' | 'medium' | Partial>; diff --git a/packages/canon/src/components/TextField/TextField.styles.css b/packages/canon/src/components/TextField/TextField.styles.css index 5ffae8aa16..899acc0034 100644 --- a/packages/canon/src/components/TextField/TextField.styles.css +++ b/packages/canon/src/components/TextField/TextField.styles.css @@ -90,8 +90,8 @@ } .canon-TextField--required { - color: var(--canon-fg-danger); - font-size: var(--canon-font-size-3); + color: var(--canon-fg-secondary); + font-size: var(--canon-font-size-2); font-weight: var(--canon-font-weight-regular); margin-left: var(--canon-space-1); } diff --git a/packages/canon/src/components/TextField/TextField.tsx b/packages/canon/src/components/TextField/TextField.tsx index 2b96bfe555..c3e1783561 100644 --- a/packages/canon/src/components/TextField/TextField.tsx +++ b/packages/canon/src/components/TextField/TextField.tsx @@ -32,7 +32,6 @@ export const TextField = forwardRef( name, error, required, - disabled, ...rest } = props; @@ -40,31 +39,18 @@ export const TextField = forwardRef( const responsiveSize = useResponsiveValue(size); // Generate unique IDs for accessibility - const inputId = `textfield-${name}`; - const descriptionId = `${inputId}-description`; - const errorId = `${inputId}-error`; + const inputId = React.useId(); + const descriptionId = React.useId(); + const errorId = React.useId(); return ( -
+
{label && ( -