Add Select component

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-04-01 17:53:06 +01:00
parent b9477931c3
commit 4b60f29047
7 changed files with 709 additions and 0 deletions
@@ -0,0 +1,307 @@
/*
* Copyright 2025 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, { useState } 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';
const meta = {
title: 'Components/Select',
component: Select,
decorators: [story => <div style={{ maxWidth: 400 }}>{story()}</div>],
} satisfies Meta<typeof Select>;
export default meta;
type Story = StoryObj<typeof meta>;
const fontOptions = [
{ value: 'sans', label: 'Sans-serif' },
{ value: 'serif', label: 'Serif' },
{ value: 'mono', label: 'Monospace' },
{ value: 'cursive', label: 'Cursive' },
];
export const Default: Story = {
args: {
label: 'Font Family',
options: fontOptions,
placeholder: 'Select a font',
name: 'font',
},
};
export const WithDescription: Story = {
args: {
...Default.args,
description: 'Choose a font family for your document',
},
};
export const Required: Story = {
args: {
...Default.args,
required: true,
},
};
export const Disabled: Story = {
args: {
...Default.args,
disabled: true,
},
};
export const DisabledOption: Story = {
args: {
...Default.args,
options: [
...fontOptions,
{ value: 'comic-sans', label: 'Comic sans', disabled: true },
],
},
};
export const NoLabel: Story = {
args: {
...Default.args,
label: undefined,
},
};
export const NoOptions: Story = {
args: {
...Default.args,
options: undefined,
},
};
export const Small: Story = {
args: {
...Default.args,
size: 'small',
},
};
export const WithValue: Story = {
args: {
...Default.args,
value: 'mono',
defaultValue: 'serif',
},
};
export const WithDefaultValue: Story = {
args: {
...Default.args,
defaultValue: 'serif',
options: fontOptions,
name: 'font',
},
};
const generateOptions = (count = 100) => {
const firstWords = [
'Moon',
'Sun',
'Star',
'Cosmic',
'Globe',
'Flux',
'Nova',
'Echo',
'Pulse',
'Vertex',
'Nexus',
'Orbit',
'Prism',
'Quantum',
'Zenith',
'Aura',
'Crystal',
'Shadow',
'Phantom',
'Azure',
'Ember',
'Frost',
'Horizon',
'Mystic',
'Raven',
'Solstice',
'Tempest',
'Vortex',
'Whisper',
'Zephyr',
];
const secondWords = [
'green',
'blue',
'red',
'black',
'white',
'silver',
'gold',
'copper',
'bronze',
'steel',
'flow',
'light',
'dark',
'dream',
'stream',
'life',
'sight',
'mind',
'craft',
'blend',
'wave',
'swift',
'sharp',
'soft',
'bold',
'clear',
'deep',
'lift',
'shift',
'grace',
];
const thirdWords = [
'Sans',
'Serif',
'Mono',
'Script',
'Display',
'Slab',
'Round',
'Thin',
'Bold',
'Italic',
'Pro',
'Neo',
'Prime',
'Plus',
'One',
'Two',
'Nova',
'Ultra',
'Elite',
'Max',
'Type',
'Text',
'View',
'Graph',
'Print',
'Read',
'Write',
'Book',
'Note',
'Letter',
];
const randomElement = <T extends any>(array: T[]): T =>
array[Math.floor(Math.random() * array.length)];
const uniqueRandomNames = Array.from({ length: count })
.map(() => {
const firstName = randomElement(firstWords);
const secondName = randomElement(secondWords);
const thirdName = randomElement(thirdWords);
return `${firstName}${secondName} ${thirdName}`;
})
.reduce((accSet, label) => {
accSet.add(label);
return accSet;
}, new Set<string>())
.values();
return Array.from(uniqueRandomNames).map(label => ({
value: label.toLocaleLowerCase('en-US').replaceAll(' ', '-'),
label,
}));
};
export const WithManyOptions: Story = {
args: {
label: 'Font Family',
options: generateOptions(),
name: 'font',
},
};
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 = {
args: {
...Default.args,
label: 'Font Family (select Comic sans to see error)',
options: [...fontOptions, { value: 'comic-sans', label: 'Comic sans' }],
required: true,
},
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>
);
},
],
};
@@ -0,0 +1,184 @@
/*
* 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.
*/
.canon-SelectFieldRoot {
display: flex;
flex-direction: column;
font-family: var(--canon-font-regular);
width: 100%;
}
.canon-SelectFieldLabel {
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 {
font-size: var(--canon-font-size-2);
font-weight: var(--canon-font-weight-regular);
color: var(--canon-fg-secondary);
margin: 0;
padding-top: var(--canon-space-1_5);
}
.canon-SelectFieldError {
font-size: var(--canon-font-size-2);
font-weight: var(--canon-font-weight-regular);
color: var(--canon-fg-danger);
margin: 0;
padding-top: var(--canon-space-1_5);
}
.canon-SelectTrigger {
box-sizing: border-box;
border-radius: var(--canon-radius-3);
border: 1px solid var(--canon-border);
padding: 0 var(--canon-space-4);
background-color: var(--canon-bg-surface-1);
font-size: var(--canon-font-size-3);
font-family: var(--canon-font-regular);
font-weight: var(--canon-font-weight-regular);
color: var(--canon-fg-primary);
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
transition: border-color 0.2s ease-in-out, outline-color 0.2s ease-in-out;
cursor: pointer;
}
.canon-SelectTrigger::placeholder {
color: var(--canon-fg-secondary);
}
.canon-SelectTrigger:hover {
border-color: var(--canon-border-hover);
}
.canon-SelectTrigger:focus-visible {
border-color: var(--canon-border-pressed);
outline: 0;
}
.canon-SelectTrigger[data-invalid] {
border-color: var(--canon-fg-danger);
}
.canon-SelectTrigger[data-invalid]:hover {
border-width: 2px;
}
.canon-SelectTrigger[data-invalid]:focus-visible {
border-width: 2px;
}
.canon-SelectTrigger[data-disabled] {
cursor: not-allowed;
border-color: var(--canon-border-disabled);
color: var(--canon-fg-disabled);
}
.canon-SelectTrigger--size-small,
.canon-SelectItem--size-small {
height: 2rem;
}
.canon-SelectTrigger--size-medium,
.canon-SelectItem--size-medium {
height: 3rem;
}
.canon-SelectIcon {
margin-left: var(--canon-space-5);
transition: transform 0.2s ease;
}
.canon-SelectTrigger[data-popup-open] .canon-SelectIcon {
transform: rotate(180deg);
}
.canon-SelectPopup {
box-sizing: border-box;
max-height: var(--available-height);
overflow-y: auto;
background-color: var(--canon-bg-surface-1);
border: 1px solid var(--canon-border);
border-radius: var(--canon-radius-3);
padding-block: var(--canon-space-1);
z-index: 1;
outline: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
transform-origin: var(--transform-origin);
transition: transform 150ms, opacity 150ms;
}
.canon-SelectPopup[data-starting-style],
.canon-SelectPopup[data-ending-style] {
opacity: 0;
transform: scale(0.9);
}
.canon-SelectItem {
position: relative;
width: var(--anchor-width);
display: grid;
grid-template-areas: 'icon text';
grid-template-columns: 1rem 1fr;
align-items: center;
padding-block: var(--canon-space-2);
padding-inline: var(--canon-space-4);
color: var(--canon-fg-primary);
border-radius: var(--canon-radius-3);
cursor: pointer;
user-select: none;
font-size: var(--canon-font-size-3);
gap: var(--canon-space-2);
outline: none;
}
.canon-SelectItem[data-highlighted] {
z-index: 0;
position: relative;
color: var(--canon-fg-primary);
}
.canon-SelectItem[data-highlighted]::before {
content: '';
z-index: -1;
position: absolute;
inset-block: 0;
inset-inline: 0.25rem;
border-radius: 0.25rem;
background-color: var(--canon-bg-tint-hover);
}
.canon-SelectItem[data-disabled] {
cursor: not-allowed;
color: var(--canon-fg-disabled);
}
.canon-SelectItemIndicator {
grid-area: icon;
display: flex;
align-items: center;
justify-content: center;
}
.canon-SelectItemText {
flex: 1;
grid-area: text;
}
@@ -0,0 +1,111 @@
/*
* 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 { Select as SelectPrimitive } from '@base-ui-components/react/select';
import { Field, Icon } from '@backstage/canon';
import clsx from 'clsx';
import './Select.styles.css';
import { SelectProps } from './types';
/**
* Select component
*
* @public
*/
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
(props, ref) => {
const {
className,
name,
label,
description,
value,
defaultValue,
placeholder,
onValueChange,
onOpenChange,
options,
size = 'medium',
disabled = false,
required = false,
} = props;
return (
<Field.Root
className={clsx('canon-SelectFieldRoot', className)}
disabled={disabled}
name={name}
>
{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}`,
)}
>
<SelectPrimitive.Value
placeholder={placeholder || 'Select an option'}
/>
<SelectPrimitive.Icon className="canon-SelectIcon">
<Icon name="chevron-down" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Backdrop />
<SelectPrimitive.Positioner>
<SelectPrimitive.Popup className="canon-SelectPopup">
{options?.map(option => (
<SelectPrimitive.Item
key={option.value}
value={option.value}
disabled={option.disabled}
className="canon-SelectItem"
>
<SelectPrimitive.ItemIndicator className="canon-SelectItemIndicator">
<Icon name="check" />
</SelectPrimitive.ItemIndicator>
<SelectPrimitive.ItemText className="canon-SelectItemText">
{option.label}
</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
{description && (
<Field.Description className="canon-SelectFieldDescription">
{description}
</Field.Description>
)}
<Field.Error className="canon-SelectFieldError" />
</Field.Root>
);
},
);
Select.displayName = 'Select';
@@ -0,0 +1,17 @@
/*
* 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.
*/
export * from './Select';
@@ -0,0 +1,88 @@
/*
* Copyright 2025 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 { Breakpoint } from '@backstage/canon';
/** @public */
export interface SelectProps {
/**
* The class name of the select field
*/
className?: string;
/**
* The size of the select field
* @defaultValue 'medium'
*/
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
*/
placeholder?: string;
/**
* Callback that is called when the value of the select field changes
*/
onValueChange?: (value: string) => void;
/**
* Callbak that is called when the select field is opened or closed
*/
onOpenChange?: (open: boolean) => void;
}
+1
View File
@@ -31,3 +31,4 @@
@import '../components/Link/styles.css';
@import '../components/Tooltip/Tooltip.styles.css';
@import '../components/ScrollArea/ScrollArea.styles.css';
@import '../components/Select/Select.styles.css';
+1
View File
@@ -42,6 +42,7 @@ export * from './components/Field';
export * from './components/Tooltip';
export * from './components/Menu';
export * from './components/ScrollArea';
export * from './components/Select';
// Types
export * from './types';