diff --git a/.changeset/shiny-bears-know.md b/.changeset/shiny-bears-know.md new file mode 100644 index 0000000000..07a7fd6250 --- /dev/null +++ b/.changeset/shiny-bears-know.md @@ -0,0 +1,5 @@ +--- +'@backstage/canon': minor +--- + +Updated TextField and Select component to work with React Hook Form. 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/css/components.css b/packages/canon/css/components.css index bcd347382c..3a8787ddca 100644 --- a/packages/canon/css/components.css +++ b/packages/canon/css/components.css @@ -417,21 +417,21 @@ height: 1.5rem; } -.canon-FieldRoot { +.canon-TextField { font-family: var(--canon-font-regular); flex-direction: column; width: 100%; display: flex; } -.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); @@ -439,7 +439,7 @@ margin: 0; } -.canon-FieldError { +.canon-TextField--error { font-size: var(--canon-font-size-2); font-weight: var(--canon-font-weight-regular); color: var(--canon-fg-danger); @@ -447,7 +447,7 @@ margin: 0; } -.canon-Input { +.canon-TextField--input { border-radius: var(--canon-radius-3); border: 1px solid var(--canon-border); padding: 0 var(--canon-space-4); @@ -460,38 +460,45 @@ transition: border-color .2s ease-in-out, outline-color .2s ease-in-out; } -.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); border-color: var(--canon-border-pressed); outline-width: 0; } -.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: .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-secondary); + font-size: var(--canon-font-size-2); + font-weight: var(--canon-font-weight-regular); + margin-left: var(--canon-space-1); +} + .canon-MenuPositioner { outline: 0; } @@ -703,21 +710,21 @@ width: 100%; } -.canon-SelectFieldRoot { +.canon-Select { font-family: var(--canon-font-regular); flex-direction: column; width: 100%; display: flex; } -.canon-SelectFieldLabel { +.canon-Select--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-SelectFieldDescription { +.canon-Select--description { font-size: var(--canon-font-size-2); font-weight: var(--canon-font-weight-regular); color: var(--canon-fg-secondary); @@ -725,7 +732,7 @@ margin: 0; } -.canon-SelectFieldError { +.canon-Select--error { font-size: var(--canon-font-size-2); font-weight: var(--canon-font-weight-regular); color: var(--canon-fg-danger); @@ -733,7 +740,7 @@ margin: 0; } -.canon-SelectTrigger { +.canon-Select--trigger { box-sizing: border-box; border-radius: var(--canon-radius-3); border: 1px solid var(--canon-border); @@ -751,38 +758,38 @@ display: flex; } -.canon-SelectTrigger::placeholder { +.canon-Select--trigger::placeholder { color: var(--canon-fg-secondary); } -.canon-SelectTrigger:hover { +.canon-Select--trigger:hover { border-color: var(--canon-border-hover); } -.canon-SelectTrigger:focus-visible { +.canon-Select--trigger:focus-visible { border-color: var(--canon-border-pressed); outline: 0; } -.canon-SelectTrigger[data-invalid] { +.canon-Select--trigger[data-invalid] { border-color: var(--canon-fg-danger); } -.canon-SelectTrigger[data-invalid]:hover, .canon-SelectTrigger[data-invalid]:focus-visible { +.canon-Select--trigger[data-invalid]:hover, .canon-Select--trigger[data-invalid]:focus-visible { border-width: 2px; } -.canon-SelectTrigger[data-disabled] { +.canon-Select--trigger[data-disabled] { cursor: not-allowed; border-color: var(--canon-border-disabled); color: var(--canon-fg-disabled); } -.canon-SelectTrigger--size-small, .canon-SelectItem--size-small { +.canon-Select--trigger-size-small { height: 2rem; } -.canon-SelectTrigger--size-medium, .canon-SelectItem--size-medium { +.canon-Select--trigger-size-medium { height: 3rem; } @@ -791,11 +798,11 @@ transition: transform .2s; } -.canon-SelectTrigger[data-popup-open] .canon-SelectIcon { +.canon-Select--trigger[data-popup-open] .canon-SelectIcon { transform: rotate(180deg); } -.canon-SelectPopup { +.canon-Select--popup { box-sizing: border-box; max-height: var(--available-height); background-color: var(--canon-bg-surface-1); @@ -810,12 +817,12 @@ box-shadow: 0 4px 12px #0003; } -.canon-SelectPopup[data-starting-style], .canon-SelectPopup[data-ending-style] { +.canon-Select--popup[data-starting-style], .canon-Select--popup[data-ending-style] { opacity: 0; transform: scale(.9); } -.canon-SelectItem { +.canon-Select--item { width: var(--anchor-width); padding-block: var(--canon-space-2); padding-inline: var(--canon-space-4); @@ -833,13 +840,13 @@ position: relative; } -.canon-SelectItem[data-highlighted] { +.canon-Select--item[data-highlighted] { z-index: 0; color: var(--canon-fg-primary); position: relative; } -.canon-SelectItem[data-highlighted]:before { +.canon-Select--item[data-highlighted]:before { content: ""; z-index: -1; background-color: var(--canon-bg-tint-hover); @@ -849,19 +856,26 @@ inset-inline: .25rem; } -.canon-SelectItem[data-disabled] { +.canon-Select--item[data-disabled] { cursor: not-allowed; color: var(--canon-fg-disabled); } -.canon-SelectItemIndicator { +.canon-Select--item-indicator { grid-area: icon; justify-content: center; align-items: center; display: flex; } -.canon-SelectItemText { +.canon-Select--item-text { flex: 1; grid-area: text; } + +.canon-Select--required { + 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/css/select.css b/packages/canon/css/select.css index 77a88eaa00..6c64c39245 100644 --- a/packages/canon/css/select.css +++ b/packages/canon/css/select.css @@ -1,18 +1,18 @@ -.canon-SelectFieldRoot { +.canon-Select { font-family: var(--canon-font-regular); flex-direction: column; width: 100%; display: flex; } -.canon-SelectFieldLabel { +.canon-Select--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-SelectFieldDescription { +.canon-Select--description { font-size: var(--canon-font-size-2); font-weight: var(--canon-font-weight-regular); color: var(--canon-fg-secondary); @@ -20,7 +20,7 @@ margin: 0; } -.canon-SelectFieldError { +.canon-Select--error { font-size: var(--canon-font-size-2); font-weight: var(--canon-font-weight-regular); color: var(--canon-fg-danger); @@ -28,7 +28,7 @@ margin: 0; } -.canon-SelectTrigger { +.canon-Select--trigger { box-sizing: border-box; border-radius: var(--canon-radius-3); border: 1px solid var(--canon-border); @@ -46,38 +46,38 @@ display: flex; } -.canon-SelectTrigger::placeholder { +.canon-Select--trigger::placeholder { color: var(--canon-fg-secondary); } -.canon-SelectTrigger:hover { +.canon-Select--trigger:hover { border-color: var(--canon-border-hover); } -.canon-SelectTrigger:focus-visible { +.canon-Select--trigger:focus-visible { border-color: var(--canon-border-pressed); outline: 0; } -.canon-SelectTrigger[data-invalid] { +.canon-Select--trigger[data-invalid] { border-color: var(--canon-fg-danger); } -.canon-SelectTrigger[data-invalid]:hover, .canon-SelectTrigger[data-invalid]:focus-visible { +.canon-Select--trigger[data-invalid]:hover, .canon-Select--trigger[data-invalid]:focus-visible { border-width: 2px; } -.canon-SelectTrigger[data-disabled] { +.canon-Select--trigger[data-disabled] { cursor: not-allowed; border-color: var(--canon-border-disabled); color: var(--canon-fg-disabled); } -.canon-SelectTrigger--size-small, .canon-SelectItem--size-small { +.canon-Select--trigger-size-small { height: 2rem; } -.canon-SelectTrigger--size-medium, .canon-SelectItem--size-medium { +.canon-Select--trigger-size-medium { height: 3rem; } @@ -86,11 +86,11 @@ transition: transform .2s; } -.canon-SelectTrigger[data-popup-open] .canon-SelectIcon { +.canon-Select--trigger[data-popup-open] .canon-SelectIcon { transform: rotate(180deg); } -.canon-SelectPopup { +.canon-Select--popup { box-sizing: border-box; max-height: var(--available-height); background-color: var(--canon-bg-surface-1); @@ -105,12 +105,12 @@ box-shadow: 0 4px 12px #0003; } -.canon-SelectPopup[data-starting-style], .canon-SelectPopup[data-ending-style] { +.canon-Select--popup[data-starting-style], .canon-Select--popup[data-ending-style] { opacity: 0; transform: scale(.9); } -.canon-SelectItem { +.canon-Select--item { width: var(--anchor-width); padding-block: var(--canon-space-2); padding-inline: var(--canon-space-4); @@ -128,13 +128,13 @@ position: relative; } -.canon-SelectItem[data-highlighted] { +.canon-Select--item[data-highlighted] { z-index: 0; color: var(--canon-fg-primary); position: relative; } -.canon-SelectItem[data-highlighted]:before { +.canon-Select--item[data-highlighted]:before { content: ""; z-index: -1; background-color: var(--canon-bg-tint-hover); @@ -144,19 +144,26 @@ inset-inline: .25rem; } -.canon-SelectItem[data-disabled] { +.canon-Select--item[data-disabled] { cursor: not-allowed; color: var(--canon-fg-disabled); } -.canon-SelectItemIndicator { +.canon-Select--item-indicator { grid-area: icon; justify-content: center; align-items: center; display: flex; } -.canon-SelectItemText { +.canon-Select--item-text { flex: 1; grid-area: text; } + +.canon-Select--required { + 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/css/styles.css b/packages/canon/css/styles.css index 37c5cb4d2d..681d3027a9 100644 --- a/packages/canon/css/styles.css +++ b/packages/canon/css/styles.css @@ -9623,21 +9623,21 @@ height: 1.5rem; } -.canon-FieldRoot { +.canon-TextField { font-family: var(--canon-font-regular); flex-direction: column; width: 100%; display: flex; } -.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); @@ -9645,7 +9645,7 @@ margin: 0; } -.canon-FieldError { +.canon-TextField--error { font-size: var(--canon-font-size-2); font-weight: var(--canon-font-weight-regular); color: var(--canon-fg-danger); @@ -9653,7 +9653,7 @@ margin: 0; } -.canon-Input { +.canon-TextField--input { border-radius: var(--canon-radius-3); border: 1px solid var(--canon-border); padding: 0 var(--canon-space-4); @@ -9666,38 +9666,45 @@ transition: border-color .2s ease-in-out, outline-color .2s ease-in-out; } -.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); border-color: var(--canon-border-pressed); outline-width: 0; } -.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: .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-secondary); + font-size: var(--canon-font-size-2); + font-weight: var(--canon-font-weight-regular); + margin-left: var(--canon-space-1); +} + .canon-MenuPositioner { outline: 0; } @@ -9909,21 +9916,21 @@ width: 100%; } -.canon-SelectFieldRoot { +.canon-Select { font-family: var(--canon-font-regular); flex-direction: column; width: 100%; display: flex; } -.canon-SelectFieldLabel { +.canon-Select--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-SelectFieldDescription { +.canon-Select--description { font-size: var(--canon-font-size-2); font-weight: var(--canon-font-weight-regular); color: var(--canon-fg-secondary); @@ -9931,7 +9938,7 @@ margin: 0; } -.canon-SelectFieldError { +.canon-Select--error { font-size: var(--canon-font-size-2); font-weight: var(--canon-font-weight-regular); color: var(--canon-fg-danger); @@ -9939,7 +9946,7 @@ margin: 0; } -.canon-SelectTrigger { +.canon-Select--trigger { box-sizing: border-box; border-radius: var(--canon-radius-3); border: 1px solid var(--canon-border); @@ -9957,38 +9964,38 @@ display: flex; } -.canon-SelectTrigger::placeholder { +.canon-Select--trigger::placeholder { color: var(--canon-fg-secondary); } -.canon-SelectTrigger:hover { +.canon-Select--trigger:hover { border-color: var(--canon-border-hover); } -.canon-SelectTrigger:focus-visible { +.canon-Select--trigger:focus-visible { border-color: var(--canon-border-pressed); outline: 0; } -.canon-SelectTrigger[data-invalid] { +.canon-Select--trigger[data-invalid] { border-color: var(--canon-fg-danger); } -.canon-SelectTrigger[data-invalid]:hover, .canon-SelectTrigger[data-invalid]:focus-visible { +.canon-Select--trigger[data-invalid]:hover, .canon-Select--trigger[data-invalid]:focus-visible { border-width: 2px; } -.canon-SelectTrigger[data-disabled] { +.canon-Select--trigger[data-disabled] { cursor: not-allowed; border-color: var(--canon-border-disabled); color: var(--canon-fg-disabled); } -.canon-SelectTrigger--size-small, .canon-SelectItem--size-small { +.canon-Select--trigger-size-small { height: 2rem; } -.canon-SelectTrigger--size-medium, .canon-SelectItem--size-medium { +.canon-Select--trigger-size-medium { height: 3rem; } @@ -9997,11 +10004,11 @@ transition: transform .2s; } -.canon-SelectTrigger[data-popup-open] .canon-SelectIcon { +.canon-Select--trigger[data-popup-open] .canon-SelectIcon { transform: rotate(180deg); } -.canon-SelectPopup { +.canon-Select--popup { box-sizing: border-box; max-height: var(--available-height); background-color: var(--canon-bg-surface-1); @@ -10016,12 +10023,12 @@ box-shadow: 0 4px 12px #0003; } -.canon-SelectPopup[data-starting-style], .canon-SelectPopup[data-ending-style] { +.canon-Select--popup[data-starting-style], .canon-Select--popup[data-ending-style] { opacity: 0; transform: scale(.9); } -.canon-SelectItem { +.canon-Select--item { width: var(--anchor-width); padding-block: var(--canon-space-2); padding-inline: var(--canon-space-4); @@ -10039,13 +10046,13 @@ position: relative; } -.canon-SelectItem[data-highlighted] { +.canon-Select--item[data-highlighted] { z-index: 0; color: var(--canon-fg-primary); position: relative; } -.canon-SelectItem[data-highlighted]:before { +.canon-Select--item[data-highlighted]:before { content: ""; z-index: -1; background-color: var(--canon-bg-tint-hover); @@ -10055,19 +10062,26 @@ inset-inline: .25rem; } -.canon-SelectItem[data-disabled] { +.canon-Select--item[data-disabled] { cursor: not-allowed; color: var(--canon-fg-disabled); } -.canon-SelectItemIndicator { +.canon-Select--item-indicator { grid-area: icon; justify-content: center; align-items: center; display: flex; } -.canon-SelectItemText { +.canon-Select--item-text { flex: 1; grid-area: text; } + +.canon-Select--required { + 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/css/textfield.css b/packages/canon/css/textfield.css index e40251fd5a..a542690580 100644 --- a/packages/canon/css/textfield.css +++ b/packages/canon/css/textfield.css @@ -1,18 +1,18 @@ -.canon-FieldRoot { +.canon-TextField { font-family: var(--canon-font-regular); flex-direction: column; width: 100%; display: flex; } -.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); @@ -20,7 +20,7 @@ margin: 0; } -.canon-FieldError { +.canon-TextField--error { font-size: var(--canon-font-size-2); font-weight: var(--canon-font-weight-regular); color: var(--canon-fg-danger); @@ -28,7 +28,7 @@ margin: 0; } -.canon-Input { +.canon-TextField--input { border-radius: var(--canon-radius-3); border: 1px solid var(--canon-border); padding: 0 var(--canon-space-4); @@ -41,34 +41,41 @@ transition: border-color .2s ease-in-out, outline-color .2s ease-in-out; } -.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); border-color: var(--canon-border-pressed); outline-width: 0; } -.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: .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-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/report.api.md b/packages/canon/report.api.md index 7335ee93f3..a2b86e57a4 100644 --- a/packages/canon/report.api.md +++ b/packages/canon/report.api.md @@ -4,8 +4,10 @@ ```ts import { Breakpoint as Breakpoint_2 } from '@backstage/canon'; +import { ChangeEvent } from 'react'; import { Context } from 'react'; import type { CSSProperties } from 'react'; +import { FocusEvent as FocusEvent_2 } from 'react'; import { ForwardRefExoticComponent } from 'react'; import { Menu as Menu_2 } from '@base-ui-components/react/menu'; import { default as React_2 } from 'react'; @@ -942,7 +944,7 @@ export const ScrollArea: { // @public (undocumented) export const Select: React_2.ForwardRefExoticComponent< - SelectProps & React_2.RefAttributes + SelectProps & React_2.RefAttributes >; // @public (undocumented) @@ -951,8 +953,11 @@ export interface SelectProps { defaultValue?: string; description?: string; disabled?: boolean; + error?: string; label?: string; name: string; + onBlur?: (event: FocusEvent_2) => void; + onChange?: (event: ChangeEvent) => void; onOpenChange?: (open: boolean) => void; onValueChange?: (value: string) => void; options?: Array<{ @@ -1082,7 +1087,7 @@ export { Text_2 as Text }; // @public (undocumented) export const TextField: React_2.ForwardRefExoticComponent< - TextFieldProps & React_2.RefAttributes + TextFieldProps & React_2.RefAttributes >; // @public (undocumented) @@ -1090,6 +1095,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/Select/Select.stories.tsx b/packages/canon/src/components/Select/Select.stories.tsx index d9de7ddb4a..96bfb31f9f 100644 --- a/packages/canon/src/components/Select/Select.stories.tsx +++ b/packages/canon/src/components/Select/Select.stories.tsx @@ -13,11 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useState } from 'react'; +import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { Select } from './Select'; -import { Form } from '@base-ui-components/react/form'; -import { Button } from '../Button'; import { Flex } from '../Flex'; const meta = { @@ -95,13 +93,6 @@ export const DisabledOption: Story = { }, }; -export const NoLabel: Story = { - args: { - ...Preview.args, - label: undefined, - }, -}; - export const NoOptions: Story = { args: { ...Preview.args, @@ -109,13 +100,6 @@ export const NoOptions: Story = { }, }; -export const Small: Story = { - args: { - ...Preview.args, - size: 'small', - }, -}; - export const WithValue: Story = { args: { ...Preview.args, @@ -263,65 +247,9 @@ export const WithManyOptions: Story = { }, }; -async function validateFont(value: string) { - // Mimic a server response - await new Promise(resolve => { - setTimeout(resolve, 500); - }); - - const restrictedFonts = ['comic-sans']; - if (restrictedFonts.includes(value)) { - return { error: 'This font should not be allowed.' }; - } - - return { success: true }; -} - -export const ShowErrorOnSubmit: Story = { +export const withErrorAndDescription: Story = { args: { ...Preview.args, - label: 'Font Family (select Comic sans to see error)', - options: [...fontOptions, { value: 'comic-sans', label: 'Comic sans' }], - required: true, + error: 'Invalid font family', }, - decorators: [ - Story => { - const [errors, setErrors] = useState({}); - const [loading, setLoading] = useState(false); - - return ( -
{ - event.preventDefault(); - const formData = new FormData(event.currentTarget); - const fontValue = formData.get('font') as string; - - setLoading(true); - const response = await validateFont(fontValue); - if (response.error) { - setErrors({ - font: response.error, - }); - } else { - setErrors({}); - } - - setLoading(false); - }} - > - - - - ); - }, - ], }; diff --git a/packages/canon/src/components/Select/Select.styles.css b/packages/canon/src/components/Select/Select.styles.css index b1a7e53b9a..19fa327bf5 100644 --- a/packages/canon/src/components/Select/Select.styles.css +++ b/packages/canon/src/components/Select/Select.styles.css @@ -14,21 +14,21 @@ * limitations under the License. */ -.canon-SelectFieldRoot { +.canon-Select { display: flex; flex-direction: column; font-family: var(--canon-font-regular); width: 100%; } -.canon-SelectFieldLabel { +.canon-Select--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-SelectFieldDescription { +.canon-Select--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-SelectFieldError { +.canon-Select--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-SelectTrigger { +.canon-Select--trigger { box-sizing: border-box; border-radius: var(--canon-radius-3); border: 1px solid var(--canon-border); @@ -62,43 +62,41 @@ cursor: pointer; } -.canon-SelectTrigger::placeholder { +.canon-Select--trigger::placeholder { color: var(--canon-fg-secondary); } -.canon-SelectTrigger:hover { +.canon-Select--trigger:hover { border-color: var(--canon-border-hover); } -.canon-SelectTrigger:focus-visible { +.canon-Select--trigger:focus-visible { border-color: var(--canon-border-pressed); outline: 0; } -.canon-SelectTrigger[data-invalid] { +.canon-Select--trigger[data-invalid] { border-color: var(--canon-fg-danger); } -.canon-SelectTrigger[data-invalid]:hover { +.canon-Select--trigger[data-invalid]:hover { border-width: 2px; } -.canon-SelectTrigger[data-invalid]:focus-visible { +.canon-Select--trigger[data-invalid]:focus-visible { border-width: 2px; } -.canon-SelectTrigger[data-disabled] { +.canon-Select--trigger[data-disabled] { cursor: not-allowed; border-color: var(--canon-border-disabled); color: var(--canon-fg-disabled); } -.canon-SelectTrigger--size-small, -.canon-SelectItem--size-small { +.canon-Select--trigger-size-small { height: 2rem; } -.canon-SelectTrigger--size-medium, -.canon-SelectItem--size-medium { +.canon-Select--trigger-size-medium { height: 3rem; } @@ -107,11 +105,11 @@ transition: transform 0.2s ease; } -.canon-SelectTrigger[data-popup-open] .canon-SelectIcon { +.canon-Select--trigger[data-popup-open] .canon-SelectIcon { transform: rotate(180deg); } -.canon-SelectPopup { +.canon-Select--popup { box-sizing: border-box; max-height: var(--available-height); overflow-y: auto; @@ -126,13 +124,13 @@ transition: transform 150ms, opacity 150ms; } -.canon-SelectPopup[data-starting-style], -.canon-SelectPopup[data-ending-style] { +.canon-Select--popup[data-starting-style], +.canon-Select--popup[data-ending-style] { opacity: 0; transform: scale(0.9); } -.canon-SelectItem { +.canon-Select--item { position: relative; width: var(--anchor-width); display: grid; @@ -150,13 +148,13 @@ outline: none; } -.canon-SelectItem[data-highlighted] { +.canon-Select--item[data-highlighted] { z-index: 0; position: relative; color: var(--canon-fg-primary); } -.canon-SelectItem[data-highlighted]::before { +.canon-Select--item[data-highlighted]::before { content: ''; z-index: -1; position: absolute; @@ -166,19 +164,26 @@ background-color: var(--canon-bg-tint-hover); } -.canon-SelectItem[data-disabled] { +.canon-Select--item[data-disabled] { cursor: not-allowed; color: var(--canon-fg-disabled); } -.canon-SelectItemIndicator { +.canon-Select--item-indicator { grid-area: icon; display: flex; align-items: center; justify-content: center; } -.canon-SelectItemText { +.canon-Select--item-text { flex: 1; grid-area: text; } + +.canon-Select--required { + 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/Select/Select.tsx b/packages/canon/src/components/Select/Select.tsx index bcacefd02e..835bb8303a 100644 --- a/packages/canon/src/components/Select/Select.tsx +++ b/packages/canon/src/components/Select/Select.tsx @@ -16,76 +16,77 @@ import React from 'react'; import { Select as SelectPrimitive } from '@base-ui-components/react/select'; -import { Field } from '@base-ui-components/react/field'; import { Icon } from '../Icon'; import clsx from 'clsx'; import './Select.styles.css'; import { SelectProps } from './types'; +import { useResponsiveValue } from '../../hooks/useResponsiveValue'; /** @public */ -export const Select = React.forwardRef( +export const Select = React.forwardRef( (props, ref) => { const { className, - name, label, description, - value, - defaultValue, - onValueChange, - onOpenChange, options, placeholder = 'Select an option', size = 'medium', - disabled = false, - required = false, + required, + error, style, + ...rest } = props; + + // Get the responsive value for the variant + const responsiveSize = useResponsiveValue(size); + + // Generate unique IDs for accessibility + const selectId = React.useId(); + const descriptionId = React.useId(); + const errorId = React.useId(); + return ( - +
{label && ( - {label} - )} - - + {label} + {required && ( + )} + + )} + + - + - + {options?.map(option => ( - + - + {option.label} @@ -95,12 +96,16 @@ export const Select = React.forwardRef( {description && ( - +

{description} - +

)} - - + {error && ( + + )} +
); }, ); diff --git a/packages/canon/src/components/Select/types.ts b/packages/canon/src/components/Select/types.ts index 960129da70..adf8568b98 100644 --- a/packages/canon/src/components/Select/types.ts +++ b/packages/canon/src/components/Select/types.ts @@ -15,6 +15,7 @@ */ import { Breakpoint } from '@backstage/canon'; +import { ChangeEvent, FocusEvent } from 'react'; /** @public */ export interface SelectProps { @@ -91,4 +92,19 @@ export interface SelectProps { * The style of the select field */ style?: React.CSSProperties; + + /** + * The error message of the select field + */ + error?: string; + + /** + * onChange handler for form integration + */ + onChange?: (event: ChangeEvent) => void; + + /** + * onBlur handler for form integration + */ + onBlur?: (event: FocusEvent) => void; } diff --git a/packages/canon/src/components/TextField/TextField.stories.tsx b/packages/canon/src/components/TextField/TextField.stories.tsx index ca33c28020..70affa6cf6 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 = { @@ -60,6 +58,13 @@ export const WithDescription: Story = { }, }; +export const Required: Story = { + args: { + ...WithLabel.args, + required: true, + }, +}; + export const Disabled: Story = { args: { ...WithLabel.args, @@ -91,76 +96,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..899acc0034 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-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 d4c95ae8b5..d144781983 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,14 +22,15 @@ import clsx from 'clsx'; import type { TextFieldProps } from './types'; /** @public */ -export const TextField = forwardRef( +export const TextField = forwardRef( (props: TextFieldProps, ref) => { const { className, size = 'medium', label, description, - name, + error, + required, style, ...rest } = props; @@ -38,28 +38,53 @@ export const TextField = forwardRef( // Get the responsive value for the variant const responsiveSize = useResponsiveValue(size); + // Generate unique IDs for accessibility + const inputId = React.useId(); + const descriptionId = React.useId(); + const errorId = React.useId(); + 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..147d398c99 100644 --- a/packages/canon/src/components/TextField/types.ts +++ b/packages/canon/src/components/TextField/types.ts @@ -44,4 +44,9 @@ export interface TextFieldProps * The name of the text field */ name: string; + + /** + * The error message of the text field + */ + 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..457d0470f8 --- /dev/null +++ b/packages/canon/src/stories/Form.stories.tsx @@ -0,0 +1,195 @@ +/* + * 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 { 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: 'Examples/Form', +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +type Inputs = { + firstname: string; + lastname: string; + city: string; +}; + +export const Uncontrolled: Story = { + render: () => { + const { + register, + handleSubmit, + control, + formState: { errors }, + } = useForm(); + + const onSubmit: SubmitHandler = data => console.log(data); + + return ( +
+ + + { + return ( + + ); + }} + /> + + + ); + }, +};