Merge pull request #29482 from backstage/canon-improve-form-components

This commit is contained in:
Charles de Dreuille
2025-04-07 14:26:34 +02:00
committed by GitHub
16 changed files with 531 additions and 343 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': minor
---
Updated TextField and Select component to work with React Hook Form.
+4 -1
View File
@@ -9,7 +9,10 @@ function getAbsolutePath(value: string): any {
return dirname(require.resolve(join(value, 'package.json')));
}
const config: StorybookConfig = {
stories: ['../src/components/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
stories: [
'../src/components/**/*.stories.@(js|jsx|mjs|ts|tsx)',
'../src/stories/**/*.stories.@(js|jsx|mjs|ts|tsx)',
],
staticDirs: ['../static'],
addons: [
getAbsolutePath('@storybook/addon-webpack5-compiler-swc'),
+48 -34
View File
@@ -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);
}
+29 -22
View File
@@ -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);
}
+48 -34
View File
@@ -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);
}
+19 -12
View File
@@ -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);
}
+8 -2
View File
@@ -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<HTMLSelectElement>
SelectProps & React_2.RefAttributes<HTMLDivElement>
>;
// @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<HTMLSelectElement>) => void;
onChange?: (event: ChangeEvent<HTMLSelectElement>) => 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<HTMLInputElement>
TextFieldProps & React_2.RefAttributes<HTMLDivElement>
>;
// @public (undocumented)
@@ -1090,6 +1095,7 @@ export interface TextFieldProps
extends Omit<React.ComponentPropsWithoutRef<'input'>, 'size'> {
className?: string;
description?: string;
error?: string | null;
label?: string;
name: string;
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
@@ -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 (
<Form
errors={errors}
onClearErrors={setErrors}
onSubmit={async event => {
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);
}}
>
<Story />
<Button
type="submit"
disabled={loading}
size="small"
style={{ marginTop: '0.75rem' }}
>
Submit
</Button>
</Form>
);
},
],
};
@@ -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);
}
+44 -39
View File
@@ -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<HTMLSelectElement, SelectProps>(
export const Select = React.forwardRef<HTMLDivElement, SelectProps>(
(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 (
<Field.Root
className={clsx('canon-SelectFieldRoot', className)}
disabled={disabled}
name={name}
style={style}
>
<div className={clsx('canon-Select', className)} style={style} ref={ref}>
{label && (
<Field.Label className="canon-SelectFieldLabel">{label}</Field.Label>
)}
<SelectPrimitive.Root
value={value}
defaultValue={defaultValue}
onValueChange={onValueChange}
onOpenChange={onOpenChange}
required={required}
name={name}
>
<SelectPrimitive.Trigger
ref={ref}
className={clsx(
'canon-SelectTrigger',
`canon-SelectTrigger--size-${size}`,
<label className="canon-Select--label" htmlFor={selectId}>
{label}
{required && (
<span aria-hidden="true" className="canon-Select--required">
(Required)
</span>
)}
</label>
)}
<SelectPrimitive.Root {...rest}>
<SelectPrimitive.Trigger
id={selectId}
className={clsx('canon-Select--trigger', {
'canon-Select--trigger-size-small': responsiveSize === 'small',
'canon-Select--trigger-size-medium': responsiveSize === 'medium',
})}
data-invalid={error}
>
<SelectPrimitive.Value placeholder={placeholder} />
<SelectPrimitive.Icon className="canon-SelectIcon">
<SelectPrimitive.Icon className="canon-Select--icon">
<Icon name="chevron-down" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Backdrop />
<SelectPrimitive.Positioner>
<SelectPrimitive.Popup className="canon-SelectPopup">
<SelectPrimitive.Popup className="canon-Select--popup">
{options?.map(option => (
<SelectPrimitive.Item
key={option.value}
value={option.value}
disabled={option.disabled}
className="canon-SelectItem"
className="canon-Select--item"
>
<SelectPrimitive.ItemIndicator className="canon-SelectItemIndicator">
<SelectPrimitive.ItemIndicator className="canon-Select--item-indicator">
<Icon name="check" />
</SelectPrimitive.ItemIndicator>
<SelectPrimitive.ItemText className="canon-SelectItemText">
<SelectPrimitive.ItemText className="canon-Select--item-text">
{option.label}
</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
@@ -95,12 +96,16 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
{description && (
<Field.Description className="canon-SelectFieldDescription">
<p className="canon-Select--description" id={descriptionId}>
{description}
</Field.Description>
</p>
)}
<Field.Error className="canon-SelectFieldError" />
</Field.Root>
{error && (
<p className="canon-Select--error" id={errorId} role="alert">
{error}
</p>
)}
</div>
);
},
);
@@ -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<HTMLSelectElement>) => void;
/**
* onBlur handler for form integration
*/
onBlur?: (event: FocusEvent<HTMLSelectElement>) => void;
}
@@ -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<Record<string, string> | undefined>(
undefined,
);
const [loading, setLoading] = useState(false);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
setLoading(true);
await new Promise(resolve => {
setTimeout(resolve, 200);
});
try {
const url = new URL(formData.get('url') as string);
const allowedHosts = [
'backstage.io',
'beta.backstage.io',
'www.backstage.io',
];
if (!allowedHosts.includes(url.hostname)) {
setErrors({ url: 'The example domain is not allowed' });
setLoading(false);
return;
}
setErrors(undefined);
setLoading(false);
return;
} catch {
setErrors({ url: 'This is not a valid URL' });
setLoading(false);
}
};
return (
<Form
errors={errors}
onClearErrors={() => setErrors(undefined)}
onSubmit={handleSubmit}
>
<Story />
<Button
type="submit"
disabled={loading}
size="small"
style={{ marginTop: '0.75rem' }}
>
Submit
</Button>
</Form>
);
},
],
};
@@ -14,21 +14,21 @@
* limitations under the License.
*/
.canon-FieldRoot {
.canon-TextField {
display: flex;
flex-direction: column;
font-family: var(--canon-font-regular);
width: 100%;
}
.canon-FieldLabel {
.canon-TextField--label {
font-size: var(--canon-font-size-2);
font-weight: var(--canon-font-weight-regular);
color: var(--canon-fg-primary);
margin-bottom: var(--canon-space-1_5);
}
.canon-FieldDescription {
.canon-TextField--description {
font-size: var(--canon-font-size-2);
font-weight: var(--canon-font-weight-regular);
color: var(--canon-fg-secondary);
@@ -36,7 +36,7 @@
padding-top: var(--canon-space-1_5);
}
.canon-FieldError {
.canon-TextField--error {
font-size: var(--canon-font-size-2);
font-weight: var(--canon-font-weight-regular);
color: var(--canon-fg-danger);
@@ -44,7 +44,7 @@
padding-top: var(--canon-space-1_5);
}
.canon-Input {
.canon-TextField--input {
border-radius: var(--canon-radius-3);
border: 1px solid var(--canon-border);
padding: 0 var(--canon-space-4);
@@ -57,34 +57,41 @@
width: 100%;
}
.canon-Input::placeholder {
.canon-TextField--input::placeholder {
color: var(--canon-fg-secondary);
}
.canon-Input:hover {
.canon-TextField--input:hover {
border-color: var(--canon-border-hover);
}
.canon-Input:focus-visible {
.canon-TextField--input:focus-visible {
outline-color: var(--canon-border-pressed);
outline-width: 0px;
border-color: var(--canon-border-pressed);
}
.canon-Input[data-invalid] {
.canon-TextField--input[data-invalid] {
border-color: var(--canon-fg-danger);
}
.canon-Input[data-disabled] {
.canon-TextField--input[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
border: 1px solid var(--canon-border-disabled);
}
.canon-Input--size-small {
.canon-TextField--input-size-small {
height: 2rem;
}
.canon-Input--size-medium {
.canon-TextField--input-size-medium {
height: 2.5rem;
}
.canon-TextField--required {
color: var(--canon-fg-secondary);
font-size: var(--canon-font-size-2);
font-weight: var(--canon-font-weight-regular);
margin-left: var(--canon-space-1);
}
@@ -15,7 +15,6 @@
*/
import React, { forwardRef } from 'react';
import { Field } from '@base-ui-components/react/field';
import { Input } from '@base-ui-components/react/input';
import { useResponsiveValue } from '../../hooks/useResponsiveValue';
import clsx from 'clsx';
@@ -23,14 +22,15 @@ import clsx from 'clsx';
import type { TextFieldProps } from './types';
/** @public */
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
export const TextField = forwardRef<HTMLDivElement, TextFieldProps>(
(props: TextFieldProps, ref) => {
const {
className,
size = 'medium',
label,
description,
name,
error,
required,
style,
...rest
} = props;
@@ -38,28 +38,53 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
// 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 (
<Field.Root
className={clsx('canon-FieldRoot', className)}
name={name}
<div
className={clsx('canon-TextField', className)}
style={style}
ref={ref}
>
{label && (
<Field.Label className="canon-FieldLabel">{label}</Field.Label>
<label className="canon-TextField--label" htmlFor={inputId}>
{label}
{required && (
<span aria-hidden="true" className="canon-TextField--required">
(Required)
</span>
)}
</label>
)}
<Input
ref={ref}
type={name}
className={clsx('canon-Input', `canon-Input--size-${responsiveSize}`)}
id={inputId}
className={clsx('canon-TextField--input', {
'canon-TextField--input-size-small': responsiveSize === 'small',
'canon-TextField--input-size-medium': responsiveSize === 'medium',
})}
aria-labelledby={label ? inputId : undefined}
aria-describedby={clsx({
[descriptionId]: description,
[errorId]: error,
})}
data-invalid={error}
required={required}
{...rest}
/>
{description && (
<Field.Description className="canon-FieldDescription">
<p className="canon-TextField--description" id={descriptionId}>
{description}
</Field.Description>
</p>
)}
<Field.Error className="canon-FieldError" />
</Field.Root>
{error && (
<p className="canon-TextField--error" id={errorId} role="alert">
{error}
</p>
)}
</div>
);
},
);
@@ -44,4 +44,9 @@ export interface TextFieldProps
* The name of the text field
*/
name: string;
/**
* The error message of the text field
*/
error?: string | null;
}
+195
View File
@@ -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<typeof meta>;
type Inputs = {
firstname: string;
lastname: string;
city: string;
};
export const Uncontrolled: Story = {
render: () => {
const {
register,
handleSubmit,
control,
formState: { errors },
} = useForm<Inputs>();
const onSubmit: SubmitHandler<Inputs> = data => console.log(data);
return (
<form
onSubmit={handleSubmit(onSubmit)}
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
alignItems: 'flex-start',
}}
>
<TextField
label="First Name"
{...register('firstname', {
required: 'First name is required',
maxLength: { value: 80, message: 'Max length is 80 characters' },
})}
error={errors.firstname?.message}
/>
<TextField
label="Last Name"
{...register('lastname', {
required: 'Last name is required',
maxLength: { value: 100, message: 'Max length is 100 characters' },
})}
error={errors.lastname?.message}
/>
<Controller
name="city"
control={control}
rules={{ required: 'New city is required' }}
render={({ field }) => {
return (
<Select
label="New City"
options={[
{ value: 'london', label: 'London' },
{ value: 'paris', label: 'Paris' },
{ value: 'new-york', label: 'New York' },
]}
name={field.name}
onValueChange={field.onChange}
error={errors.city?.message}
/>
);
}}
/>
<Button type="submit">Submit</Button>
</form>
);
},
};
export const Controlled: Story = {
render: () => {
const {
handleSubmit,
control,
formState: { errors },
} = useForm<Inputs>();
const [firstname, setFirstname] = React.useState('John');
const [lastname, setLastname] = React.useState('Doe');
const [city, setCity] = React.useState('london');
const onSubmit: SubmitHandler<Inputs> = data => {
console.log('data', data);
setFirstname(data.firstname);
setLastname(data.lastname);
setCity(data.city);
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
alignItems: 'flex-start',
}}
>
<Controller
name="firstname"
control={control}
defaultValue={firstname}
rules={{ required: 'First name is required' }}
render={({ field }) => {
return (
<TextField
label="First Name"
name={field.name}
value={firstname}
onChange={e => {
field.onChange(e);
setFirstname(e.target.value);
}}
error={errors.firstname?.message}
/>
);
}}
/>
<Controller
name="lastname"
control={control}
defaultValue={lastname}
rules={{ required: 'Last name is required' }}
render={({ field }) => {
return (
<TextField
label="Last Name"
name={field.name}
value={lastname}
onChange={e => {
field.onChange(e);
setLastname(e.target.value);
}}
error={errors.lastname?.message}
/>
);
}}
/>
<Controller
name="city"
control={control}
defaultValue={city}
rules={{ required: 'New city is required' }}
render={({ field }) => {
return (
<Select
label="New City"
options={[
{ value: 'london', label: 'London' },
{ value: 'paris', label: 'Paris' },
{ value: 'new-york', label: 'New York' },
]}
name={field.name}
value={city}
onValueChange={field.onChange}
error={errors.city?.message}
/>
);
}}
/>
<Button type="submit">Submit</Button>
</form>
);
},
};