diff --git a/.changeset/tender-gifts-fall.md b/.changeset/tender-gifts-fall.md new file mode 100644 index 0000000000..f53b9c1d5d --- /dev/null +++ b/.changeset/tender-gifts-fall.md @@ -0,0 +1,5 @@ +--- +'@backstage/canon': minor +--- + +Add new Select component for Canon diff --git a/canon-docs/src/app/(docs)/components/select/page.mdx b/canon-docs/src/app/(docs)/components/select/page.mdx new file mode 100644 index 0000000000..4399f62235 --- /dev/null +++ b/canon-docs/src/app/(docs)/components/select/page.mdx @@ -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. + +} + code={` +`} +/> + + + + Usage + Theming + + + +`} + /> + + + 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. + `} + /> + + + + +## API reference + + + +## Examples + +### With Label and description + +Select component with label and description. + +} + code={``} +/> + +### Sizes + +Here's a view when the selects have different sizes. + +} + code={` + + + `} +/> + +### Disabled + +Here's a view when the select is disabled. + +} + code={``} +/> + +### Responsive + +Here's a view when the select is responsive. + +`} +/> diff --git a/canon-docs/src/app/(docs)/components/select/props.ts b/canon-docs/src/app/(docs)/components/select/props.ts new file mode 100644 index 0000000000..14bd3bb26f --- /dev/null +++ b/canon-docs/src/app/(docs)/components/select/props.ts @@ -0,0 +1,54 @@ +import { classNamePropDefs, stylePropDefs } from '../../../../utils/propDefs'; +import type { PropDef } from '../../../../utils/propDefs'; + +export const selectPropDefs: Record = { + 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, +}; diff --git a/canon-docs/src/components/PropsTable/PropsTable.tsx b/canon-docs/src/components/PropsTable/PropsTable.tsx index c10daf5082..ec82006b8c 100644 --- a/canon-docs/src/components/PropsTable/PropsTable.tsx +++ b/canon-docs/src/components/PropsTable/PropsTable.tsx @@ -25,8 +25,8 @@ export const PropsTable = >({ Prop - Type - Default + Type + Default Responsive @@ -45,7 +45,7 @@ export const PropsTable = >({ {n} - + @@ -61,7 +61,7 @@ export const PropsTable = >({ )} - + {data[n].default ? data[n].default : '-'} diff --git a/canon-docs/src/snippets/stories-snippets.tsx b/canon-docs/src/snippets/stories-snippets.tsx index 76137e9b82..9dec825ad2 100644 --- a/canon-docs/src/snippets/stories-snippets.tsx +++ b/canon-docs/src/snippets/stories-snippets.tsx @@ -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 ? : null; }; + +export const SelectSnippet = ({ + story, +}: { + story: keyof typeof SelectStories; +}) => { + const stories = composeStories(SelectStories); + const StoryComponent = stories[story as keyof typeof stories]; + + return StoryComponent ? : null; +}; diff --git a/canon-docs/src/utils/data.ts b/canon-docs/src/utils/data.ts index 6aa487d93a..4c5436eac7 100644 --- a/canon-docs/src/utils/data.ts +++ b/canon-docs/src/utils/data.ts @@ -97,6 +97,11 @@ export const components: Page[] = [ slug: 'input', status: 'alpha', }, + { + title: 'Select', + slug: 'select', + status: 'alpha', + }, { title: 'Table', slug: 'table', diff --git a/packages/canon/css/components.css b/packages/canon/css/components.css index 1713468367..8492bb6d83 100644 --- a/packages/canon/css/components.css +++ b/packages/canon/css/components.css @@ -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; +} diff --git a/packages/canon/css/select.css b/packages/canon/css/select.css new file mode 100644 index 0000000000..77a88eaa00 --- /dev/null +++ b/packages/canon/css/select.css @@ -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; +} diff --git a/packages/canon/css/styles.css b/packages/canon/css/styles.css index 7289562f9d..a998cdcb8e 100644 --- a/packages/canon/css/styles.css +++ b/packages/canon/css/styles.css @@ -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; +} diff --git a/packages/canon/report.api.md b/packages/canon/report.api.md index 6dfbc59ffb..b0b400aaea 100644 --- a/packages/canon/report.api.md +++ b/packages/canon/report.api.md @@ -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 +>; + +// @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>; + style?: React.CSSProperties; + value?: string; +} + // @public (undocumented) export type Space = | '0.5' 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..d9de7ddb4a --- /dev/null +++ b/packages/canon/src/components/Select/Select.stories.tsx @@ -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; + +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: { + 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 => ( + + + + + ), +}; + +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 = (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: { + ...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 ( + { + 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); + }} + > + + + Submit + + + ); + }, + ], +}; 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..1fc7072489 --- /dev/null +++ b/packages/canon/src/components/Select/Select.tsx @@ -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( + (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 ( + + {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..2cddf1fe62 --- /dev/null +++ b/packages/canon/src/components/Select/index.ts @@ -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'; diff --git a/packages/canon/src/components/Select/types.ts b/packages/canon/src/components/Select/types.ts new file mode 100644 index 0000000000..960129da70 --- /dev/null +++ b/packages/canon/src/components/Select/types.ts @@ -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>; + + /** + * 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; +} 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';