From b06f680a99af9630289fda4e82ed4e998297bfcb Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 24 Mar 2025 17:42:55 +0000 Subject: [PATCH 01/13] Add new TextField component Signed-off-by: Charles de Dreuille --- packages/canon/css/components.css | 78 ++++---- packages/canon/css/field.css | 37 ---- packages/canon/css/input.css | 38 ---- packages/canon/css/styles.css | 78 ++++---- packages/canon/css/textfield.css | 74 ++++++++ .../src/components/Field/Field.stories.tsx | 87 --------- .../src/components/Field/Field.styles.css | 53 ------ packages/canon/src/components/Field/Field.tsx | 92 --------- .../src/components/Input/Input.stories.tsx | 41 ---- packages/canon/src/components/Input/Input.tsx | 43 ----- packages/canon/src/components/Input/index.ts | 18 -- .../types.ts => TextField/TextField.props.ts} | 21 ++- .../TextField/TextField.stories.tsx | 177 ++++++++++++++++++ .../TextField.styles.css} | 40 +++- .../src/components/TextField/TextField.tsx | 70 +++++++ .../components/{Field => TextField}/index.ts | 2 +- .../canon/src/components/TextField/types.ts | 45 +++++ packages/canon/src/css/components.css | 3 +- packages/canon/src/index.ts | 3 +- 19 files changed, 499 insertions(+), 501 deletions(-) delete mode 100644 packages/canon/css/field.css delete mode 100644 packages/canon/css/input.css create mode 100644 packages/canon/css/textfield.css delete mode 100644 packages/canon/src/components/Field/Field.stories.tsx delete mode 100644 packages/canon/src/components/Field/Field.styles.css delete mode 100644 packages/canon/src/components/Field/Field.tsx delete mode 100644 packages/canon/src/components/Input/Input.stories.tsx delete mode 100644 packages/canon/src/components/Input/Input.tsx delete mode 100644 packages/canon/src/components/Input/index.ts rename packages/canon/src/components/{Input/types.ts => TextField/TextField.props.ts} (57%) create mode 100644 packages/canon/src/components/TextField/TextField.stories.tsx rename packages/canon/src/components/{Input/Input.styles.css => TextField/TextField.styles.css} (61%) create mode 100644 packages/canon/src/components/TextField/TextField.tsx rename packages/canon/src/components/{Field => TextField}/index.ts (95%) create mode 100644 packages/canon/src/components/TextField/types.ts diff --git a/packages/canon/css/components.css b/packages/canon/css/components.css index cb376e64ed..b95ea2425a 100644 --- a/packages/canon/css/components.css +++ b/packages/canon/css/components.css @@ -404,6 +404,36 @@ height: 1.5rem; } +.canon-FieldRoot { + font-family: var(--canon-font-regular); + flex-direction: column; + width: 100%; + display: flex; +} + +.canon-FieldLabel { + 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 { + font-size: var(--canon-font-size-2); + font-weight: var(--canon-font-weight-regular); + color: var(--canon-fg-secondary); + padding-top: var(--canon-space-1_5); + margin: 0; +} + +.canon-FieldError { + 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-Input { border-radius: var(--canon-radius-3); border: 1px solid var(--canon-border); @@ -435,52 +465,20 @@ border-color: var(--canon-fg-danger); } -.canon-Input--size-sm { +.canon-Input[data-disabled] { + opacity: .5; + cursor: not-allowed; + border: 1px solid var(--canon-border-disabled); +} + +.canon-Input--size-small { height: 2rem; } -.canon-Input--size-md { +.canon-Input--size-medium { height: 2.5rem; } -.canon-FieldRoot { - font-family: var(--canon-font-regular); - flex-direction: column; - width: 100%; - display: flex; -} - -.canon-FieldLabel { - 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 { - font-size: var(--canon-font-size-2); - font-weight: var(--canon-font-weight-regular); - color: var(--canon-fg-secondary); - padding-top: var(--canon-space-1_5); - margin: 0; -} - -.canon-FieldError { - 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-FieldValidity { - font-size: var(--canon-font-size-2); - font-weight: var(--canon-font-weight-regular); - color: var(--canon-fg-secondary); - padding-top: var(--canon-space-1_5); - margin: 0; -} - .canon-MenuTrigger { box-sizing: border-box; border: 1px solid var(--color-gray-200); diff --git a/packages/canon/css/field.css b/packages/canon/css/field.css deleted file mode 100644 index dcd3a71af5..0000000000 --- a/packages/canon/css/field.css +++ /dev/null @@ -1,37 +0,0 @@ -.canon-FieldRoot { - font-family: var(--canon-font-regular); - flex-direction: column; - width: 100%; - display: flex; -} - -.canon-FieldLabel { - 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 { - font-size: var(--canon-font-size-2); - font-weight: var(--canon-font-weight-regular); - color: var(--canon-fg-secondary); - padding-top: var(--canon-space-1_5); - margin: 0; -} - -.canon-FieldError { - 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-FieldValidity { - font-size: var(--canon-font-size-2); - font-weight: var(--canon-font-weight-regular); - color: var(--canon-fg-secondary); - padding-top: var(--canon-space-1_5); - margin: 0; -} diff --git a/packages/canon/css/input.css b/packages/canon/css/input.css deleted file mode 100644 index a0ca6b18e4..0000000000 --- a/packages/canon/css/input.css +++ /dev/null @@ -1,38 +0,0 @@ -.canon-Input { - border-radius: var(--canon-radius-3); - border: 1px solid var(--canon-border); - padding: 0 var(--canon-space-4); - 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); - color: var(--canon-fg-primary); - width: 100%; - transition: border-color .2s ease-in-out, outline-color .2s ease-in-out; -} - -.canon-Input::placeholder { - color: var(--canon-fg-secondary); -} - -.canon-Input:hover { - border-color: var(--canon-border-hover); -} - -.canon-Input:focus-visible { - outline-color: var(--canon-border-pressed); - border-color: var(--canon-border-pressed); - outline-width: 0; -} - -.canon-Input[data-invalid] { - border-color: var(--canon-fg-danger); -} - -.canon-Input--size-sm { - height: 2rem; -} - -.canon-Input--size-md { - height: 2.5rem; -} diff --git a/packages/canon/css/styles.css b/packages/canon/css/styles.css index 3b312c220e..5c747ce3d1 100644 --- a/packages/canon/css/styles.css +++ b/packages/canon/css/styles.css @@ -9605,6 +9605,36 @@ height: 1.5rem; } +.canon-FieldRoot { + font-family: var(--canon-font-regular); + flex-direction: column; + width: 100%; + display: flex; +} + +.canon-FieldLabel { + 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 { + font-size: var(--canon-font-size-2); + font-weight: var(--canon-font-weight-regular); + color: var(--canon-fg-secondary); + padding-top: var(--canon-space-1_5); + margin: 0; +} + +.canon-FieldError { + 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-Input { border-radius: var(--canon-radius-3); border: 1px solid var(--canon-border); @@ -9636,52 +9666,20 @@ border-color: var(--canon-fg-danger); } -.canon-Input--size-sm { +.canon-Input[data-disabled] { + opacity: .5; + cursor: not-allowed; + border: 1px solid var(--canon-border-disabled); +} + +.canon-Input--size-small { height: 2rem; } -.canon-Input--size-md { +.canon-Input--size-medium { height: 2.5rem; } -.canon-FieldRoot { - font-family: var(--canon-font-regular); - flex-direction: column; - width: 100%; - display: flex; -} - -.canon-FieldLabel { - 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 { - font-size: var(--canon-font-size-2); - font-weight: var(--canon-font-weight-regular); - color: var(--canon-fg-secondary); - padding-top: var(--canon-space-1_5); - margin: 0; -} - -.canon-FieldError { - 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-FieldValidity { - font-size: var(--canon-font-size-2); - font-weight: var(--canon-font-weight-regular); - color: var(--canon-fg-secondary); - padding-top: var(--canon-space-1_5); - margin: 0; -} - .canon-MenuTrigger { box-sizing: border-box; border: 1px solid var(--color-gray-200); diff --git a/packages/canon/css/textfield.css b/packages/canon/css/textfield.css new file mode 100644 index 0000000000..e40251fd5a --- /dev/null +++ b/packages/canon/css/textfield.css @@ -0,0 +1,74 @@ +.canon-FieldRoot { + font-family: var(--canon-font-regular); + flex-direction: column; + width: 100%; + display: flex; +} + +.canon-FieldLabel { + 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 { + font-size: var(--canon-font-size-2); + font-weight: var(--canon-font-weight-regular); + color: var(--canon-fg-secondary); + padding-top: var(--canon-space-1_5); + margin: 0; +} + +.canon-FieldError { + 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-Input { + border-radius: var(--canon-radius-3); + border: 1px solid var(--canon-border); + padding: 0 var(--canon-space-4); + 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); + color: var(--canon-fg-primary); + width: 100%; + transition: border-color .2s ease-in-out, outline-color .2s ease-in-out; +} + +.canon-Input::placeholder { + color: var(--canon-fg-secondary); +} + +.canon-Input:hover { + border-color: var(--canon-border-hover); +} + +.canon-Input:focus-visible { + outline-color: var(--canon-border-pressed); + border-color: var(--canon-border-pressed); + outline-width: 0; +} + +.canon-Input[data-invalid] { + border-color: var(--canon-fg-danger); +} + +.canon-Input[data-disabled] { + opacity: .5; + cursor: not-allowed; + border: 1px solid var(--canon-border-disabled); +} + +.canon-Input--size-small { + height: 2rem; +} + +.canon-Input--size-medium { + height: 2.5rem; +} diff --git a/packages/canon/src/components/Field/Field.stories.tsx b/packages/canon/src/components/Field/Field.stories.tsx deleted file mode 100644 index b949010f5f..0000000000 --- a/packages/canon/src/components/Field/Field.stories.tsx +++ /dev/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 React from 'react'; -import type { Meta, StoryObj } from '@storybook/react'; -import { Field } from './Field'; -import { Input } from '../Input/Input'; - -const meta = { - title: 'Components/Field', - component: Field.Root, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - render: () => ( - - Label - - Error - - ), -}; - -export const WithLabelAndDescription: Story = { - render: () => ( - - Label - - Description - - ), -}; - -export const WithError: Story = { - render: () => ( - - value !== 'Backstage' ? 'Please enter a different name' : null - } - validationMode="onChange" - > - Name - - - An error will show if the value is not Backstage - - Error - - ), -}; - -export const WithValidity: Story = { - render: () => ( - - value !== 'Backstage' ? 'Please enter a different name' : null - } - > - Name - - - An error will show if the value is not Backstage - - - {validityState => ( -
{validityState.value ? 'Not Backstage' : 'Backstage'}
- )} -
-
- ), -}; diff --git a/packages/canon/src/components/Field/Field.styles.css b/packages/canon/src/components/Field/Field.styles.css deleted file mode 100644 index 6a8e3c8e22..0000000000 --- a/packages/canon/src/components/Field/Field.styles.css +++ /dev/null @@ -1,53 +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-FieldRoot { - display: flex; - flex-direction: column; - font-family: var(--canon-font-regular); - width: 100%; -} - -.canon-FieldLabel { - 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 { - font-size: var(--canon-font-size-2); - font-weight: var(--canon-font-weight-regular); - color: var(--canon-fg-secondary); - margin: 0; - padding-top: var(--canon-space-1_5); -} - -.canon-FieldError { - 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-FieldValidity { - font-size: var(--canon-font-size-2); - font-weight: var(--canon-font-weight-regular); - color: var(--canon-fg-secondary); - margin: 0; - padding-top: var(--canon-space-1_5); -} diff --git a/packages/canon/src/components/Field/Field.tsx b/packages/canon/src/components/Field/Field.tsx deleted file mode 100644 index b0c86b2c85..0000000000 --- a/packages/canon/src/components/Field/Field.tsx +++ /dev/null @@ -1,92 +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 React from 'react'; -import { Field as FieldPrimitive } from '@base-ui-components/react/field'; -import clsx from 'clsx'; - -const FieldRoot = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -FieldRoot.displayName = FieldPrimitive.Root.displayName; - -const FieldLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -FieldLabel.displayName = FieldPrimitive.Label.displayName; - -const FieldDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -FieldDescription.displayName = FieldPrimitive.Description.displayName; - -const FieldError = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -FieldError.displayName = FieldPrimitive.Error.displayName; - -const FieldValidity = ({ - children, - className, - ...props -}: React.ComponentPropsWithoutRef & { - className?: string; -}) => ( - - {validityState => ( -
- {children(validityState)} -
- )} -
-); - -/** @public */ -export const Field = { - Root: FieldRoot, - Label: FieldLabel, - Description: FieldDescription, - Error: FieldError, - Validity: FieldValidity, -}; diff --git a/packages/canon/src/components/Input/Input.stories.tsx b/packages/canon/src/components/Input/Input.stories.tsx deleted file mode 100644 index c1c76ddc09..0000000000 --- a/packages/canon/src/components/Input/Input.stories.tsx +++ /dev/null @@ -1,41 +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 React from 'react'; -import type { Meta, StoryObj } from '@storybook/react'; -import { Input } from './Input'; -import { Flex } from '../Flex'; - -const meta = { - title: 'Components/Input', - component: Input, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Primary: Story = { - render: () => , -}; - -export const Sizes: Story = { - render: () => ( - - - - - ), -}; diff --git a/packages/canon/src/components/Input/Input.tsx b/packages/canon/src/components/Input/Input.tsx deleted file mode 100644 index 5eb68e7bf4..0000000000 --- a/packages/canon/src/components/Input/Input.tsx +++ /dev/null @@ -1,43 +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 React, { ElementRef, forwardRef } from 'react'; -import { Input as InputPrimitive } from '@base-ui-components/react/input'; -import clsx from 'clsx'; -import type { InputProps } from './types'; - -/** @public */ -const Input = forwardRef, InputProps>( - (props, ref) => { - const { size = 'md', className, ...rest } = props; - - return ( - - ); - }, -); - -Input.displayName = InputPrimitive.displayName; - -export { Input }; diff --git a/packages/canon/src/components/Input/index.ts b/packages/canon/src/components/Input/index.ts deleted file mode 100644 index ce4e08ccc2..0000000000 --- a/packages/canon/src/components/Input/index.ts +++ /dev/null @@ -1,18 +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. - */ - -export { Input } from './Input'; -export type { InputProps } from './types'; diff --git a/packages/canon/src/components/Input/types.ts b/packages/canon/src/components/TextField/TextField.props.ts similarity index 57% rename from packages/canon/src/components/Input/types.ts rename to packages/canon/src/components/TextField/TextField.props.ts index ac013d1bbd..db0d09567e 100644 --- a/packages/canon/src/components/Input/types.ts +++ b/packages/canon/src/components/TextField/TextField.props.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. @@ -14,9 +14,20 @@ * limitations under the License. */ -import { Input } from '@base-ui-components/react/input'; +import type { PropDef, GetPropDefTypes } from '../../props/prop-def'; /** @public */ -export interface InputProps extends Omit { - size?: 'sm' | 'md'; -} +export const textFieldPropDefs = { + size: { + type: 'enum', + values: ['small', 'medium'], + className: 'canon-Button--size', + default: 'medium', + responsive: true, + }, +} satisfies { + size: PropDef<'small' | 'medium'>; +}; + +/** @public */ +export type TextFieldOwnProps = GetPropDefTypes; diff --git a/packages/canon/src/components/TextField/TextField.stories.tsx b/packages/canon/src/components/TextField/TextField.stories.tsx new file mode 100644 index 0000000000..a9af58dcf9 --- /dev/null +++ b/packages/canon/src/components/TextField/TextField.stories.tsx @@ -0,0 +1,177 @@ +/* + * 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, { useState } 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 { userEvent, waitFor, within, expect } from '@storybook/test'; +import { Flex } from '../Flex'; + +const meta = { + title: 'Components/TextField', + component: TextField, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + name: 'url', + placeholder: 'Enter a URL', + }, +}; + +export const Filled: Story = { + args: { + ...Default.args, + defaultValue: 'https://example.com', + }, +}; + +export const WithLabel: Story = { + args: { + ...Default.args, + label: 'Label', + }, +}; + +export const WithDescription: Story = { + args: { + ...WithLabel.args, + description: 'Description', + }, +}; + +export const Disabled: Story = { + args: { + ...WithLabel.args, + disabled: true, + }, +}; + +export const Sizes: Story = { + args: { + ...Default.args, + label: 'Label', + description: 'Description', + }, + render: args => ( + + + + + ), +}; + +export const Responsive: Story = { + args: { + ...WithLabel.args, + size: { + initial: 'small', + sm: 'medium', + }, + }, +}; + +async function submitForm(value: string) { + // Mimic a server response + await new Promise(resolve => { + setTimeout(resolve, 200); + }); + + try { + const url = new URL(value); + + if (url.hostname.endsWith('example.com')) { + return { error: 'The example domain is not allowed' }; + } + } catch { + return { error: 'This is not a valid URL' }; + } + + return { success: true }; +} + +export const ShowErrorOnSubmit: Story = { + args: { + ...WithLabel.args, + pattern: 'https?://.*', + type: 'url', + required: true, + label: 'Homepage', + }, + decorators: [ + Story => { + const [errors, setErrors] = useState({}); + const [loading, setLoading] = useState(false); + + return ( +
{ + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const value = formData.get('url') as string; + + setLoading(true); + const response = await submitForm(value); + const serverErrors = { + url: response.error, + }; + + setErrors(serverErrors); + setLoading(false); + }} + > + + + + ); + }, + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const input = canvas.getByLabelText('Homepage', { + selector: 'input', + }); + + await userEvent.type(input, 'https://example.com', { + delay: 20, + }); + + const submitButton = canvas.getByRole('button'); + + await userEvent.click(submitButton); + + await waitFor(() => { + expect( + canvas.getByText('The example domain is not allowed'), + ).toBeInTheDocument(); + }); + }, +}; diff --git a/packages/canon/src/components/Input/Input.styles.css b/packages/canon/src/components/TextField/TextField.styles.css similarity index 61% rename from packages/canon/src/components/Input/Input.styles.css rename to packages/canon/src/components/TextField/TextField.styles.css index 0ff108008d..bd36661b23 100644 --- a/packages/canon/src/components/Input/Input.styles.css +++ b/packages/canon/src/components/TextField/TextField.styles.css @@ -14,6 +14,36 @@ * limitations under the License. */ +.canon-FieldRoot { + display: flex; + flex-direction: column; + font-family: var(--canon-font-regular); + width: 100%; +} + +.canon-FieldLabel { + 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 { + font-size: var(--canon-font-size-2); + font-weight: var(--canon-font-weight-regular); + color: var(--canon-fg-secondary); + margin: 0; + padding-top: var(--canon-space-1_5); +} + +.canon-FieldError { + 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-Input { border-radius: var(--canon-radius-3); border: 1px solid var(--canon-border); @@ -45,10 +75,16 @@ border-color: var(--canon-fg-danger); } -.canon-Input--size-sm { +.canon-Input[data-disabled] { + opacity: 0.5; + cursor: not-allowed; + border: 1px solid var(--canon-border-disabled); +} + +.canon-Input--size-small { height: 2rem; } -.canon-Input--size-md { +.canon-Input--size-medium { height: 2.5rem; } diff --git a/packages/canon/src/components/TextField/TextField.tsx b/packages/canon/src/components/TextField/TextField.tsx new file mode 100644 index 0000000000..aca4f070d4 --- /dev/null +++ b/packages/canon/src/components/TextField/TextField.tsx @@ -0,0 +1,70 @@ +/* + * 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, { 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'; + +import type { TextFieldProps } from './types'; + +/** @public */ +export const TextField = forwardRef( + (props: TextFieldProps, ref) => { + const { + className, + size = 'medium', + label, + description, + name, + ...rest + } = props; + + // Get the responsive value for the variant + const responsiveSize = useResponsiveValue(size); + + return ( + + {label && ( + + {label} + + )} + + {description && ( + + {description} + + )} + + + ); + }, +); + +TextField.displayName = 'TextField'; diff --git a/packages/canon/src/components/Field/index.ts b/packages/canon/src/components/TextField/index.ts similarity index 95% rename from packages/canon/src/components/Field/index.ts rename to packages/canon/src/components/TextField/index.ts index 297ed9b27b..d491fe5e42 100644 --- a/packages/canon/src/components/Field/index.ts +++ b/packages/canon/src/components/TextField/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './Field'; +export * from './TextField'; diff --git a/packages/canon/src/components/TextField/types.ts b/packages/canon/src/components/TextField/types.ts new file mode 100644 index 0000000000..9b1b06509b --- /dev/null +++ b/packages/canon/src/components/TextField/types.ts @@ -0,0 +1,45 @@ +/* + * 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 type { TextFieldOwnProps } from './TextField.props'; + +export interface TextFieldProps + extends Omit, 'size'> { + /** + * The class name of the text field + */ + className?: string; + + /** + * The size of the text field + * @defaultValue 'medium' + */ + size?: TextFieldOwnProps['size']; + + /** + * The label of the text field + */ + label?: string; + + /** + * The description of the text field + */ + description?: string; + + /** + * The name of the text field + */ + name: string; +} diff --git a/packages/canon/src/css/components.css b/packages/canon/src/css/components.css index 8c77a0f58c..9d86eeaeff 100644 --- a/packages/canon/src/css/components.css +++ b/packages/canon/src/css/components.css @@ -25,8 +25,7 @@ @import '../components/Text/styles.css'; @import '../components/Heading/styles.css'; @import '../components/IconButton/styles.css'; -@import '../components/Input/Input.styles.css'; -@import '../components/Field/Field.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'; diff --git a/packages/canon/src/index.ts b/packages/canon/src/index.ts index 3c9a28b44d..b2dee95cd9 100644 --- a/packages/canon/src/index.ts +++ b/packages/canon/src/index.ts @@ -37,8 +37,7 @@ export * from './components/Icon'; export * from './components/IconButton'; export * from './components/Checkbox'; export * from './components/Table'; -export * from './components/Input'; -export * from './components/Field'; +export * from './components/TextField'; export * from './components/Tooltip'; export * from './components/Menu'; export * from './components/ScrollArea'; From 43a5ff532c6ed43257b05ad6ac431a23cb8a9562 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 24 Mar 2025 17:53:57 +0000 Subject: [PATCH 02/13] Potential fix for code scanning alert no. 1043: Incomplete URL substring sanitization Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Charles de Dreuille --- packages/canon/src/components/TextField/TextField.stories.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/canon/src/components/TextField/TextField.stories.tsx b/packages/canon/src/components/TextField/TextField.stories.tsx index a9af58dcf9..e378fc1c58 100644 --- a/packages/canon/src/components/TextField/TextField.stories.tsx +++ b/packages/canon/src/components/TextField/TextField.stories.tsx @@ -98,7 +98,8 @@ async function submitForm(value: string) { try { const url = new URL(value); - if (url.hostname.endsWith('example.com')) { + const allowedHosts = ['example.com', 'beta.example.com', 'www.example.com']; + if (!allowedHosts.includes(url.hostname)) { return { error: 'The example domain is not allowed' }; } } catch { From 5074d61cf16c3cb765db2e2c860c0d5f818539a6 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 24 Mar 2025 18:06:28 +0000 Subject: [PATCH 03/13] Fix report + changeset Signed-off-by: Charles de Dreuille --- .changeset/whole-papers-retire.md | 5 ++ packages/canon/report.api.md | 72 ++++++++----------- .../canon/src/components/TextField/index.ts | 3 + .../canon/src/components/TextField/types.ts | 1 + tsconfig.json | 1 + 5 files changed, 39 insertions(+), 43 deletions(-) create mode 100644 .changeset/whole-papers-retire.md diff --git a/.changeset/whole-papers-retire.md b/.changeset/whole-papers-retire.md new file mode 100644 index 0000000000..1dc392317c --- /dev/null +++ b/.changeset/whole-papers-retire.md @@ -0,0 +1,5 @@ +--- +'@backstage/canon': minor +--- + +Added a new TextField component to replace the Field and Input component. After feedback, it became clear that we needed to build a more opinionated version to avoid any problem in the future. diff --git a/packages/canon/report.api.md b/packages/canon/report.api.md index e3d55339ea..600e4ba878 100644 --- a/packages/canon/report.api.md +++ b/packages/canon/report.api.md @@ -5,9 +5,7 @@ ```ts import { Context } from 'react'; import type { CSSProperties } from 'react'; -import { Field as Field_2 } from '@base-ui-components/react/field'; import { ForwardRefExoticComponent } from 'react'; -import { Input as Input_2 } from '@base-ui-components/react/input'; import { Menu as Menu_2 } from '@base-ui-components/react/menu'; import { default as React_2 } from 'react'; import * as React_3 from 'react'; @@ -245,36 +243,6 @@ export type EnumPropDef = { required?: boolean; }; -// @public (undocumented) -export const Field: { - Root: React_2.ForwardRefExoticComponent< - Omit, 'ref'> & - React_2.RefAttributes - >; - Label: React_2.ForwardRefExoticComponent< - Omit, 'ref'> & - React_2.RefAttributes - >; - Description: React_2.ForwardRefExoticComponent< - Omit< - Field_2.Description.Props & React_2.RefAttributes, - 'ref' - > & - React_2.RefAttributes - >; - Error: React_2.ForwardRefExoticComponent< - Omit, 'ref'> & - React_2.RefAttributes - >; - Validity: ({ - children, - className, - ...props - }: React_2.ComponentPropsWithoutRef & { - className?: string; - }) => React_2.JSX.Element; -}; - // @public (undocumented) export const Flex: ForwardRefExoticComponent< FlexProps & RefAttributes @@ -717,17 +685,6 @@ export interface IconProviderProps { // @public (undocumented) export const icons: IconMap; -// @public (undocumented) -export const Input: React_2.ForwardRefExoticComponent< - InputProps & React_2.RefAttributes ->; - -// @public (undocumented) -export interface InputProps extends Omit { - // (undocumented) - size?: 'sm' | 'md'; -} - // @public (undocumented) export type JustifyContent = | 'stretch' @@ -1067,6 +1024,35 @@ const Text_2: React_2.ForwardRefExoticComponent< >; export { Text_2 as Text }; +// @public (undocumented) +export const TextField: React_2.ForwardRefExoticComponent< + TextFieldProps & React_2.RefAttributes +>; + +// @public (undocumented) +export type TextFieldOwnProps = GetPropDefTypes; + +// @public (undocumented) +export const textFieldPropDefs: { + size: { + type: 'enum'; + values: ('small' | 'medium')[]; + className: string; + default: 'medium'; + responsive: true; + }; +}; + +// @public (undocumented) +export interface TextFieldProps + extends Omit, 'size'> { + className?: string; + description?: string; + label?: string; + name: string; + size?: TextFieldOwnProps['size']; +} + // @public (undocumented) export interface TextProps { // (undocumented) diff --git a/packages/canon/src/components/TextField/index.ts b/packages/canon/src/components/TextField/index.ts index d491fe5e42..9c04b687bd 100644 --- a/packages/canon/src/components/TextField/index.ts +++ b/packages/canon/src/components/TextField/index.ts @@ -15,3 +15,6 @@ */ export * from './TextField'; +export type { TextFieldProps } from './types'; +export { textFieldPropDefs } from './TextField.props'; +export type { TextFieldOwnProps } from './TextField.props'; diff --git a/packages/canon/src/components/TextField/types.ts b/packages/canon/src/components/TextField/types.ts index 9b1b06509b..019e5fbbb7 100644 --- a/packages/canon/src/components/TextField/types.ts +++ b/packages/canon/src/components/TextField/types.ts @@ -15,6 +15,7 @@ */ import type { TextFieldOwnProps } from './TextField.props'; +/** @public */ export interface TextFieldProps extends Omit, 'size'> { /** diff --git a/tsconfig.json b/tsconfig.json index 8d72a3315b..8942e6f48f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "plugins/*/dev", "plugins/*/migrations" ], + "exclude": ["**/*.stories.tsx", "**/*.stories.ts"], "compilerOptions": { "outDir": "dist-types", "rootDir": ".", From eb0a0d7fb33340495e350064cc2f63fec2aa78ac Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 31 Mar 2025 11:23:35 +0100 Subject: [PATCH 04/13] Fix stories Signed-off-by: Charles de Dreuille --- .../components/TextField/TextField.props.ts | 33 -------- .../TextField/TextField.stories.tsx | 76 ++++++++++--------- .../canon/src/components/TextField/index.ts | 2 - .../canon/src/components/TextField/types.ts | 5 +- 4 files changed, 43 insertions(+), 73 deletions(-) delete mode 100644 packages/canon/src/components/TextField/TextField.props.ts diff --git a/packages/canon/src/components/TextField/TextField.props.ts b/packages/canon/src/components/TextField/TextField.props.ts deleted file mode 100644 index db0d09567e..0000000000 --- a/packages/canon/src/components/TextField/TextField.props.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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 type { PropDef, GetPropDefTypes } from '../../props/prop-def'; - -/** @public */ -export const textFieldPropDefs = { - size: { - type: 'enum', - values: ['small', 'medium'], - className: 'canon-Button--size', - default: 'medium', - responsive: true, - }, -} satisfies { - size: PropDef<'small' | 'medium'>; -}; - -/** @public */ -export type TextFieldOwnProps = GetPropDefTypes; diff --git a/packages/canon/src/components/TextField/TextField.stories.tsx b/packages/canon/src/components/TextField/TextField.stories.tsx index e378fc1c58..c62a2e75f1 100644 --- a/packages/canon/src/components/TextField/TextField.stories.tsx +++ b/packages/canon/src/components/TextField/TextField.stories.tsx @@ -89,26 +89,6 @@ export const Responsive: Story = { }, }; -async function submitForm(value: string) { - // Mimic a server response - await new Promise(resolve => { - setTimeout(resolve, 200); - }); - - try { - const url = new URL(value); - - const allowedHosts = ['example.com', 'beta.example.com', 'www.example.com']; - if (!allowedHosts.includes(url.hostname)) { - return { error: 'The example domain is not allowed' }; - } - } catch { - return { error: 'This is not a valid URL' }; - } - - return { success: true }; -} - export const ShowErrorOnSubmit: Story = { args: { ...WithLabel.args, @@ -116,30 +96,54 @@ export const ShowErrorOnSubmit: Story = { type: 'url', required: true, label: 'Homepage', + name: 'url', }, decorators: [ Story => { const [errors, setErrors] = useState({}); 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({}); + setLoading(false); + + return; + } catch { + setErrors({ url: 'This is not a valid URL' }); + setLoading(false); + } + }; + return (
{ - event.preventDefault(); - const formData = new FormData(event.currentTarget); - const value = formData.get('url') as string; - - setLoading(true); - const response = await submitForm(value); - const serverErrors = { - url: response.error, - }; - - setErrors(serverErrors); - setLoading(false); - }} + onClearErrors={() => setErrors({})} + onSubmit={handleSubmit} >