diff --git a/pages/button-dropdown/permutations.page.tsx b/pages/button-dropdown/permutations.page.tsx index 2f0dff5b83..fc1483a5b6 100644 --- a/pages/button-dropdown/permutations.page.tsx +++ b/pages/button-dropdown/permutations.page.tsx @@ -24,6 +24,19 @@ const permutations = createPermutations([ ariaLabel: ['Button dropdown'], fullWidth: [false, true], }, + // Custom trigger icon + { + items: [[]], + variant: ['icon', 'inline-icon'], + ariaLabel: ['Button dropdown'], + iconName: [undefined, 'settings'], + iconSvg: [ + undefined, + + + , + ], + }, ]); export default function () { diff --git a/pages/button-group/item-permutations.page.tsx b/pages/button-group/item-permutations.page.tsx index 8ef0145460..6b97e1c9c1 100644 --- a/pages/button-group/item-permutations.page.tsx +++ b/pages/button-group/item-permutations.page.tsx @@ -124,6 +124,28 @@ const menuDropdownPermutations = createPermutations + + , + ], + items: [ + [ + { + id: 'cut', + iconName: 'delete-marker', + text: 'Cut', + }, + ], + ], + }, ]); export default function () { diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b0666e38ef..09c5b8aae0 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -6113,6 +6113,172 @@ because fixed positioning results in a slight, visible lag when scrolling comple "optional": true, "type": "boolean", }, + { + "description": "Specifies alternate text for a custom icon, for use with \`iconUrl\`. Applies to the \`icon\` and \`inline-icon\` variants only.", + "name": "iconAlt", + "optional": true, + "type": "string", + }, + { + "description": "Specifies the name of the icon used in the button dropdown trigger, used with the [icon component](/components/icon/). +Defaults to \`ellipsis\`. Applies to the \`icon\` and \`inline-icon\` variants only.", + "inlineType": { + "name": "IconProps.Name", + "type": "union", + "values": [ + "search", + "map", + "filter", + "key", + "file", + "pause", + "play", + "microphone", + "remove", + "copy", + "menu", + "script", + "close", + "status-pending", + "refresh", + "external", + "history", + "group", + "calendar", + "ellipsis", + "zoom-in", + "zoom-out", + "dot", + "security", + "download", + "edit", + "add-plus", + "anchor-link", + "angle-left-double", + "angle-left", + "angle-right-double", + "angle-right", + "angle-up", + "angle-down", + "announcement", + "arrow-left", + "arrow-right", + "arrow-up", + "arrow-down", + "at-symbol", + "audio-full", + "audio-half", + "audio-off", + "backward-10-seconds", + "bug", + "call", + "caret-down-filled", + "caret-down", + "caret-left-filled", + "caret-right-filled", + "caret-up-filled", + "caret-up", + "check", + "contact", + "closed-caption", + "closed-caption-unavailable", + "command-prompt", + "delete-marker", + "drag-indicator", + "edit-gen-ai", + "envelope", + "exit-full-screen", + "expand", + "face-happy", + "face-happy-filled", + "face-neutral", + "face-neutral-filled", + "face-sad", + "face-sad-filled", + "file-open", + "flag", + "folder-open", + "folder", + "forward-10-seconds", + "full-screen", + "gen-ai", + "globe", + "grid-view", + "group-active", + "heart", + "heart-filled", + "insert-row", + "keyboard", + "light-dark", + "list-view", + "location-pin", + "lock-private", + "microphone-off", + "mini-player", + "multiscreen", + "notification", + "redo", + "resize-area", + "search-gen-ai", + "settings", + "send", + "share", + "shrink", + "slash", + "slash-divider", + "star-filled", + "star-half", + "star", + "status-in-progress", + "status-info", + "status-negative", + "status-not-started", + "status-positive", + "status-stopped", + "status-warning", + "stop-circle", + "subtract-minus", + "suggestions", + "suggestions-gen-ai", + "support", + "thumbs-down-filled", + "thumbs-down", + "thumbs-up-filled", + "thumbs-up", + "ticket", + "transcript", + "treeview-collapse", + "treeview-expand", + "undo", + "unlocked", + "upload-download", + "upload", + "user-profile-active", + "user-profile", + "video-off", + "video-on", + "video-unavailable", + "video-camera-off", + "video-camera-on", + "video-camera-unavailable", + "view-full", + "view-horizontal", + "view-vertical", + "zoom-to-fit", + ], + }, + "name": "iconName", + "optional": true, + "type": "string", + }, + { + "description": "Specifies the URL of a custom icon. Applies to the \`icon\` and \`inline-icon\` variants only. + +If you set both \`iconUrl\` and \`iconSvg\`, \`iconSvg\` will take precedence.", + "name": "iconUrl", + "optional": true, + "type": "string", + }, { "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must @@ -6625,6 +6791,14 @@ When returning \`null\`, the default styling will be applied.", "isDefault": true, "name": "children", }, + { + "description": "Custom SVG icon. Equivalent to the \`svg\` slot of the [icon component](/components/icon/). +Applies to the \`icon\` and \`inline-icon\` variants only. + +If you set both \`iconUrl\` and \`iconSvg\`, \`iconSvg\` will take precedence.", + "isDefault": false, + "name": "iconSvg", + }, ], "releaseStatus": "stable", } @@ -6789,6 +6963,10 @@ use the \`id\` attribute, consider setting it on a parent element instead.", * \`disabledReason\` (optional, boolean) - Provides a reason why the button is disabled (only when \`disabled\` is \`true\`). If provided, the button becomes focusable. * \`loading\` (optional, boolean) - The loading state indication for the menu button. * \`loadingText\` (optional, string) - The loading text announced to screen readers. +* \`iconName\` (optional, string) - Specifies the name of the icon, used with the [icon component](/components/icon/). Defaults to \`ellipsis\`. +* \`iconAlt\` (optional, string) - Specifies alternate text for the icon when using \`iconUrl\`. +* \`iconUrl\` (optional, string) - Specifies the URL of a custom icon. +* \`iconSvg\` (optional, ReactNode) - Custom SVG icon. Equivalent to the \`svg\` slot of the [icon component](/components/icon/). * \`items\` (ButtonDropdownProps.ItemOrGroup[]) - The array of dropdown items that belong to this menu. ### group diff --git a/src/button-dropdown/__tests__/button-dropdown.test.tsx b/src/button-dropdown/__tests__/button-dropdown.test.tsx index 3f10e4debd..62236fdd0b 100644 --- a/src/button-dropdown/__tests__/button-dropdown.test.tsx +++ b/src/button-dropdown/__tests__/button-dropdown.test.tsx @@ -9,6 +9,7 @@ import ButtonDropdown, { ButtonDropdownProps } from '../../../lib/components/but import InternalButtonDropdown from '../../../lib/components/button-dropdown/internal'; import { KeyCode } from '../../../lib/components/internal/keycode'; import createWrapper, { ButtonWrapper } from '../../../lib/components/test-utils/dom'; +import { getIconHTML } from '../../icon/__tests__/utils'; import iconStyles from '../../../lib/components/icon/styles.css.js'; import liveRegionStyles from '../../../lib/components/live-region/test-classes/styles.css.js'; @@ -539,3 +540,65 @@ describe('native attributes', () => { expect(wrapper.findMainAction()?.getElement()).toHaveAttribute('aria-describedby', 'my-custom-description'); }); }); + +describe('custom trigger icon', () => { + (['icon', 'inline-icon'] as Array).forEach(variant => { + describe(`"${variant}" variant`, () => { + test('renders ellipsis icon by default', () => { + const trigger = renderButtonDropdown({ items, variant, ariaLabel: 'Actions' }).findNativeButton(); + const icon = trigger.findIcon()!.find('svg')!; + + expect(icon.getElement()).toContainHTML(getIconHTML('ellipsis')); + }); + + test('renders custom icon with iconName', () => { + const trigger = renderButtonDropdown({ + items, + variant, + ariaLabel: 'Actions', + iconName: 'settings', + }).findNativeButton(); + const icon = trigger.findIcon()!.find('svg')!; + + expect(icon.getElement()).toContainHTML(getIconHTML('settings')); + }); + + test('renders custom icon with iconUrl', () => { + const iconUrl = 'data:image/png;base64,aaaa'; + const wrapper = renderButtonDropdown({ items, variant, ariaLabel: 'Actions', iconUrl, iconAlt: 'Custom icon' }); + + const icon = wrapper.findNativeButton().find('img')!.getElement(); + expect(icon).toHaveAttribute('src', iconUrl); + expect(icon).toHaveAttribute('alt', 'Custom icon'); + }); + + test('renders custom icon with iconSvg', () => { + const iconSvg = ( + + + + ); + const wrapper = renderButtonDropdown({ items, variant, ariaLabel: 'Actions', iconSvg }); + + expect(wrapper.findNativeButton().find('.custom-trigger-icon')).toBeTruthy(); + }); + + test('does not warn when custom icon is provided', () => { + renderButtonDropdown({ items, variant, ariaLabel: 'Actions', iconName: 'settings' }); + expect(warnOnce).not.toHaveBeenCalled(); + }); + }); + }); + + (['normal', 'primary'] as Array).forEach(variant => { + test(`warns and ignores custom icon for "${variant}" variant`, () => { + const wrapper = renderButtonDropdown({ items, variant, children: 'Actions', iconName: 'settings' }); + + expect(warnOnce).toHaveBeenCalledWith( + 'ButtonDropdown', + 'Custom icon is only supported for "icon" and "inline-icon" component variant.' + ); + expect(wrapper.findNativeButton().getElement()).toHaveTextContent('Actions'); + }); + }); +}); diff --git a/src/button-dropdown/index.tsx b/src/button-dropdown/index.tsx index 9a0663afbd..b5a840039d 100644 --- a/src/button-dropdown/index.tsx +++ b/src/button-dropdown/index.tsx @@ -29,6 +29,10 @@ const ButtonDropdown = React.forwardRef( expandableGroups = false, expandToViewport = false, ariaLabel, + iconName, + iconAlt, + iconUrl, + iconSvg, children, onItemClick, onItemFollow, @@ -42,10 +46,11 @@ const ButtonDropdown = React.forwardRef( ref: React.Ref ) => { const baseComponentProps = useBaseComponent('ButtonDropdown', { - props: { expandToViewport, expandableGroups, variant }, + props: { expandToViewport, expandableGroups, variant, iconName }, metadata: { mainAction: !!mainAction, checkboxItems: hasCheckboxItems(items), + hasCustomIcon: Boolean(iconUrl || iconSvg || iconName), hasDisabledReason: Boolean(disabledReason), hasDisabledReasons: hasDisabledReasonItems(items), }, @@ -73,6 +78,10 @@ const ButtonDropdown = React.forwardRef( expandableGroups={expandableGroups} expandToViewport={expandToViewport} ariaLabel={ariaLabel} + iconName={iconName} + iconAlt={iconAlt} + iconUrl={iconUrl} + iconSvg={iconSvg} onItemClick={onItemClick} onItemFollow={onItemFollow} mainAction={mainAction} diff --git a/src/button-dropdown/interfaces.ts b/src/button-dropdown/interfaces.ts index 952acb2529..096041a77e 100644 --- a/src/button-dropdown/interfaces.ts +++ b/src/button-dropdown/interfaces.ts @@ -131,6 +131,28 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor * * `inline-icon` for icon buttons with no outer padding */ variant?: ButtonDropdownProps.Variant; + /** + * Specifies the name of the icon used in the button dropdown trigger, used with the [icon component](/components/icon/). + * Defaults to `ellipsis`. Applies to the `icon` and `inline-icon` variants only. + */ + iconName?: IconProps.Name; + /** + * Specifies alternate text for a custom icon, for use with `iconUrl`. Applies to the `icon` and `inline-icon` variants only. + */ + iconAlt?: string; + /** + * Specifies the URL of a custom icon. Applies to the `icon` and `inline-icon` variants only. + * + * If you set both `iconUrl` and `iconSvg`, `iconSvg` will take precedence. + */ + iconUrl?: string; + /** + * Custom SVG icon. Equivalent to the `svg` slot of the [icon component](/components/icon/). + * Applies to the `icon` and `inline-icon` variants only. + * + * If you set both `iconUrl` and `iconSvg`, `iconSvg` will take precedence. + */ + iconSvg?: React.ReactNode; /** * Controls expandability of the item groups. */ diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index b89326e3c7..ead6b58ec8 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -47,6 +47,10 @@ const InternalButtonDropdown = React.forwardRef( customTriggerBuilder, expandToViewport, ariaLabel, + iconName, + iconAlt, + iconUrl, + iconSvg, title, description, preferCenter, @@ -75,10 +79,15 @@ const InternalButtonDropdown = React.forwardRef( checkSafeUrl('ButtonDropdown', mainAction.href); } + const hasCustomTriggerIcon = !!(iconName || iconUrl || iconSvg); + if (isDevelopment) { if (mainAction && variant !== 'primary' && variant !== 'normal') { warnOnce('ButtonDropdown', 'Main action is only supported for "primary" and "normal" component variant.'); } + if (hasCustomTriggerIcon && variant !== 'icon' && variant !== 'inline-icon') { + warnOnce('ButtonDropdown', 'Custom icon is only supported for "icon" and "inline-icon" component variant.'); + } } const hasMainAction = mainAction && (variant === 'primary' || variant === 'normal'); const isVisualRefresh = useVisualRefresh(); @@ -146,7 +155,10 @@ const InternalButtonDropdown = React.forwardRef( const iconProps: Partial = variant === 'icon' || variant === 'inline-icon' ? { - iconName: 'ellipsis', + iconName: hasCustomTriggerIcon ? iconName : 'ellipsis', + iconAlt, + iconUrl, + iconSvg, } : { iconName: isOneTheme ? 'angle-down' : 'caret-down-filled', diff --git a/src/button-group/__tests__/button-group.test.tsx b/src/button-group/__tests__/button-group.test.tsx index e40e03ea5e..476992cd55 100644 --- a/src/button-group/__tests__/button-group.test.tsx +++ b/src/button-group/__tests__/button-group.test.tsx @@ -5,6 +5,7 @@ import { render } from '@testing-library/react'; import ButtonGroup, { ButtonGroupProps } from '../../../lib/components/button-group'; import createWrapper from '../../../lib/components/test-utils/dom'; +import { getIconHTML } from '../../icon/__tests__/utils'; import customCssProps from '../../internal/generated/custom-css-properties'; function renderButtonGroup(props: ButtonGroupProps) { @@ -165,3 +166,52 @@ test('icon-toggle-button maintains correct icon on state change', () => { expect(wrapper.findToggleButtonById('test-toggle-state')!.find('.default-icon')).toBeFalsy(); expect(wrapper.findToggleButtonById('test-toggle-state')!.find('.pressed-icon')).toBeTruthy(); }); + +describe('menu-dropdown custom icon', () => { + const menuDropdown: ButtonGroupProps.MenuDropdown = { + type: 'menu-dropdown', + id: 'menu', + text: 'More actions', + items: [{ id: 'one', text: 'One' }], + }; + + function renderMenuTrigger(item: ButtonGroupProps.MenuDropdown) { + const { container } = render(); + return createWrapper(container).findButtonGroup()!.findMenuById('menu')!.findTriggerButton()!; + } + + test('renders ellipsis icon by default', () => { + const trigger = renderMenuTrigger(menuDropdown); + const icon = trigger.find('svg')!; + + expect(icon.getElement()).toContainHTML(getIconHTML('ellipsis')); + }); + + test('renders custom icon with iconName', () => { + const trigger = renderMenuTrigger({ ...menuDropdown, iconName: 'settings' }); + const icon = trigger.find('svg')!; + + expect(icon.getElement()).toContainHTML(getIconHTML('settings')); + }); + + test('renders custom icon with iconUrl', () => { + const iconUrl = 'data:image/png;base64,aaaa'; + const trigger = renderMenuTrigger({ ...menuDropdown, iconUrl, iconAlt: 'Custom icon' }); + + expect(trigger.find('img')!.getElement()).toHaveAttribute('src', iconUrl); + expect(trigger.find('img')!.getElement()).toHaveAttribute('alt', 'Custom icon'); + }); + + test('renders custom icon with iconSvg', () => { + const trigger = renderMenuTrigger({ + ...menuDropdown, + iconSvg: ( + + + + ), + }); + + expect(trigger.find('.custom-menu-icon')).toBeTruthy(); + }); +}); diff --git a/src/button-group/interfaces.ts b/src/button-group/interfaces.ts index 26adb23551..01fe77ca7e 100644 --- a/src/button-group/interfaces.ts +++ b/src/button-group/interfaces.ts @@ -83,6 +83,10 @@ export interface ButtonGroupProps extends BaseComponentProps { * * `disabledReason` (optional, boolean) - Provides a reason why the button is disabled (only when `disabled` is `true`). If provided, the button becomes focusable. * * `loading` (optional, boolean) - The loading state indication for the menu button. * * `loadingText` (optional, string) - The loading text announced to screen readers. + * * `iconName` (optional, string) - Specifies the name of the icon, used with the [icon component](/components/icon/). Defaults to `ellipsis`. + * * `iconAlt` (optional, string) - Specifies alternate text for the icon when using `iconUrl`. + * * `iconUrl` (optional, string) - Specifies the URL of a custom icon. + * * `iconSvg` (optional, ReactNode) - Custom SVG icon. Equivalent to the `svg` slot of the [icon component](/components/icon/). * * `items` (ButtonDropdownProps.ItemOrGroup[]) - The array of dropdown items that belong to this menu. * * ### group @@ -125,12 +129,15 @@ export interface IconToggleButtonRuntime iconSvg?: string; pressedIconSvg?: string; } +export interface MenuDropdownRuntime extends Omit { + iconSvg?: string; +} export type ItemOrGroupRuntime = ItemRuntime | ButtonGroupProps.Group; export type ItemRuntime = | IconButtonRuntime | IconToggleButtonRuntime | ButtonGroupProps.IconFileInput - | ButtonGroupProps.MenuDropdown; + | MenuDropdownRuntime; export type InternalItemOrGroup = InternalItem | ButtonGroupProps.Group; export type InternalItem = @@ -202,6 +209,10 @@ export namespace ButtonGroupProps { disabledReason?: string; loading?: boolean; loadingText?: string; + iconName?: IconProps.Name; + iconAlt?: string; + iconUrl?: string; + iconSvg?: React.ReactNode; items: ReadonlyArray; } diff --git a/src/button-group/menu-dropdown-item.tsx b/src/button-group/menu-dropdown-item.tsx index 05ae5757cc..af3ffcd152 100644 --- a/src/button-group/menu-dropdown-item.tsx +++ b/src/button-group/menu-dropdown-item.tsx @@ -29,6 +29,7 @@ const MenuDropdownItem = React.forwardRef( ref: React.Ref ) => { const containerRef = React.useRef(null); + const hasCustomIcon = item.iconName || item.iconUrl || item.iconSvg; const onClickHandler = (event: CustomEvent) => { fireCancelableEvent(onItemClick, { id: event.detail.id, checked: event.detail.checked }, event); }; @@ -62,7 +63,10 @@ const MenuDropdownItem = React.forwardRef( data-itemid={item.id} ariaExpanded={ariaExpanded} className={clsx(testUtilStyles.item, testUtilsClass)} - iconName="ellipsis" + iconName={hasCustomIcon ? item.iconName : 'ellipsis'} + iconAlt={item.iconAlt} + iconUrl={item.iconUrl} + iconSvg={item.iconSvg} loading={item.loading} loadingText={item.loadingText} disabled={item.disabled}