Restructure Select with React Aria
Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user