Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-04-01 18:40:44 +01:00
parent 4b60f29047
commit 1b0cf4063d
14 changed files with 764 additions and 20 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': minor
---
Add new Select component for Canon
@@ -0,0 +1,129 @@
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 { buttonVariants } from '@/snippets/code-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 buttons 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 buttons are disabled.
<Snippet
align="center"
py={4}
open
preview={<SelectSnippet story="Disabled" />}
code={`<Select
disabled
label="Font family"
options={[ ... ]}
/>`}
/>
### Responsive
Here's a view when buttons are 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;
}
+27
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,32 @@ 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'>>;
value?: string;
}
// @public (undocumented)
export type Space =
| '0.5'
@@ -18,11 +18,11 @@ 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,
decorators: [story => <div style={{ maxWidth: 400 }}>{story()}</div>],
} satisfies Meta<typeof Select>;
export default meta;
@@ -36,38 +36,58 @@ const fontOptions = [
];
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: {
...Default.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: {
...Default.args,
...Preview.args,
required: true,
},
};
export const Disabled: Story = {
args: {
...Default.args,
...Preview.args,
disabled: true,
},
};
export const DisabledOption: Story = {
args: {
...Default.args,
...Preview.args,
options: [
...fontOptions,
{ value: 'comic-sans', label: 'Comic sans', disabled: true },
@@ -77,28 +97,28 @@ export const DisabledOption: Story = {
export const NoLabel: Story = {
args: {
...Default.args,
...Preview.args,
label: undefined,
},
};
export const NoOptions: Story = {
args: {
...Default.args,
...Preview.args,
options: undefined,
},
};
export const Small: Story = {
args: {
...Default.args,
...Preview.args,
size: 'small',
},
};
export const WithValue: Story = {
args: {
...Default.args,
...Preview.args,
value: 'mono',
defaultValue: 'serif',
},
@@ -106,7 +126,7 @@ export const WithValue: Story = {
export const WithDefaultValue: Story = {
args: {
...Default.args,
...Preview.args,
defaultValue: 'serif',
options: fontOptions,
name: 'font',
@@ -259,7 +279,7 @@ async function validateFont(value: string) {
export const ShowErrorOnSubmit: Story = {
args: {
...Default.args,
...Preview.args,
label: 'Font Family (select Comic sans to see error)',
options: [...fontOptions, { value: 'comic-sans', label: 'Comic sans' }],
required: true,
@@ -21,11 +21,7 @@ import clsx from 'clsx';
import './Select.styles.css';
import { SelectProps } from './types';
/**
* Select component
*
* @public
*/
/** @public */
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
(props, ref) => {
const {
@@ -42,12 +38,14 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
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>
@@ -15,3 +15,4 @@
*/
export * from './Select';
export * from './types';
@@ -85,4 +85,9 @@ export interface SelectProps {
* 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;
}