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..7ceb4845a9 --- /dev/null +++ b/canon-docs/src/app/(docs)/components/select/page.mdx @@ -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. + +} + 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 buttons have different sizes. + +} + code={` + + + `} +/> + +### Disabled + +Here's a view when buttons are disabled. + +} + code={``} +/> + +### Responsive + +Here's a view when buttons are 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..2e19455ff4 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,32 @@ 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>; + 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 index 3d4849c393..d9de7ddb4a 100644 --- a/packages/canon/src/components/Select/Select.stories.tsx +++ b/packages/canon/src/components/Select/Select.stories.tsx @@ -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 => {story()}], } satisfies Meta; 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 => ( + + + + + ), +}; + 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, diff --git a/packages/canon/src/components/Select/Select.tsx b/packages/canon/src/components/Select/Select.tsx index c6b8edc984..a3d99efde0 100644 --- a/packages/canon/src/components/Select/Select.tsx +++ b/packages/canon/src/components/Select/Select.tsx @@ -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( (props, ref) => { const { @@ -42,12 +38,14 @@ export const Select = React.forwardRef( size = 'medium', disabled = false, required = false, + style, } = props; return ( {label && ( {label} diff --git a/packages/canon/src/components/Select/index.ts b/packages/canon/src/components/Select/index.ts index b0464dabd8..2cddf1fe62 100644 --- a/packages/canon/src/components/Select/index.ts +++ b/packages/canon/src/components/Select/index.ts @@ -15,3 +15,4 @@ */ export * from './Select'; +export * from './types'; diff --git a/packages/canon/src/components/Select/types.ts b/packages/canon/src/components/Select/types.ts index 8e1407450d..c2f67b9ee7 100644 --- a/packages/canon/src/components/Select/types.ts +++ b/packages/canon/src/components/Select/types.ts @@ -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; }