Added support for custom icon/prefix in panel menu extensions.

This commit is contained in:
Marcus Andersson
2026-01-14 13:40:00 +01:00
parent 999b24d2a0
commit cc8505f4e0
8 changed files with 78 additions and 7 deletions
+1
View File
@@ -188,6 +188,7 @@ export interface PanelOptionsEditorConfig<TOptions, TSettings = any, TValue = an
export interface PanelMenuItem { export interface PanelMenuItem {
type?: 'submenu' | 'divider' | 'group'; type?: 'submenu' | 'divider' | 'group';
text: string; text: string;
prefix?: React.ReactElement;
iconClassName?: IconName; iconClassName?: IconName;
onClick?: (event: React.MouseEvent) => void; onClick?: (event: React.MouseEvent) => void;
shortcut?: string; shortcut?: string;
@@ -28,7 +28,9 @@ export type PluginExtensionLink = PluginExtensionBase & {
type: PluginExtensionTypes.link; type: PluginExtensionTypes.link;
path?: string; path?: string;
onClick?: (event?: React.MouseEvent) => void; onClick?: (event?: React.MouseEvent) => void;
icon?: IconName | React.ReactNode; icon?: IconName;
suffix?: React.ReactElement;
prefix?: React.ReactElement;
category?: string; category?: string;
openInNewTab?: boolean; openInNewTab?: boolean;
}; };
@@ -106,7 +108,9 @@ export type PluginAddedLinksConfigureFunc<Context extends object> = (context: Re
description: string; description: string;
path: string; path: string;
onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void; onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void;
icon: IconName | React.ReactNode; icon: IconName;
suffix: React.ReactElement;
prefix: React.ReactElement;
category: string; category: string;
openInNewTab: boolean; openInNewTab: boolean;
}> }>
@@ -136,7 +140,7 @@ export type PluginExtensionAddedLinkConfig<Context extends object = object> = Pl
configure?: PluginAddedLinksConfigureFunc<Context>; configure?: PluginAddedLinksConfigureFunc<Context>;
// (Optional) A icon that can be displayed in the ui for the extension option. // (Optional) A icon that can be displayed in the ui for the extension option.
icon?: IconName | React.ReactNode; icon?: IconName;
// (Optional) A category to be used when grouping the options in the ui // (Optional) A category to be used when grouping the options in the ui
category?: string; category?: string;
@@ -144,6 +148,12 @@ export type PluginExtensionAddedLinkConfig<Context extends object = object> = Pl
// (Optional) If true, opens the link in a new tab (renders with target="_blank") // (Optional) If true, opens the link in a new tab (renders with target="_blank")
// (Important: this is not guaranteed, depends on the extension point if it implements it.) // (Important: this is not guaranteed, depends on the extension point if it implements it.)
openInNewTab?: boolean; openInNewTab?: boolean;
// (Optional) A React element that will be displayed after the title
suffix?: React.ReactElement;
// (Optional) A React element that will be displayed before the title
prefix?: React.ReactElement;
}; };
export type PluginExtensionExposedComponentConfig<Props = {}> = PluginExtensionConfigBase & { export type PluginExtensionExposedComponentConfig<Props = {}> = PluginExtensionConfigBase & {
@@ -11,6 +11,7 @@ import { IconName } from '../../types/icon';
import { Icon } from '../Icon/Icon'; import { Icon } from '../Icon/Icon';
import { Stack } from '../Layout/Stack/Stack'; import { Stack } from '../Layout/Stack/Stack';
import { MenuItemPrefix } from './MenuItemPrefix';
import { SubMenu } from './SubMenu'; import { SubMenu } from './SubMenu';
/** @internal */ /** @internal */
@@ -30,6 +31,8 @@ export interface MenuItemProps<T = unknown> {
target?: LinkTarget; target?: LinkTarget;
/** Icon of the menu item */ /** Icon of the menu item */
icon?: IconName; icon?: IconName;
/** Prefix of the menu item if icon is specified prefix will be ignored */
prefix?: React.ReactElement;
/** Role of the menu item */ /** Role of the menu item */
role?: AriaRole; role?: AriaRole;
/** Url of the menu item */ /** Url of the menu item */
@@ -79,6 +82,7 @@ export const MenuItem = React.memo(
customSubMenuContainerStyles, customSubMenuContainerStyles,
shortcut, shortcut,
testId, testId,
prefix,
} = props; } = props;
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const [isActive, setIsActive] = useState(active); const [isActive, setIsActive] = useState(active);
@@ -176,7 +180,7 @@ export const MenuItem = React.memo(
{...disabledProps} {...disabledProps}
> >
<Stack direction="row" justifyContent="flex-start" alignItems="center"> <Stack direction="row" justifyContent="flex-start" alignItems="center">
{icon && <Icon name={icon} className={styles.icon} aria-hidden />} <MenuItemPrefix icon={icon} prefix={prefix} />
<span className={cx(styles.ellipsis, styles.label)}>{label}</span> <span className={cx(styles.ellipsis, styles.label)}>{label}</span>
<div className={cx(styles.rightWrapper, { [styles.withShortcut]: hasShortcut })}> <div className={cx(styles.rightWrapper, { [styles.withShortcut]: hasShortcut })}>
{hasShortcut && ( {hasShortcut && (
@@ -272,9 +276,6 @@ const getStyles = (theme: GrafanaTheme2) => {
color: theme.colors.action.disabledText, color: theme.colors.action.disabledText,
}, },
}), }),
icon: css({
opacity: 0.7,
}),
rightWrapper: css({ rightWrapper: css({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -0,0 +1,48 @@
import { css } from '@emotion/css';
import { IconName } from '@grafana/data';
import { useStyles2 } from '../../themes/ThemeContext';
import { Icon } from '../Icon/Icon';
import { getSvgSize } from '../Icon/utils';
type MenuItemPrefixProps = {
prefix?: React.ReactElement;
icon?: IconName;
};
/** @internal */
export function MenuItemPrefix({ prefix, icon }: MenuItemPrefixProps): React.ReactNode {
const styles = useStyles2(getStyles);
if (!icon && !prefix) {
return null;
}
if (icon) {
return <Icon name={icon} className={styles.icon} aria-hidden />;
}
return (
<div className={styles.prefix} aria-hidden>
{prefix}
</div>
);
}
function getStyles() {
const prefixSize = getSvgSize('md');
return {
icon: css({
opacity: 0.7,
}),
prefix: css({
display: 'inline-block',
verticalAlign: 'middle',
width: prefixSize,
height: prefixSize,
overflow: 'hidden',
}),
};
}
@@ -29,6 +29,7 @@ export function PanelHeaderMenu({ items }: Props) {
key={item.text} key={item.text}
label={item.text} label={item.text}
icon={item.iconClassName} icon={item.iconClassName}
prefix={item.prefix}
childItems={item.subMenu ? renderItems(item.subMenu) : undefined} childItems={item.subMenu ? renderItems(item.subMenu) : undefined}
url={item.href} url={item.href}
onClick={item.onClick} onClick={item.onClick}
@@ -20,6 +20,8 @@ export type AddedLinkRegistryItem<Context extends object = object> = {
onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void; onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void;
configure?: PluginAddedLinksConfigureFunc<Context>; configure?: PluginAddedLinksConfigureFunc<Context>;
icon?: IconName; icon?: IconName;
suffix?: React.ReactElement;
prefix?: React.ReactElement;
category?: string; category?: string;
openInNewTab?: boolean; openInNewTab?: boolean;
}; };
@@ -86,6 +86,8 @@ export function usePluginLinks({
path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionPointId) : undefined, path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionPointId) : undefined,
category: overrides?.category || addedLink.category, category: overrides?.category || addedLink.category,
openInNewTab: overrides?.openInNewTab ?? addedLink.openInNewTab, openInNewTab: overrides?.openInNewTab ?? addedLink.openInNewTab,
suffix: overrides?.suffix || addedLink.suffix,
prefix: overrides?.prefix || addedLink.prefix,
}; };
extensions.push(extension); extensions.push(extension);
@@ -421,6 +421,7 @@ export function createExtensionSubMenu(extensions: PluginExtensionLink[]): Panel
onClick: extension.onClick, onClick: extension.onClick,
iconClassName: extension.icon, iconClassName: extension.icon,
target: extension.openInNewTab ? '_blank' : undefined, target: extension.openInNewTab ? '_blank' : undefined,
prefix: extension.prefix,
}); });
continue; continue;
} }
@@ -435,6 +436,7 @@ export function createExtensionSubMenu(extensions: PluginExtensionLink[]): Panel
onClick: extension.onClick, onClick: extension.onClick,
iconClassName: extension.icon, iconClassName: extension.icon,
target: extension.openInNewTab ? '_blank' : undefined, target: extension.openInNewTab ? '_blank' : undefined,
prefix: extension.prefix,
}); });
} }
@@ -484,6 +486,8 @@ export function getLinkExtensionOverrides(
icon = config.icon, icon = config.icon,
category = config.category, category = config.category,
openInNewTab = config.openInNewTab, openInNewTab = config.openInNewTab,
suffix = config.suffix,
prefix = config.prefix,
...rest ...rest
} = overrides; } = overrides;
@@ -509,6 +513,8 @@ export function getLinkExtensionOverrides(
icon, icon,
category, category,
openInNewTab, openInNewTab,
suffix,
prefix,
}; };
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {