Restructure Select with React Aria

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-07-12 10:42:07 +02:00
parent 38e63d0cb4
commit 70da598ff9
8 changed files with 146 additions and 212 deletions
@@ -49,9 +49,16 @@ export const Preview: Story = {
},
};
export const WithLabel: Story = {
args: {
...Default.args,
label: 'Font Family',
},
};
export const WithDescription: Story = {
args: {
...Preview.args,
...WithLabel.args,
description: 'Choose a font family for your document',
},
};
@@ -71,14 +78,14 @@ export const Sizes: Story = {
export const Required: Story = {
args: {
...Preview.args,
required: true,
isRequired: true,
},
};
export const Disabled: Story = {
args: {
...Preview.args,
disabled: true,
isDisabled: true,
},
};
@@ -102,15 +109,15 @@ export const NoOptions: Story = {
export const WithValue: Story = {
args: {
...Preview.args,
value: 'mono',
defaultValue: 'serif',
selectedKey: 'mono',
defaultSelectedKey: 'serif',
},
};
export const WithDefaultValue: Story = {
args: {
...Preview.args,
defaultValue: 'serif',
defaultSelectedKey: 'serif',
options: fontOptions,
name: 'font',
},
@@ -246,12 +253,12 @@ export const WithManyOptions: Story = {
},
};
export const WithErrorAndDescription: Story = {
args: {
...Preview.args,
error: 'Invalid font family',
},
};
// export const WithErrorAndDescription: Story = {
// args: {
// ...Preview.args,
// error: 'Invalid font family',
// },
// };
export const WithLongOptionNames: Story = {
args: {
@@ -286,6 +293,6 @@ export const WithLongOptionNames: Story = {
placeholder: 'Select a document template',
name: 'template',
style: { maxWidth: 400 },
value: 'annual-report-2024',
selectedKey: 'annual-report-2024',
},
};
@@ -14,39 +14,8 @@
* limitations under the License.
*/
.bui-Select {
display: flex;
flex-direction: column;
font-family: var(--bui-font-regular);
width: 100%;
}
.bui-SelectLabel {
font-size: var(--bui-font-size-2);
font-weight: var(--bui-font-weight-regular);
color: var(--bui-fg-primary);
margin-bottom: var(--bui-space-1_5);
cursor: pointer;
}
.bui-SelectLabel[data-disabled] {
cursor: default;
}
.bui-SelectDescription {
font-size: var(--bui-font-size-2);
font-weight: var(--bui-font-weight-regular);
color: var(--bui-fg-secondary);
margin: 0;
padding-top: var(--bui-space-1_5);
}
.bui-SelectError {
font-size: var(--bui-font-size-2);
font-weight: var(--bui-font-weight-regular);
color: var(--bui-fg-danger);
margin: 0;
padding-top: var(--bui-space-1_5);
}
/* .bui-Select {
} */
.bui-SelectTrigger {
box-sizing: border-box;
+50 -88
View File
@@ -14,13 +14,20 @@
* limitations under the License.
*/
import { forwardRef, useCallback, useId, useRef, MouseEvent } from 'react';
import { Select as SelectPrimitive } from '@base-ui-components/react/select';
import { Icon } from '../Icon';
import { forwardRef, useEffect } from 'react';
import {
Select as AriaSelect,
SelectValue,
Button,
Popover,
ListBox,
ListBoxItem,
} from 'react-aria-components';
import clsx from 'clsx';
import './Select.styles.css';
import { SelectProps } from './types';
import { useStyles } from '../../hooks/useStyles';
import { FieldLabel } from '../FieldLabel';
/** @public */
export const Select = forwardRef<HTMLDivElement, SelectProps>((props, ref) => {
@@ -31,102 +38,57 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>((props, ref) => {
options,
placeholder = 'Select an option',
size = 'medium',
required,
error,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
isRequired,
secondaryLabel,
style,
...rest
} = props;
const { classNames: popoverClassNames } = useStyles('Popover');
const { classNames: listClassNames } = useStyles('List');
const { classNames, dataAttributes } = useStyles('Select', {
size,
});
// Generate unique IDs for accessibility
const selectId = useId();
const descriptionId = useId();
const errorId = useId();
useEffect(() => {
if (!label && !ariaLabel && !ariaLabelledBy) {
console.warn(
'TextField requires either a visible label, aria-label, or aria-labelledby for accessibility',
);
}
}, [label, ariaLabel, ariaLabelledBy]);
const triggerRef = useRef<HTMLButtonElement>(null);
const handleLabelClick = useCallback(
(e: MouseEvent<HTMLLabelElement>) => {
if (!props.disabled && triggerRef.current) {
e.preventDefault();
triggerRef.current.focus();
}
},
[props.disabled],
);
// If a secondary label is provided, use it. Otherwise, use 'Required' if the field is required.
const secondaryLabelText = secondaryLabel || (isRequired ? 'Required' : null);
return (
<div className={clsx(classNames.root, className)} style={style} ref={ref}>
{label && (
<label
className="bui-SelectLabel"
htmlFor={selectId}
onClick={handleLabelClick}
data-disabled={props.disabled ? true : undefined}
>
{label}
{required && (
<span aria-hidden="true" className={classNames.required}>
(Required)
</span>
)}
</label>
)}
<SelectPrimitive.Root {...rest}>
<SelectPrimitive.Trigger
ref={triggerRef}
id={selectId}
className={classNames.trigger}
data-size={dataAttributes['data-size']}
data-invalid={error}
>
<SelectPrimitive.Value
className={classNames.value}
placeholder={placeholder}
/>
<SelectPrimitive.Icon className={classNames.icon}>
<Icon name="chevron-down" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Backdrop />
<SelectPrimitive.Positioner>
<SelectPrimitive.Popup className={classNames.popup}>
{options?.map(option => (
<SelectPrimitive.Item
key={option.value}
value={option.value}
disabled={option.disabled}
className={classNames.item}
>
<SelectPrimitive.ItemIndicator
className={classNames.itemIndicator}
>
<Icon name="check" />
</SelectPrimitive.ItemIndicator>
<SelectPrimitive.ItemText className={classNames.itemText}>
{option.label}
</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
{description && (
<p className={classNames.description} id={descriptionId}>
{description}
</p>
)}
{error && (
<p className={classNames.error} id={errorId} role="alert">
{error}
</p>
)}
</div>
<AriaSelect
className={clsx(classNames.root, className)}
{...dataAttributes}
ref={ref}
{...rest}
>
<FieldLabel
label={label}
secondaryLabel={secondaryLabelText}
description={description}
/>
<Button data-size={dataAttributes['data-size']}>
<SelectValue />
<span aria-hidden="true"></span>
</Button>
<Popover className={popoverClassNames.root}>
<ListBox className={listClassNames.root}>
{options?.map(option => (
<ListBoxItem key={option.value} className={listClassNames.row}>
{option.label}
</ListBoxItem>
))}
</ListBox>
</Popover>
</AriaSelect>
);
});
+13 -77
View File
@@ -15,14 +15,23 @@
*/
import { Breakpoint } from '../../';
import { ChangeEvent, FocusEvent } from 'react';
import { ReactNode } from 'react';
import type { SelectProps as AriaSelectProps } from 'react-aria-components';
import type { FieldLabelProps } from '../FieldLabel/types';
interface SelectOption {
name: string;
value: string;
}
/** @public */
export interface SelectProps {
export interface SelectProps
extends AriaSelectProps<SelectOption>,
Omit<FieldLabelProps, 'htmlFor' | 'id'> {
/**
* The class name of the select field
* An icon to render before the input
*/
className?: string;
icon?: ReactNode;
/**
* The size of the select field
@@ -30,81 +39,8 @@ export interface SelectProps {
*/
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
/**
* The label of the select field
*/
label?: string;
/**
* The description of the select field
*/
description?: string;
/**
* The name of the select field
*/
name: string;
/**
* Whether the select field should ignore user input
* @defaultValue false
*/
disabled?: boolean;
/**
* Whether the select field is required
* @defaultValue false
*/
required?: boolean;
/**
* The options of the select field
*/
options?: Array<{ value: string; label: string; disabled?: boolean }>;
/**
* The current value of the select field
*/
value?: string;
/**
* The default value of the select field, if nothing has been selected yet
*/
defaultValue?: string;
/**
* A placeholder text to show if nothing has been selected and there's no default value
* @defaultValue 'Select an option'
*/
placeholder?: string;
/**
* Callback that is called when the value of the select field changes
*/
onValueChange?: (value: string) => void;
/**
* Callback that is called when the select field is opened or closed
*/
onOpenChange?: (open: boolean) => void;
/**
* 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;
}
+5
View File
@@ -14,6 +14,11 @@
* limitations under the License.
*/
/* Generic component styles */
@import './generic/popover.css';
@import './generic/list.css';
/* Component styles */
@import '../components/Avatar/Avatar.styles.css';
@import '../components/Box/styles.css';
@import '../components/Button/styles.css';
+23
View File
@@ -0,0 +1,23 @@
/*
* 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.
*/
.bui-List {
background-color: green;
}
.bui-ListRow {
padding: var(--bui-space-2);
}
@@ -0,0 +1,22 @@
/*
* 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.
*/
.bui-Popover {
background-color: var(--bui-bg-surface-1);
padding: var(--bui-space-2);
border-radius: var(--bui-radius-2);
border: 1px solid var(--bui-border);
}
@@ -128,6 +128,12 @@ export const componentDefinitions = {
weight: ['regular', 'bold'] as const,
},
},
List: {
classNames: {
root: 'bui-List',
row: 'bui-ListRow',
},
},
Menu: {
classNames: {
trigger: 'bui-MenuTrigger',
@@ -147,6 +153,11 @@ export const componentDefinitions = {
separator: 'bui-MenuSeparator',
},
},
Popover: {
classNames: {
root: 'bui-Popover',
},
},
RadioGroup: {
classNames: {
root: 'bui-RadioGroup',
@@ -171,7 +182,6 @@ export const componentDefinitions = {
Select: {
classNames: {
root: 'bui-Select',
required: 'bui-SelectRequired',
trigger: 'bui-SelectTrigger',
value: 'bui-SelectValue',
icon: 'bui-SelectIcon',