diff --git a/packages/canon/src/components/Select/Select.stories.tsx b/packages/canon/src/components/Select/Select.stories.tsx new file mode 100644 index 0000000000..3d4849c393 --- /dev/null +++ b/packages/canon/src/components/Select/Select.stories.tsx @@ -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 =>
{story()}
], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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 = (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()) + .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 ( +
{ + 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); + }} + > + + + + ); + }, + ], +}; diff --git a/packages/canon/src/components/Select/Select.styles.css b/packages/canon/src/components/Select/Select.styles.css new file mode 100644 index 0000000000..b1a7e53b9a --- /dev/null +++ b/packages/canon/src/components/Select/Select.styles.css @@ -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; +} diff --git a/packages/canon/src/components/Select/Select.tsx b/packages/canon/src/components/Select/Select.tsx new file mode 100644 index 0000000000..c6b8edc984 --- /dev/null +++ b/packages/canon/src/components/Select/Select.tsx @@ -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( + (props, ref) => { + const { + className, + name, + label, + description, + value, + defaultValue, + placeholder, + onValueChange, + onOpenChange, + options, + size = 'medium', + disabled = false, + required = false, + } = props; + return ( + + {label && ( + {label} + )} + + + + + + + + + + + + {options?.map(option => ( + + + + + + {option.label} + + + ))} + + + + + {description && ( + + {description} + + )} + + + ); + }, +); + +Select.displayName = 'Select'; diff --git a/packages/canon/src/components/Select/index.ts b/packages/canon/src/components/Select/index.ts new file mode 100644 index 0000000000..b0464dabd8 --- /dev/null +++ b/packages/canon/src/components/Select/index.ts @@ -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'; diff --git a/packages/canon/src/components/Select/types.ts b/packages/canon/src/components/Select/types.ts new file mode 100644 index 0000000000..8e1407450d --- /dev/null +++ b/packages/canon/src/components/Select/types.ts @@ -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>; + + /** + * 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; +} diff --git a/packages/canon/src/css/components.css b/packages/canon/src/css/components.css index 8c77a0f58c..cd6517c6ed 100644 --- a/packages/canon/src/css/components.css +++ b/packages/canon/src/css/components.css @@ -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'; diff --git a/packages/canon/src/index.ts b/packages/canon/src/index.ts index 3c9a28b44d..1f7841cfd5 100644 --- a/packages/canon/src/index.ts +++ b/packages/canon/src/index.ts @@ -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';