fix(ui): allow custom className on BUI components

Fixes className prop handling across BUI components to allow users to
add custom classes that augment rather than override default styles.

Changes:
- Extract className from cleanedProps before spreading
- Add className as last argument to clsx() calls
- Update type definitions to support className prop

Affected components:
- Menu and all variants (MenuListBox, MenuAutocomplete, etc.)
- Switch, Skeleton, FieldLabel
- Header, HeaderToolbar, HeaderPage
- Tabs, TabList, Tab, TabPanel

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2025-10-22 15:10:40 +02:00
parent 1ef3ca48d6
commit b78fc4541b
18 changed files with 126 additions and 42 deletions
+17
View File
@@ -0,0 +1,17 @@
---
'@backstage/ui': minor
---
**BREAKING**: Changed className prop behavior to augment default styles instead of being ignored or overriding them.
Affected components:
- Menu, MenuListBox, MenuAutocomplete, MenuAutocompleteListbox, MenuItem, MenuListBoxItem, MenuSection, MenuSeparator
- Switch
- Skeleton
- FieldLabel
- Header, HeaderToolbar
- HeaderPage
- Tabs, TabList, Tab, TabPanel
If you were passing custom className values to any of these components that relied on the previous behavior, you may need to adjust your styles to account for the default classes now being applied alongside your custom classes.
+10 -5
View File
@@ -850,7 +850,8 @@ export const FieldLabel: ForwardRefExoticComponent<
>;
// @public (undocumented)
export interface FieldLabelProps {
export interface FieldLabelProps
extends Pick<React.HTMLAttributes<HTMLDivElement>, 'className'> {
description?: string | null;
htmlFor?: string;
id?: string;
@@ -946,6 +947,8 @@ export interface HeaderPageProps {
// (undocumented)
breadcrumbs?: HeaderPageBreadcrumb[];
// (undocumented)
className?: string;
// (undocumented)
customActions?: React.ReactNode;
// (undocumented)
tabs?: HeaderTab[];
@@ -955,6 +958,8 @@ export interface HeaderPageProps {
// @public
export interface HeaderProps {
// (undocumented)
className?: string;
// (undocumented)
customActions?: React.ReactNode;
// (undocumented)
@@ -1149,7 +1154,7 @@ export const RadioGroup: ForwardRefExoticComponent<
// @public (undocumented)
export interface RadioGroupProps
extends Omit<RadioGroupProps_2, 'children'>,
Omit<FieldLabelProps, 'htmlFor' | 'id'> {
Omit<FieldLabelProps, 'htmlFor' | 'id' | 'className'> {
// (undocumented)
children?: ReactNode;
}
@@ -1171,7 +1176,7 @@ export const SearchField: ForwardRefExoticComponent<
// @public (undocumented)
export interface SearchFieldProps
extends SearchFieldProps_2,
Omit<FieldLabelProps, 'htmlFor' | 'id'> {
Omit<FieldLabelProps, 'htmlFor' | 'id' | 'className'> {
icon?: ReactNode | false;
placeholder?: string;
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
@@ -1189,7 +1194,7 @@ export interface SelectProps
name: string;
value: string;
}>,
Omit<FieldLabelProps, 'htmlFor' | 'id'> {
Omit<FieldLabelProps, 'htmlFor' | 'id' | 'className'> {
icon?: ReactNode;
options?: Array<{
value: string;
@@ -1392,7 +1397,7 @@ export const TextField: ForwardRefExoticComponent<
// @public (undocumented)
export interface TextFieldProps
extends TextFieldProps_2,
Omit<FieldLabelProps, 'htmlFor' | 'id'> {
Omit<FieldLabelProps, 'htmlFor' | 'id' | 'className'> {
icon?: ReactNode;
placeholder?: string;
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
@@ -24,14 +24,21 @@ import clsx from 'clsx';
export const FieldLabel = forwardRef<HTMLDivElement, FieldLabelProps>(
(props: FieldLabelProps, ref) => {
const { classNames, cleanedProps } = useStyles('FieldLabel', props);
const { label, secondaryLabel, description, htmlFor, id, ...rest } =
cleanedProps;
const {
className,
label,
secondaryLabel,
description,
htmlFor,
id,
...rest
} = cleanedProps;
if (!label) return null;
return (
<div
className={clsx(classNames.root, styles[classNames.root])}
className={clsx(classNames.root, styles[classNames.root], className)}
{...rest}
ref={ref}
>
@@ -15,7 +15,8 @@
*/
/** @public */
export interface FieldLabelProps {
export interface FieldLabelProps
extends Pick<React.HTMLAttributes<HTMLDivElement>, 'className'> {
/**
* The label of the text field
*/
+10 -2
View File
@@ -35,8 +35,15 @@ declare module 'react-aria-components' {
*/
export const Header = (props: HeaderProps) => {
const { classNames, cleanedProps } = useStyles('Header', props);
const { tabs, icon, title, titleLink, customActions, onTabSelectionChange } =
cleanedProps;
const {
className,
tabs,
icon,
title,
titleLink,
customActions,
onTabSelectionChange,
} = cleanedProps;
const hasTabs = tabs && tabs.length > 0;
@@ -54,6 +61,7 @@ export const Header = (props: HeaderProps) => {
className={clsx(
classNames.tabsWrapper,
styles[classNames.tabsWrapper],
className,
)}
>
<Tabs onSelectionChange={onTabSelectionChange}>
@@ -31,7 +31,8 @@ import clsx from 'clsx';
*/
export const HeaderToolbar = (props: HeaderToolbarProps) => {
const { classNames, cleanedProps } = useStyles('Header', props);
const { icon, title, titleLink, customActions, hasTabs } = cleanedProps;
const { className, icon, title, titleLink, customActions, hasTabs } =
cleanedProps;
let navigate = useNavigate();
// Refs for collision detection
@@ -53,7 +54,11 @@ export const HeaderToolbar = (props: HeaderToolbarProps) => {
return (
<RouterProvider navigate={navigate} useHref={useHref}>
<div
className={clsx(classNames.toolbar, styles[classNames.toolbar])}
className={clsx(
classNames.toolbar,
styles[classNames.toolbar],
className,
)}
data-has-tabs={hasTabs}
>
<div
@@ -29,6 +29,7 @@ export interface HeaderProps {
customActions?: React.ReactNode;
tabs?: HeaderTab[];
onTabSelectionChange?: TabsProps['onSelectionChange'];
className?: string;
}
/**
@@ -59,4 +60,5 @@ export interface HeaderToolbarProps {
titleLink?: HeaderProps['titleLink'];
customActions?: HeaderProps['customActions'];
hasTabs?: boolean;
className?: string;
}
@@ -32,10 +32,12 @@ import clsx from 'clsx';
*/
export const HeaderPage = (props: HeaderPageProps) => {
const { classNames, cleanedProps } = useStyles('HeaderPage', props);
const { title, tabs, customActions, breadcrumbs } = cleanedProps;
const { className, title, tabs, customActions, breadcrumbs } = cleanedProps;
return (
<Container className={clsx(classNames.root, styles[classNames.root])}>
<Container
className={clsx(classNames.root, styles[classNames.root], className)}
>
<div className={clsx(classNames.content, styles[classNames.content])}>
<div
className={clsx(
@@ -26,6 +26,7 @@ export interface HeaderPageProps {
customActions?: React.ReactNode;
tabs?: HeaderTab[];
breadcrumbs?: HeaderPageBreadcrumb[];
className?: string;
}
/**
+46 -12
View File
@@ -86,6 +86,7 @@ export const SubmenuTrigger = (props: SubmenuTriggerProps) => {
export const Menu = (props: MenuProps<object>) => {
const { classNames, cleanedProps } = useStyles('Menu', props);
const {
className,
placement = 'bottom start',
virtualized = false,
maxWidth,
@@ -136,7 +137,11 @@ export const Menu = (props: MenuProps<object>) => {
return (
<RAPopover
ref={popoverRef}
className={clsx(classNames.popover, styles[classNames.popover])}
className={clsx(
classNames.popover,
styles[classNames.popover],
className,
)}
placement={placement}
isNonModal={true}
isKeyboardDismissDisabled={false}
@@ -163,6 +168,7 @@ export const Menu = (props: MenuProps<object>) => {
export const MenuListBox = (props: MenuListBoxProps<object>) => {
const { classNames, cleanedProps } = useStyles('Menu', props);
const {
className,
selectionMode = 'single',
placement = 'bottom start',
virtualized = false,
@@ -184,7 +190,11 @@ export const MenuListBox = (props: MenuListBoxProps<object>) => {
return (
<RAPopover
className={clsx(classNames.popover, styles[classNames.popover])}
className={clsx(
classNames.popover,
styles[classNames.popover],
className,
)}
placement={placement}
>
{virtualized ? (
@@ -207,6 +217,7 @@ export const MenuListBox = (props: MenuListBoxProps<object>) => {
export const MenuAutocomplete = (props: MenuAutocompleteProps<object>) => {
const { classNames, cleanedProps } = useStyles('Menu', props);
const {
className,
placement = 'bottom start',
virtualized = false,
maxWidth,
@@ -229,7 +240,11 @@ export const MenuAutocomplete = (props: MenuAutocompleteProps<object>) => {
return (
<RAPopover
className={clsx(classNames.popover, styles[classNames.popover])}
className={clsx(
classNames.popover,
styles[classNames.popover],
className,
)}
placement={placement}
>
<RouterProvider navigate={navigate} useHref={useHref}>
@@ -281,6 +296,7 @@ export const MenuAutocompleteListbox = (
) => {
const { classNames, cleanedProps } = useStyles('Menu', props);
const {
className,
selectionMode = 'single',
placement = 'bottom start',
virtualized = false,
@@ -304,7 +320,11 @@ export const MenuAutocompleteListbox = (
return (
<RAPopover
className={clsx(classNames.popover, styles[classNames.popover])}
className={clsx(
classNames.popover,
styles[classNames.popover],
className,
)}
placement={placement}
>
<RAAutocomplete filter={contains}>
@@ -352,6 +372,7 @@ export const MenuAutocompleteListbox = (
export const MenuItem = (props: MenuItemProps) => {
const { classNames, cleanedProps } = useStyles('Menu', props);
const {
className,
iconStart,
color = 'primary',
children,
@@ -365,7 +386,7 @@ export const MenuItem = (props: MenuItemProps) => {
if (isLink && isExternal) {
return (
<RAMenuItem
className={clsx(classNames.item, styles[classNames.item])}
className={clsx(classNames.item, styles[classNames.item], className)}
data-color={color}
textValue={typeof children === 'string' ? children : undefined}
onAction={() => window.open(href, '_blank', 'noopener,noreferrer')}
@@ -398,7 +419,7 @@ export const MenuItem = (props: MenuItemProps) => {
return (
<RAMenuItem
className={clsx(classNames.item, styles[classNames.item])}
className={clsx(classNames.item, styles[classNames.item], className)}
data-color={color}
href={href}
textValue={typeof children === 'string' ? children : undefined}
@@ -429,14 +450,18 @@ export const MenuItem = (props: MenuItemProps) => {
/** @public */
export const MenuListBoxItem = (props: MenuListBoxItemProps) => {
const { classNames, cleanedProps } = useStyles('Menu', props);
const { children, ...rest } = cleanedProps;
const { children, className, ...rest } = cleanedProps;
return (
<RAListBoxItem
textValue={
typeof props.children === 'string' ? props.children : undefined
}
className={clsx(classNames.itemListBox, styles[classNames.itemListBox])}
className={clsx(
classNames.itemListBox,
styles[classNames.itemListBox],
className,
)}
{...rest}
>
<div
@@ -466,11 +491,15 @@ export const MenuListBoxItem = (props: MenuListBoxItemProps) => {
/** @public */
export const MenuSection = (props: MenuSectionProps<object>) => {
const { classNames, cleanedProps } = useStyles('Menu', props);
const { children, title, ...rest } = cleanedProps;
const { children, className, title, ...rest } = cleanedProps;
return (
<RAMenuSection
className={clsx(classNames.section, styles[classNames.section])}
className={clsx(
classNames.section,
styles[classNames.section],
className,
)}
{...rest}
>
<RAMenuHeader
@@ -489,11 +518,16 @@ export const MenuSection = (props: MenuSectionProps<object>) => {
/** @public */
export const MenuSeparator = (props: MenuSeparatorProps) => {
const { classNames, cleanedProps } = useStyles('Menu', props);
const { className, ...rest } = cleanedProps;
return (
<RAMenuSeparator
className={clsx(classNames.separator, styles[classNames.separator])}
{...cleanedProps}
className={clsx(
classNames.separator,
styles[classNames.separator],
className,
)}
{...rest}
/>
);
};
@@ -22,7 +22,7 @@ import type { FieldLabelProps } from '../FieldLabel/types';
/** @public */
export interface PasswordFieldProps
extends AriaTextFieldProps,
Omit<FieldLabelProps, 'htmlFor' | 'id'> {
Omit<FieldLabelProps, 'htmlFor' | 'id' | 'className'> {
/**
* An icon to render before the input
*/
@@ -24,7 +24,7 @@ import { ReactNode } from 'react';
/** @public */
export interface RadioGroupProps
extends Omit<AriaRadioGroupProps, 'children'>,
Omit<FieldLabelProps, 'htmlFor' | 'id'> {
Omit<FieldLabelProps, 'htmlFor' | 'id' | 'className'> {
children?: ReactNode;
}
@@ -22,7 +22,7 @@ import type { FieldLabelProps } from '../FieldLabel/types';
/** @public */
export interface SearchFieldProps
extends AriaSearchFieldProps,
Omit<FieldLabelProps, 'htmlFor' | 'id'> {
Omit<FieldLabelProps, 'htmlFor' | 'id' | 'className'> {
/**
* An icon to render before the input
*/
+1 -1
View File
@@ -25,7 +25,7 @@ export interface SelectProps
name: string;
value: string;
}>,
Omit<FieldLabelProps, 'htmlFor' | 'id'> {
Omit<FieldLabelProps, 'htmlFor' | 'id' | 'className'> {
/**
* An icon to render before the input
*/
@@ -27,11 +27,11 @@ export const Skeleton = (props: SkeletonProps) => {
rounded: false,
...props,
});
const { width, height, rounded, style, ...rest } = cleanedProps;
const { className, width, height, rounded, style, ...rest } = cleanedProps;
return (
<div
className={clsx(classNames.root, styles[classNames.root])}
className={clsx(classNames.root, styles[classNames.root], className)}
data-rounded={rounded}
style={{
width,
+2 -2
View File
@@ -25,11 +25,11 @@ import clsx from 'clsx';
export const Switch = forwardRef<HTMLLabelElement, SwitchProps>(
(props, ref) => {
const { classNames, cleanedProps } = useStyles('Switch', props);
const { label, ...rest } = cleanedProps;
const { className, label, ...rest } = cleanedProps;
return (
<AriaSwitch
className={clsx(classNames.root, styles[classNames.root])}
className={clsx(classNames.root, styles[classNames.root], className)}
ref={ref}
{...rest}
>
+8 -6
View File
@@ -85,7 +85,7 @@ const isTabActive = (
*/
export const Tabs = (props: TabsProps) => {
const { classNames, cleanedProps } = useStyles('Tabs', props);
const { children, ...rest } = cleanedProps;
const { className, children, ...rest } = cleanedProps;
const tabsRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
@@ -149,7 +149,7 @@ export const Tabs = (props: TabsProps) => {
<TabsContext.Provider value={contextValue}>
<RouterProvider navigate={navigate} useHref={useHref}>
<AriaTabs
className={clsx(classNames.tabs, styles[classNames.tabs])}
className={clsx(classNames.tabs, styles[classNames.tabs], className)}
keyboardActivation="manual"
selectedKey={computedSelectedKey}
ref={tabsRef}
@@ -169,7 +169,7 @@ export const Tabs = (props: TabsProps) => {
*/
export const TabList = (props: TabListProps) => {
const { classNames, cleanedProps } = useStyles('Tabs', props);
const { children, ...rest } = cleanedProps;
const { className, children, ...rest } = cleanedProps;
const { setHoveredKey, tabRefs, tabsRef, hoveredKey, prevHoveredKey } =
useTabsContext();
@@ -193,6 +193,7 @@ export const TabList = (props: TabListProps) => {
className={clsx(
classNames.tabListWrapper,
styles[classNames.tabListWrapper],
className,
)}
>
<AriaTabList
@@ -220,6 +221,7 @@ export const TabList = (props: TabListProps) => {
export const Tab = (props: TabProps) => {
const { classNames, cleanedProps } = useStyles('Tabs', props);
const {
className,
href,
children,
id,
@@ -231,7 +233,7 @@ export const Tab = (props: TabProps) => {
return (
<AriaTab
id={id}
className={clsx(classNames.tab, styles[classNames.tab])}
className={clsx(classNames.tab, styles[classNames.tab], className)}
ref={el => setTabRef(id as string, el as HTMLDivElement)}
href={href}
{...rest}
@@ -248,11 +250,11 @@ export const Tab = (props: TabProps) => {
*/
export const TabPanel = (props: TabPanelProps) => {
const { classNames, cleanedProps } = useStyles('Tabs', props);
const { children, ...rest } = cleanedProps;
const { className, children, ...rest } = cleanedProps;
return (
<AriaTabPanel
className={clsx(classNames.panel, styles[classNames.panel])}
className={clsx(classNames.panel, styles[classNames.panel], className)}
{...rest}
>
{children}
@@ -22,7 +22,7 @@ import type { FieldLabelProps } from '../FieldLabel/types';
/** @public */
export interface TextFieldProps
extends AriaTextFieldProps,
Omit<FieldLabelProps, 'htmlFor' | 'id'> {
Omit<FieldLabelProps, 'htmlFor' | 'id' | 'className'> {
/**
* The HTML input type for the text field
*