Add support for custom icon

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-07-14 11:00:42 +02:00
parent 2a9ece59e2
commit 83fd7f4794
10 changed files with 156 additions and 190 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': minor
---
**Breaking change** We are moving the Select component to use React Aria under the hood. We updated most props and events according to their underlying API.
File diff suppressed because one or more lines are too long
+108 -139
View File
@@ -9552,6 +9552,7 @@
font-size: var(--bui-font-size-2);
font-weight: var(--bui-font-weight-regular);
margin-top: var(--bui-space-2);
display: inline-block;
}
.bui-FieldLabelWrapper {
@@ -10049,6 +10050,16 @@
overflow: hidden;
}
.bui-Popover {
background-color: var(--bui-bg-surface-1);
border: 1px solid var(--bui-border);
border-radius: var(--bui-radius-3);
padding-block: var(--bui-space-1);
margin-right: 12px;
overflow: scroll;
box-shadow: 0 4px 12px #0000001a;
}
.bui-RadioGroup {
color: var(--bui-fg-primary);
flex-direction: column;
@@ -10607,122 +10618,94 @@
width: 100%;
}
.bui-Select {
font-family: var(--bui-font-regular);
flex-direction: column;
width: 100%;
display: flex;
}
.bui-SelectLabel {
font-size: var(--bui-font-size-2);
font-weight: var(--bui-font-weight-regular);
color: var(--bui-fg-primary);
margin-bottom: var(--bui-space-1_5);
cursor: pointer;
}
.bui-SelectLabel[data-disabled] {
cursor: default;
}
.bui-SelectDescription {
font-size: var(--bui-font-size-2);
font-weight: var(--bui-font-weight-regular);
color: var(--bui-fg-secondary);
padding-top: var(--bui-space-1_5);
margin: 0;
}
.bui-SelectError {
font-size: var(--bui-font-size-2);
font-weight: var(--bui-font-weight-regular);
color: var(--bui-fg-danger);
padding-top: var(--bui-space-1_5);
margin: 0;
.bui-Select[data-invalid] {
& .bui-SelectTrigger {
border-color: var(--bui-fg-danger);
}
}
.bui-SelectTrigger {
box-sizing: border-box;
border-radius: var(--bui-radius-3);
border: 1px solid var(--bui-border);
padding: 0 var(--bui-space-4);
background-color: var(--bui-bg-surface-1);
font-size: var(--bui-font-size-3);
font-family: var(--bui-font-regular);
font-weight: var(--bui-font-weight-regular);
color: var(--bui-fg-primary);
cursor: pointer;
justify-content: space-between;
align-items: center;
gap: var(--bui-space-2);
width: 100%;
max-width: 100%;
transition: border-color .2s ease-in-out, outline-color .2s ease-in-out;
display: flex;
& svg {
flex-shrink: 0;
}
&[data-size="small"] {
height: 2rem;
padding-inline: var(--bui-space-3);
}
&[data-size="medium"] {
height: 3rem;
padding-inline: var(--bui-space-4);
}
&[data-size="small"] svg {
width: 1rem;
height: 1rem;
}
&[data-size="medium"] svg {
width: 1.25rem;
height: 1.25rem;
}
&::placeholder {
color: var(--bui-fg-secondary);
}
&:hover {
border-color: var(--bui-border-hover);
}
&:focus-visible {
border-color: var(--bui-border-pressed);
outline: 0;
}
&[data-invalid] {
border-color: var(--bui-fg-danger);
}
&[data-invalid]:hover, &[data-invalid]:focus-visible {
border-width: 2px;
}
&[data-disabled] {
cursor: not-allowed;
border-color: var(--bui-border-disabled);
color: var(--bui-fg-disabled);
}
&[data-popup-open] .bui-SelectIcon {
transform: rotate(180deg);
}
}
.bui-SelectTrigger::placeholder {
color: var(--bui-fg-secondary);
}
.bui-SelectValue {
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
font-size: var(--bui-font-size-3);
font-family: var(--bui-font-regular);
font-weight: var(--bui-font-weight-regular);
color: var(--bui-fg-primary);
overflow: hidden;
.bui-SelectTrigger:hover {
border-color: var(--bui-border-hover);
}
.bui-SelectTrigger:focus-visible {
border-color: var(--bui-border-pressed);
outline: 0;
}
.bui-SelectTrigger[data-invalid] {
border-color: var(--bui-fg-danger);
}
.bui-SelectTrigger[data-invalid]:hover, .bui-SelectTrigger[data-invalid]:focus-visible {
border-width: 2px;
}
.bui-SelectTrigger[data-disabled] {
cursor: not-allowed;
border-color: var(--bui-border-disabled);
color: var(--bui-fg-disabled);
}
.bui-SelectTrigger[data-size="small"] {
height: 2rem;
}
.bui-SelectTrigger[data-size="medium"] {
height: 3rem;
}
.bui-SelectIcon {
margin-left: var(--bui-space-5);
transition: transform .2s;
}
.bui-SelectTrigger[data-popup-open] .bui-SelectIcon {
transform: rotate(180deg);
}
.bui-SelectPopup {
box-sizing: border-box;
max-height: var(--available-height);
background-color: var(--bui-bg-surface-1);
border: 1px solid var(--bui-border);
border-radius: var(--bui-radius-3);
padding-block: var(--bui-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;
}
.bui-SelectPopup[data-starting-style], .bui-SelectPopup[data-ending-style] {
opacity: 0;
transform: scale(.9);
& .bui-SelectItemIndicator {
display: none;
}
}
.bui-SelectItem {
@@ -10741,61 +10724,47 @@
grid-template-areas: "icon text";
display: grid;
position: relative;
}
.bui-SelectItem[data-highlighted] {
z-index: 0;
color: var(--bui-fg-primary);
position: relative;
}
&[data-focused] {
z-index: 0;
color: var(--bui-fg-primary);
position: relative;
}
.bui-SelectItem[data-highlighted]:before {
content: "";
z-index: -1;
background-color: var(--bui-bg-tint-hover);
border-radius: .25rem;
position: absolute;
inset-block: 0;
inset-inline: .25rem;
}
&[data-focused]:before {
content: "";
z-index: -1;
background-color: var(--bui-bg-tint-hover);
border-radius: .25rem;
position: absolute;
inset-block: 0;
inset-inline: .25rem;
}
.bui-SelectItem[data-disabled] {
cursor: not-allowed;
color: var(--bui-fg-disabled);
&[data-disabled] {
cursor: not-allowed;
color: var(--bui-fg-disabled);
}
&[data-selected] .bui-SelectItemIndicator {
opacity: 1;
}
}
.bui-SelectItemIndicator {
opacity: 0;
grid-area: icon;
justify-content: center;
align-items: center;
transition: opacity .2s ease-in-out;
display: flex;
}
.bui-SelectItemText {
.bui-SelectItemLabel {
flex: 1;
grid-area: text;
}
.bui-SelectRequired {
color: var(--bui-fg-secondary);
font-size: var(--bui-font-size-2);
font-weight: var(--bui-font-weight-regular);
margin-left: var(--bui-space-1);
}
.bui-SelectIcon {
justify-content: center;
align-items: center;
display: flex;
}
.bui-SelectValue {
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
overflow: hidden;
}
.bui-Switch {
align-items: center;
gap: var(--bui-space-3);
+22 -23
View File
@@ -5,14 +5,12 @@
```ts
import { Avatar as Avatar_2 } from '@base-ui-components/react/avatar';
import { ButtonProps as ButtonProps_2 } from 'react-aria-components';
import { ChangeEvent } from 'react';
import { Collapsible as Collapsible_2 } from '@base-ui-components/react/collapsible';
import { ComponentProps } from 'react';
import type { ComponentPropsWithRef } from 'react';
import { Context } from 'react';
import type { CSSProperties } from 'react';
import type { ElementType } from 'react';
import { FocusEvent as FocusEvent_2 } from 'react';
import { ForwardRefExoticComponent } from 'react';
import { HTMLAttributes } from 'react';
import { JSX as JSX_2 } from 'react/jsx-runtime';
@@ -26,6 +24,7 @@ import { RefAttributes } from 'react';
import type { RemixiconComponentType } from '@remixicon/react';
import { ScrollArea as ScrollArea_2 } from '@base-ui-components/react/scroll-area';
import type { SearchFieldProps as SearchFieldProps_2 } from 'react-aria-components';
import type { SelectProps as SelectProps_2 } from 'react-aria-components';
import type { SwitchProps as SwitchProps_2 } from 'react-aria-components';
import { Table as Table_2 } from '@tanstack/react-table';
import { Tabs as Tabs_2 } from '@base-ui-components/react/tabs';
@@ -426,6 +425,13 @@ export const componentDefinitions: {
readonly weight: readonly ['regular', 'bold'];
};
};
readonly List: {
readonly classNames: {
readonly root: 'bui-List';
readonly row: 'bui-ListRow';
readonly label: 'bui-ListLabel';
};
};
readonly Menu: {
readonly classNames: {
readonly trigger: 'bui-MenuTrigger';
@@ -445,6 +451,11 @@ export const componentDefinitions: {
readonly separator: 'bui-MenuSeparator';
};
};
readonly Popover: {
readonly classNames: {
readonly root: 'bui-Popover';
};
};
readonly RadioGroup: {
readonly classNames: {
readonly root: 'bui-RadioGroup';
@@ -469,16 +480,13 @@ export const componentDefinitions: {
readonly Select: {
readonly classNames: {
readonly root: 'bui-Select';
readonly required: 'bui-SelectRequired';
readonly trigger: 'bui-SelectTrigger';
readonly value: 'bui-SelectValue';
readonly icon: 'bui-SelectIcon';
readonly popup: 'bui-SelectPopup';
readonly list: 'bui-SelectList';
readonly item: 'bui-SelectItem';
readonly itemIndicator: 'bui-SelectItemIndicator';
readonly itemText: 'bui-SelectItemText';
readonly description: 'bui-SelectDescription';
readonly error: 'bui-SelectError';
readonly itemLabel: 'bui-SelectItemLabel';
};
readonly dataAttributes: {
readonly size: readonly ['small', 'medium'];
@@ -1521,28 +1529,19 @@ export const Select: ForwardRefExoticComponent<
>;
// @public (undocumented)
export interface SelectProps {
className?: string;
defaultValue?: string;
description?: string;
disabled?: boolean;
error?: string;
label?: string;
name: string;
onBlur?: (event: FocusEvent_2<HTMLSelectElement>) => void;
onChange?: (event: ChangeEvent<HTMLSelectElement>) => void;
onOpenChange?: (open: boolean) => void;
onValueChange?: (value: string) => void;
export interface SelectProps
extends SelectProps_2<{
name: string;
value: string;
}>,
Omit<FieldLabelProps, 'htmlFor' | 'id'> {
icon?: ReactNode;
options?: Array<{
value: string;
label: string;
disabled?: boolean;
}>;
placeholder?: string;
required?: boolean;
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
style?: React.CSSProperties;
value?: string;
}
// @public (undocumented)
@@ -61,8 +61,8 @@ const DataTablePagination = forwardRef(
{ label: 'Show 40 results', value: '40' },
{ label: 'Show 50 results', value: '50' },
]}
value={pageSize?.toString()}
onValueChange={value => {
selectedKey={pageSize?.toString()}
onSelectionChange={value => {
table?.setPageSize(Number(value));
}}
className="bui-DataTablePagination--select"
@@ -17,6 +17,7 @@ import type { Meta, StoryObj } from '@storybook/react';
import { Select } from './Select';
import { Flex } from '../Flex';
import { Form } from 'react-aria-components';
import { RiCloudLine } from '@remixicon/react';
const meta = {
title: 'Forms/Select',
@@ -64,6 +65,13 @@ export const WithLabelAndDescription: Story = {
},
};
export const WithIcon: Story = {
args: {
...WithLabel.args,
},
render: args => <Select {...args} icon={<RiCloudLine />} />,
};
export const Sizes: Story = {
args: {
...Preview.args,
@@ -33,6 +33,10 @@
gap: var(--bui-space-2);
max-width: 100%;
& svg {
flex-shrink: 0;
}
&[data-size='small'] {
height: 2rem;
padding-inline: var(--bui-space-3);
@@ -103,11 +107,6 @@
}
}
.bui-SelectIcon {
margin-left: var(--bui-space-5);
transition: transform 0.2s ease;
}
.bui-SelectItem {
position: relative;
width: var(--anchor-width);
@@ -164,16 +163,3 @@
flex: 1;
grid-area: text;
}
.bui-SelectRequired {
color: var(--bui-fg-secondary);
font-size: var(--bui-font-size-2);
font-weight: var(--bui-font-weight-regular);
margin-left: var(--bui-space-1);
}
.bui-SelectIcon {
display: flex;
align-items: center;
justify-content: center;
}
@@ -41,6 +41,7 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>((props, ref) => {
options,
placeholder = 'Select an option',
size = 'small',
icon,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
isRequired,
@@ -81,6 +82,7 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>((props, ref) => {
className={classNames.trigger}
data-size={dataAttributes['data-size']}
>
{icon}
<SelectValue className={classNames.value} />
<Icon aria-hidden="true" name="chevron-down" />
</Button>
@@ -19,14 +19,12 @@ import { ReactNode } from 'react';
import type { SelectProps as AriaSelectProps } from 'react-aria-components';
import type { FieldLabelProps } from '../FieldLabel/types';
interface SelectOption {
name: string;
value: string;
}
/** @public */
export interface SelectProps
extends AriaSelectProps<SelectOption>,
extends AriaSelectProps<{
name: string;
value: string;
}>,
Omit<FieldLabelProps, 'htmlFor' | 'id'> {
/**
* An icon to render before the input
@@ -190,7 +190,6 @@ export const componentDefinitions = {
item: 'bui-SelectItem',
itemIndicator: 'bui-SelectItemIndicator',
itemLabel: 'bui-SelectItemLabel',
error: 'bui-SelectError',
},
dataAttributes: {
size: ['small', 'medium'] as const,