Fix select component to work with RHF
Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user