Fix select component to work with RHF

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-04-04 16:40:17 +01:00
parent 73abef2968
commit c2a8800a68
6 changed files with 123 additions and 139 deletions
@@ -95,13 +95,6 @@ export const DisabledOption: Story = {
},
};
export const NoLabel: Story = {
args: {
...Preview.args,
label: undefined,
},
};
export const NoOptions: Story = {
args: {
...Preview.args,
@@ -109,13 +102,6 @@ export const NoOptions: Story = {
},
};
export const Small: Story = {
args: {
...Preview.args,
size: 'small',
},
};
export const WithValue: Story = {
args: {
...Preview.args,
@@ -263,65 +249,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;
}
@@ -31,6 +31,7 @@ export const TextField = forwardRef<HTMLDivElement, TextFieldProps>(
description,
error,
required,
style,
...rest
} = props;
@@ -43,7 +44,11 @@ export const TextField = forwardRef<HTMLDivElement, TextFieldProps>(
const errorId = React.useId();
return (
<div className={clsx('canon-TextField', className)} ref={ref}>
<div
className={clsx('canon-TextField', className)}
style={style}
ref={ref}
>
{label && (
<label className="canon-TextField--label" htmlFor={inputId}>
{label}
+24 -1
View File
@@ -16,9 +16,10 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { useForm, SubmitHandler } from 'react-hook-form';
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',
@@ -30,6 +31,7 @@ type Story = StoryObj<typeof meta>;
type Inputs = {
firstname: string;
lastname: string;
city: string;
};
export const Default: Story = {
@@ -37,6 +39,7 @@ export const Default: Story = {
const {
register,
handleSubmit,
control,
formState: { errors },
} = useForm<Inputs>();
@@ -68,6 +71,26 @@ export const Default: Story = {
})}
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>
);