Merge pull request #29440 from backstage/canon-select

Canon - Add Select component
This commit is contained in:
Charles de Dreuille
2025-04-02 13:25:18 +01:00
committed by GitHub
17 changed files with 1456 additions and 4 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': minor
---
Add new Select component for Canon
@@ -0,0 +1,128 @@
import { PropsTable } from '@/components/PropsTable';
import { Snippet } from '@/components/Snippet';
import { Tabs } from '@/components/Tabs';
import { CodeBlock } from '@/components/CodeBlock';
import { SelectSnippet } from '@/snippets/stories-snippets';
import { selectPropDefs } from './props';
# Select
A common form component for choosing a predefined value in a dropdown menu.
<Snippet
align="center"
py={4}
preview={<SelectSnippet story="Preview" />}
code={`<Select name="font" label="Font Family" options={[
{ value: 'sans', label: 'Sans-serif' },
{ value: 'serif', label: 'Serif' },
{ value: 'mono', label: 'Monospace' },
{ value: 'cursive', label: 'Cursive' },
]} />
`}
/>
<Tabs.Root>
<Tabs.List>
<Tabs.Tab>Usage</Tabs.Tab>
<Tabs.Tab>Theming</Tabs.Tab>
</Tabs.List>
<Tabs.Panel>
<CodeBlock
code={`import { Select } from '@backstage/canon';
<Select
name="font"
options={[
{ value: 'sans', label: 'Sans-serif' },
{ value: 'serif', label: 'Serif' },
{ value: 'mono', label: 'Monospace' },
{ value: 'cursive', label: 'Cursive' },
]}
/>
`}
/>
</Tabs.Panel>
<Tabs.Panel>
We recommend starting with our [global tokens](/theme/theming) to customize the library and align it with
your brand. For additional flexibility, you can use the provided class names for each element listed below.
<CodeBlock
code={`<Select className="canon-SelectFieldRoot" />`}
/>
</Tabs.Panel>
</Tabs.Root>
## API reference
<PropsTable data={selectPropDefs} />
## Examples
### With Label and description
Select component with label and description.
<Snippet
align="center"
py={4}
open
preview={<SelectSnippet story="WithDescription" />}
code={`<Select
name="font"
label="Font Family"
description="Choose a font family for your document"
options={[ ... ]}
/>`}
/>
### Sizes
Here's a view when the selects have different sizes.
<Snippet
align="center"
py={4}
open
preview={<SelectSnippet story="Sizes" />}
code={`<Flex>
<Select
size="small"
label="Font family"
options={[ ... ]}
/>
<Select
size="medium"
label="Font family"
options={[ ... ]}
/>
</Flex>`}
/>
### Disabled
Here's a view when the select is disabled.
<Snippet
align="center"
py={4}
open
preview={<SelectSnippet story="Disabled" />}
code={`<Select
disabled
label="Font family"
options={[ ... ]}
/>`}
/>
### Responsive
Here's a view when the select is responsive.
<CodeBlock
code={`<Select
size={{ initial: 'small', lg: 'medium' }}
label="Font family"
options={[ ... ]}
/>`}
/>
@@ -0,0 +1,54 @@
import { classNamePropDefs, stylePropDefs } from '../../../../utils/propDefs';
import type { PropDef } from '../../../../utils/propDefs';
export const selectPropDefs: Record<string, PropDef> = {
label: {
type: 'string',
default: 'Select an option',
responsive: false,
},
description: {
type: 'string',
responsive: false,
},
name: {
type: 'string',
responsive: false,
required: true,
},
options: {
type: 'enum',
values: ['Array<{ value: string, label: string }>'],
required: true,
},
value: {
type: 'string',
responsive: false,
},
defaultValue: {
type: 'string',
responsive: false,
},
placeholder: {
type: 'string',
responsive: false,
},
size: {
type: 'enum',
values: ['small', 'medium'],
default: 'medium',
responsive: true,
},
onValueChange: {
type: 'enum',
values: ['(value: string) => void'],
responsive: false,
},
onOpenChange: {
type: 'enum',
values: ['(open: boolean) => void'],
responsive: false,
},
...classNamePropDefs,
...stylePropDefs,
};
@@ -25,8 +25,8 @@ export const PropsTable = <T extends Record<string, PropData>>({
<Table.Header>
<Table.HeaderRow>
<Table.HeaderCell style={{ width: '16%' }}>Prop</Table.HeaderCell>
<Table.HeaderCell style={{ width: '56%' }}>Type</Table.HeaderCell>
<Table.HeaderCell style={{ width: '14%' }}>Default</Table.HeaderCell>
<Table.HeaderCell style={{ width: '50%' }}>Type</Table.HeaderCell>
<Table.HeaderCell style={{ width: '20%' }}>Default</Table.HeaderCell>
<Table.HeaderCell style={{ width: '14%' }}>
Responsive
</Table.HeaderCell>
@@ -45,7 +45,7 @@ export const PropsTable = <T extends Record<string, PropData>>({
<Table.Cell style={{ width: '16%' }}>
<Chip head>{n}</Chip>
</Table.Cell>
<Table.Cell style={{ width: '56%' }}>
<Table.Cell style={{ width: '50%' }}>
<div
style={{ display: 'flex', flexWrap: 'wrap', gap: '0.375rem' }}
>
@@ -61,7 +61,7 @@ export const PropsTable = <T extends Record<string, PropData>>({
)}
</div>
</Table.Cell>
<Table.Cell style={{ width: '14%' }}>
<Table.Cell style={{ width: '20%' }}>
<Chip>{data[n].default ? data[n].default : '-'}</Chip>
</Table.Cell>
<Table.Cell style={{ width: '14%' }}>
@@ -13,6 +13,7 @@ import * as IconStories from '../../../packages/canon/src/components/Icon/Icon.s
import * as InputStories from '../../../packages/canon/src/components/Input/Input.stories';
import * as TextStories from '../../../packages/canon/src/components/Text/Text.stories';
import * as FlexStories from '../../../packages/canon/src/components/Flex/Flex.stories';
import * as SelectStories from '../../../packages/canon/src/components/Select/Select.stories';
export const BoxSnippet = ({ story }: { story: keyof typeof BoxStories }) => {
const stories = composeStories(BoxStories);
@@ -125,3 +126,14 @@ export const TextSnippet = ({ story }: { story: keyof typeof TextStories }) => {
return StoryComponent ? <StoryComponent /> : null;
};
export const SelectSnippet = ({
story,
}: {
story: keyof typeof SelectStories;
}) => {
const stories = composeStories(SelectStories);
const StoryComponent = stories[story as keyof typeof stories];
return StoryComponent ? <StoryComponent /> : null;
};
+5
View File
@@ -97,6 +97,11 @@ export const components: Page[] = [
slug: 'input',
status: 'alpha',
},
{
title: 'Select',
slug: 'select',
status: 'alpha',
},
{
title: 'Table',
slug: 'table',
+163
View File
@@ -691,3 +691,166 @@
background-color: var(--canon-scrollbar-thumb);
width: 100%;
}
.canon-SelectFieldRoot {
font-family: var(--canon-font-regular);
flex-direction: column;
width: 100%;
display: flex;
}
.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);
padding-top: var(--canon-space-1_5);
margin: 0;
}
.canon-SelectFieldError {
font-size: var(--canon-font-size-2);
font-weight: var(--canon-font-weight-regular);
color: var(--canon-fg-danger);
padding-top: var(--canon-space-1_5);
margin: 0;
}
.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);
cursor: pointer;
justify-content: space-between;
align-items: center;
width: 100%;
transition: border-color .2s ease-in-out, outline-color .2s ease-in-out;
display: flex;
}
.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, .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 .2s;
}
.canon-SelectTrigger[data-popup-open] .canon-SelectIcon {
transform: rotate(180deg);
}
.canon-SelectPopup {
box-sizing: border-box;
max-height: var(--available-height);
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;
transform-origin: var(--transform-origin);
outline: 0;
transition: transform .15s, opacity .15s;
overflow-y: auto;
box-shadow: 0 4px 12px #0003;
}
.canon-SelectPopup[data-starting-style], .canon-SelectPopup[data-ending-style] {
opacity: 0;
transform: scale(.9);
}
.canon-SelectItem {
width: var(--anchor-width);
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);
align-items: center;
gap: var(--canon-space-2);
outline: none;
grid-template-columns: 1rem 1fr;
grid-template-areas: "icon text";
display: grid;
position: relative;
}
.canon-SelectItem[data-highlighted] {
z-index: 0;
color: var(--canon-fg-primary);
position: relative;
}
.canon-SelectItem[data-highlighted]:before {
content: "";
z-index: -1;
background-color: var(--canon-bg-tint-hover);
border-radius: .25rem;
position: absolute;
inset-block: 0;
inset-inline: .25rem;
}
.canon-SelectItem[data-disabled] {
cursor: not-allowed;
color: var(--canon-fg-disabled);
}
.canon-SelectItemIndicator {
grid-area: icon;
justify-content: center;
align-items: center;
display: flex;
}
.canon-SelectItemText {
flex: 1;
grid-area: text;
}
+162
View File
@@ -0,0 +1,162 @@
.canon-SelectFieldRoot {
font-family: var(--canon-font-regular);
flex-direction: column;
width: 100%;
display: flex;
}
.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);
padding-top: var(--canon-space-1_5);
margin: 0;
}
.canon-SelectFieldError {
font-size: var(--canon-font-size-2);
font-weight: var(--canon-font-weight-regular);
color: var(--canon-fg-danger);
padding-top: var(--canon-space-1_5);
margin: 0;
}
.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);
cursor: pointer;
justify-content: space-between;
align-items: center;
width: 100%;
transition: border-color .2s ease-in-out, outline-color .2s ease-in-out;
display: flex;
}
.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, .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 .2s;
}
.canon-SelectTrigger[data-popup-open] .canon-SelectIcon {
transform: rotate(180deg);
}
.canon-SelectPopup {
box-sizing: border-box;
max-height: var(--available-height);
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;
transform-origin: var(--transform-origin);
outline: 0;
transition: transform .15s, opacity .15s;
overflow-y: auto;
box-shadow: 0 4px 12px #0003;
}
.canon-SelectPopup[data-starting-style], .canon-SelectPopup[data-ending-style] {
opacity: 0;
transform: scale(.9);
}
.canon-SelectItem {
width: var(--anchor-width);
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);
align-items: center;
gap: var(--canon-space-2);
outline: none;
grid-template-columns: 1rem 1fr;
grid-template-areas: "icon text";
display: grid;
position: relative;
}
.canon-SelectItem[data-highlighted] {
z-index: 0;
color: var(--canon-fg-primary);
position: relative;
}
.canon-SelectItem[data-highlighted]:before {
content: "";
z-index: -1;
background-color: var(--canon-bg-tint-hover);
border-radius: .25rem;
position: absolute;
inset-block: 0;
inset-inline: .25rem;
}
.canon-SelectItem[data-disabled] {
cursor: not-allowed;
color: var(--canon-fg-disabled);
}
.canon-SelectItemIndicator {
grid-area: icon;
justify-content: center;
align-items: center;
display: flex;
}
.canon-SelectItemText {
flex: 1;
grid-area: text;
}
+163
View File
@@ -9897,3 +9897,166 @@
background-color: var(--canon-scrollbar-thumb);
width: 100%;
}
.canon-SelectFieldRoot {
font-family: var(--canon-font-regular);
flex-direction: column;
width: 100%;
display: flex;
}
.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);
padding-top: var(--canon-space-1_5);
margin: 0;
}
.canon-SelectFieldError {
font-size: var(--canon-font-size-2);
font-weight: var(--canon-font-weight-regular);
color: var(--canon-fg-danger);
padding-top: var(--canon-space-1_5);
margin: 0;
}
.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);
cursor: pointer;
justify-content: space-between;
align-items: center;
width: 100%;
transition: border-color .2s ease-in-out, outline-color .2s ease-in-out;
display: flex;
}
.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, .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 .2s;
}
.canon-SelectTrigger[data-popup-open] .canon-SelectIcon {
transform: rotate(180deg);
}
.canon-SelectPopup {
box-sizing: border-box;
max-height: var(--available-height);
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;
transform-origin: var(--transform-origin);
outline: 0;
transition: transform .15s, opacity .15s;
overflow-y: auto;
box-shadow: 0 4px 12px #0003;
}
.canon-SelectPopup[data-starting-style], .canon-SelectPopup[data-ending-style] {
opacity: 0;
transform: scale(.9);
}
.canon-SelectItem {
width: var(--anchor-width);
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);
align-items: center;
gap: var(--canon-space-2);
outline: none;
grid-template-columns: 1rem 1fr;
grid-template-areas: "icon text";
display: grid;
position: relative;
}
.canon-SelectItem[data-highlighted] {
z-index: 0;
color: var(--canon-fg-primary);
position: relative;
}
.canon-SelectItem[data-highlighted]:before {
content: "";
z-index: -1;
background-color: var(--canon-bg-tint-hover);
border-radius: .25rem;
position: absolute;
inset-block: 0;
inset-inline: .25rem;
}
.canon-SelectItem[data-disabled] {
cursor: not-allowed;
color: var(--canon-fg-disabled);
}
.canon-SelectItemIndicator {
grid-area: icon;
justify-content: center;
align-items: center;
display: flex;
}
.canon-SelectItemText {
flex: 1;
grid-area: text;
}
+28
View File
@@ -3,6 +3,7 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Breakpoint as Breakpoint_2 } from '@backstage/canon';
import { Context } from 'react';
import type { CSSProperties } from 'react';
import { Field as Field_2 } from '@base-ui-components/react/field';
@@ -955,6 +956,33 @@ export const ScrollArea: {
>;
};
// @public (undocumented)
export const Select: React_2.ForwardRefExoticComponent<
SelectProps & React_2.RefAttributes<HTMLSelectElement>
>;
// @public (undocumented)
export interface SelectProps {
className?: string;
defaultValue?: string;
description?: string;
disabled?: boolean;
label?: string;
name: string;
onOpenChange?: (open: boolean) => void;
onValueChange?: (value: string) => void;
options?: Array<{
value: string;
label: string;
disabled?: boolean;
}>;
placeholder?: string;
required?: boolean;
size?: 'small' | 'medium' | Partial<Record<Breakpoint_2, 'small' | 'medium'>>;
style?: React.CSSProperties;
value?: string;
}
// @public (undocumented)
export type Space =
| '0.5'
@@ -0,0 +1,327 @@
/*
* 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';
import { Flex } from '../Flex';
const meta = {
title: 'Components/Select',
component: Select,
} 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: {
options: fontOptions,
name: 'font',
},
};
export const Preview: Story = {
args: {
label: 'Font Family',
options: fontOptions,
placeholder: 'Select a font',
name: 'font',
style: { maxWidth: 260 },
},
};
export const WithDescription: Story = {
args: {
...Preview.args,
description: 'Choose a font family for your document',
},
};
export const Sizes: Story = {
args: {
...Preview.args,
},
render: args => (
<Flex direction="row" gap="2" style={{ width: '100%', maxWidth: 540 }}>
<Select {...args} size="small" />
<Select {...args} size="medium" />
</Flex>
),
};
export const Required: Story = {
args: {
...Preview.args,
required: true,
},
};
export const Disabled: Story = {
args: {
...Preview.args,
disabled: true,
},
};
export const DisabledOption: Story = {
args: {
...Preview.args,
options: [
...fontOptions,
{ value: 'comic-sans', label: 'Comic sans', disabled: true },
],
},
};
export const NoLabel: Story = {
args: {
...Preview.args,
label: undefined,
},
};
export const NoOptions: Story = {
args: {
...Preview.args,
options: undefined,
},
};
export const Small: Story = {
args: {
...Preview.args,
size: 'small',
},
};
export const WithValue: Story = {
args: {
...Preview.args,
value: 'mono',
defaultValue: 'serif',
},
};
export const WithDefaultValue: Story = {
args: {
...Preview.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: {
...Preview.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,107 @@
/*
* 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';
/** @public */
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
(props, ref) => {
const {
className,
name,
label,
description,
value,
defaultValue,
onValueChange,
onOpenChange,
options,
placeholder = 'Select an option',
size = 'medium',
disabled = false,
required = false,
style,
} = props;
return (
<Field.Root
className={clsx('canon-SelectFieldRoot', className)}
disabled={disabled}
name={name}
style={style}
>
{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} />
<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,18 @@
/*
* 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';
export * from './types';
@@ -0,0 +1,94 @@
/*
* 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
* @defaultValue 'Select an option'
*/
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;
/**
* The style of the select field
*/
style?: React.CSSProperties;
}
+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';