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:
@@ -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.
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user