import {
ComponentPropsWithRef,
JSXElementConstructor,
ReactElement,
ReactNode,
RefObject,
useEffect,
useRef,
useState,
} from 'react';
import {
FloatingNode,
FloatingPortal,
FloatingTree,
type Placement,
useFloatingParentNodeId,
} from '@floating-ui/react';
import {
HeadingItemType,
isHeadingItemType,
isLinkItemType,
isNodeItemType,
isSeparatorItemType,
LinkItemType,
MenuItem,
MenuItemType,
NodeItemType,
PDSIcon,
SeparatorItemType,
Tally,
TallyProps,
useDropdown,
useIsMobile,
} from '@pantheon-systems/pds-toolkit-react';
import { Button } from '../Button/Button';
import {
ButtonVariant,
extractText,
mergeClasses,
StatusDotType,
} from '../Button/utils';
import { Icon } from '../Icon/Icon';
import { IconButton } from '../IconButton/IconButton';
import { NavAccordion } from './NavAccordion';
import './menu-button.css';
type MenuButtonSize = 'xs' | 's' | 'm';
/**
* Prop types for MenuButton
*/
export interface MenuButtonProps extends ComponentPropsWithRef<'span'> {
/**
* Additional class names
*/
className?: string;
/**
* Is the button disabled?
*/
disabled?: boolean;
/**
* Icon name for icon-only mode.
*/
iconName?: PDSIcon;
/**
* Render as an icon-only button (no label, no angle-down). Not compatible with `tally` or `statusDot`.
*/
iconOnly?: boolean;
/**
* ID value for this component.
*/
id: string;
/**
* Is the trigger agentic? Gives the trigger the AI gradient border, like
* `isAgentic` on `Button`. Only valid on the `primary` and `secondary`
* variants; in `iconOnly` mode it also turns the icon button's border on.
*/
isAgentic?: boolean;
/**
* Opt in to swapping the trigger + floating dropdown panel for an inline
* accordion at the mobile breakpoint. Defaults to `false` — MenuButton
* always renders its normal trigger + dropdown otherwise, regardless of
* viewport. Set this explicitly when placing MenuButton inside a mobile
* nav menu that provides the accordion's expected layout (e.g. `Navbar`'s
* own mobile menu) — typically `isMobileAccordion={useIsMobile()}`, using
* the `useIsMobile` hook, so the swap only takes effect at the mobile
* breakpoint.
*/
isMobileAccordion?: boolean;
/**
* Will this be used for a split button?
*/
isSplitButton?: boolean;
/**
* The text of the button
*/
label: ReactNode;
/**
* Array of menu items
*/
menuItems: Array<
| HeadingItemType
| LinkItemType
| MenuItemType
| NodeItemType
| SeparatorItemType
>;
/**
* Optional callback function for when the button is clicked or activated by the keyboard.
*/
onClick?: () => void;
/**
* Optional callback fired whenever the menu's open state changes. Use
* this to manage focus deliberately when this MenuButton is nested
* inside another floating panel — e.g. return focus to the row that
* owns this menu when it closes, rather than leaving it to Floating UI's
* default (which can otherwise leave focus on an ancestor panel).
*/
onOpenChange?: (isOpen: boolean) => void;
/**
* Preferred placement of the dropdown panel relative to its anchor.
* Defaults to `bottom-start` (current behavior). `SplitButton` passes
* this through unchanged — see its own `placement` prop.
*/
placement?: Placement;
/**
* Ref to an ancestor element that Floating UI should use as the
* *position* reference (while the trigger button remains the
* *interaction* reference for click/keyboard). Set internally by
* `SplitButton` when `isSplitButton` is true, so the panel anchors to
* the whole split-button control instead of just the caret half. Not
* intended for direct use outside `SplitButton`.
*/
positionReferenceRef?: RefObject<HTMLElement | null>;
/**
* Which size of button to render.
*/
size?: MenuButtonSize;
/**
* A status dot displayed in the left icon slot. Not available in `iconOnly` mode.
*/
statusDot?: StatusDotType;
/**
* A tally badge displayed between the label and the angle-down icon. Not available in `iconOnly` mode.
*/
tally?: {
label: TallyProps['label'];
type: TallyProps['type'];
};
/**
* Optional `data-testid` value for this component.
*/
testId?: string;
/**
* Translatable string for description of unlabeled group of menu items.
*/
undefinedLabel?: string;
/**
* Which variant of button to render. Not available in `iconOnly` mode.
* `navbar` is visually identical to `subtle` except it has no background
* at rest — only on hover/expanded — for triggers that sit directly on a
* nav/toolbar surface rather than a plain page background. `reverse-secondary`
* is for triggers on a dark/reverse surface — passed straight through to
* the underlying `Button`, which already supports it.
*/
variant?:
| Extract<
ButtonVariant,
'primary' | 'secondary' | 'subtle' | 'reverse-secondary'
>
| 'navbar';
}
/**
* MenuButton UI component.
*
* Wraps the actual implementation in a Floating UI tree boundary: when
* nested inside another floating panel (e.g. a `Dropdown`'s panel), it joins
* that panel's tree so outside-clicks and Escape inside this menu resolve to
* this (innermost) menu without wrongly dismissing the parent. When not
* nested, it provides its own single-node tree — no consumer opt-in needed.
*/
export const MenuButton = (props: MenuButtonProps) => {
const parentId = useFloatingParentNodeId();
if (parentId === null) {
return (
<FloatingTree>
<MenuButtonImpl {...props} />
</FloatingTree>
);
}
return <MenuButtonImpl {...props} />;
};
const MenuButtonImpl = ({
className,
disabled,
iconName = 'ellipsisVertical',
iconOnly = false,
id,
isAgentic = false,
isMobileAccordion = false,
isSplitButton = false,
label,
menuItems,
onClick,
onOpenChange,
placement = 'bottom-start',
positionReferenceRef,
size = 's',
statusDot,
tally,
testId,
undefinedLabel = 'Unlabeled group',
variant = 'primary',
...props
}: MenuButtonProps) => {
// Shared dropdown hook.
const {
activeIndex,
floatingStyles,
getFloatingProps,
getItemProps,
getReferenceProps,
isMounted,
isOpen,
listRef,
nodeId,
refs,
setIsOpen,
transitionStyles,
} = useDropdown({
enableClick: !disabled,
enableTypeahead: true,
offsetValue: 8,
placement,
role: 'menu',
});
// v2 raises this above Modals/Drawers when nested in one (`useOverlayZIndex`,
// not exported by the package). Proposals have no overlay context.
const zIndex = 'var(--pds-z-index-dropdown)';
// Anchors panel position to the split-button root; refs.setReference below still owns click/keyboard interaction.
useEffect(() => {
if (isSplitButton && positionReferenceRef?.current) {
refs.setPositionReference(positionReferenceRef.current);
}
}, [isSplitButton, positionReferenceRef, refs]);
// Notify the consumer whenever open state changes, so it can manage
// focus deliberately (e.g. when nested inside another floating panel).
// Skips the initial mount — `isOpen` starts `false`, and this effect
// would otherwise fire an unwanted "closed" notification on every mount,
// not just on an actual open-to-closed transition.
const hasMountedRef = useRef(false);
useEffect(() => {
if (!hasMountedRef.current) {
hasMountedRef.current = true;
return;
}
onOpenChange?.(isOpen);
}, [isOpen]);
// Only the active responsive variant is mounted (see render), so per-item ids
// exist once rather than in both the mobile accordion and desktop dropdown.
// `isMobileAccordion` opts in to the accordion swap at all — off by default,
// MenuButton always renders its normal trigger + dropdown regardless of
// viewport, so `data-pds-mobile` has no effect unless a consumer (typically
// Navbar, injecting this automatically) explicitly opts in.
const isMobile = useIsMobile();
const showMobileAccordion = isMobileAccordion && isMobile;
// Set up classes.
const baseClass = 'pds-menu-button';
// `navbar` renders as the underlying Button's `subtle` variant (same
// hover/active/foreground tokens) with an additional modifier class that
// resets the rest-state background to transparent.
const isNavbar = variant === 'navbar';
const buttonVariant = isNavbar ? 'subtle' : variant;
// IDs.
const triggerID = id;
const [listboxID] = useState(`menu-button-${id}`);
// Tracking for grouped items.
const groupIDs: string[] = [];
// Flatten menu items to get only focusable items (for index mapping).
const focusableItems: MenuItem[] = [];
const flattenItems = (items: MenuItem[]) => {
items.forEach((item) => {
if (
!isSeparatorItemType(item) &&
!isHeadingItemType(item) &&
!isNodeItemType(item)
) {
focusableItems.push(item);
}
});
};
flattenItems(menuItems);
// Handle menu item activation.
const activateItem = (item: MenuItem) => {
if ('disabled' in item && item.disabled) return;
if ('callback' in item && item.callback) {
item.callback(item);
}
setIsOpen(false);
};
// Handle trigger click callback.
const handleTriggerClick = () => {
if (onClick) {
onClick();
}
};
// Function to render each menu item for the Floating UI dropdown.
const renderItem = (
item: MenuItem,
flatIndex: number,
groupID: string = '',
groupHeadingID: string = '',
) => {
const groupIDString = groupID ? `-group-${groupID}` : '';
const fallbackItemID =
triggerID + groupIDString + '-item-' + (flatIndex + 1);
const itemID = 'id' in item && item.id ? item.id : fallbackItemID;
if (isSeparatorItemType(item)) {
return (
<li key={itemID} className='pds-dropdown__separator' role='separator' />
);
}
if (isHeadingItemType(item)) {
return (
<li
key={groupHeadingID || itemID}
className={`pds-dropdown__heading ${baseClass}__listbox-heading`}
id={groupHeadingID || undefined}
role='presentation'
>
{item.label}
</li>
);
}
if (isNodeItemType(item)) {
return (
<li key={itemID} role='presentation'>
{item.nodeContent}
</li>
);
}
if (isLinkItemType(item) || ('label' in item && item.label)) {
const focusableIndex = focusableItems.indexOf(item);
const isFocused = activeIndex === focusableIndex;
const isCritical = 'isCritical' in item && item.isCritical;
const criticalLabel =
'criticalLabel' in item ? item.criticalLabel : undefined;
const itemLabel: ReactNode[] = [];
if ('label' in item && item.label) {
itemLabel.push(item.label);
}
if (isLinkItemType(item) && item.linkContent) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const linkElement = item.linkContent as ReactElement<any>;
itemLabel.push(linkElement.props.children);
}
if (item.iconName) {
itemLabel.unshift(
<Icon
key={`icon-${triggerID}`}
iconName={item.iconName}
size={item.iconSize ?? 'xs'}
/>,
);
}
if (isCritical && criticalLabel) {
itemLabel.unshift(
<span key={`critical-${triggerID}`} className='visually-hidden'>
{criticalLabel}
</span>,
);
}
const itemDescription = item.description ? (
<span className='pds-dropdown__item-description'>
{item.description}
</span>
) : null;
let itemContent: ReactNode;
if (isLinkItemType(item) && item.linkContent) {
const linkElement = item.linkContent as ReactElement<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
JSXElementConstructor<any>
>;
const LinkElement = linkElement.type;
itemContent = (
<LinkElement
tabIndex={-1}
{...linkElement.props}
onClick={() => setIsOpen(false)}
>
<span className='pds-dropdown__item-label'>
<span className={`${baseClass}__item-label`}>{itemLabel}</span>
{itemDescription}
</span>
</LinkElement>
);
} else {
itemContent = (
<span className='pds-dropdown__item-label'>
<span className={`${baseClass}__item-label`}>{itemLabel}</span>
{itemDescription}
</span>
);
}
// `isSelected` is an extension to the v2 item type (see decisions.md):
// when set (true or false) the item is a `menuitemradio` and a check
// trails the label on the selected one. Unselected items keep a hidden
// check so the panel width doesn't change with the selection.
const isSelected = (item as { isSelected?: boolean }).isSelected;
const isCheckable = isSelected !== undefined;
const menuItemClasses = mergeClasses([
'pds-dropdown__item',
isFocused ? 'pds-dropdown__item--focused' : null,
isCritical ? 'pds-dropdown__item--critical' : null,
]);
return (
<li
key={itemID}
ref={(node) => {
listRef.current[focusableIndex] = node;
}}
aria-checked={isCheckable ? isSelected : undefined}
aria-disabled={item.disabled || undefined}
className={menuItemClasses}
data-testid={'testId' in item ? item.testId : undefined}
id={itemID}
role={isCheckable ? 'menuitemradio' : 'menuitem'}
tabIndex={isFocused ? 0 : -1}
{...getItemProps({
onClick: () => {
if (!isLinkItemType(item)) {
activateItem(item);
}
setIsOpen(false);
},
onKeyDown: (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (isLinkItemType(item) && item.linkContent) {
const link = (e.currentTarget as HTMLElement).querySelector(
'a',
) as HTMLAnchorElement | null;
link?.click();
} else {
activateItem(item);
}
}
},
})}
>
{itemContent}
{isCheckable && (
<Icon
className={mergeClasses([
`${baseClass}__item-check`,
isSelected ? null : `${baseClass}__item-check--hidden`,
])}
iconName='check'
size='s'
/>
)}
</li>
);
}
};
// Simplified item renderer for the mobile accordion — no Floating UI bindings.
const renderAccordionItem = (item: MenuItem, index: number) => {
const itemID =
'id' in item && item.id
? item.id
: `${triggerID}-accordion-item-${index + 1}`;
if (isSeparatorItemType(item)) {
return (
<li key={itemID} className='pds-dropdown__separator' role='separator' />
);
}
if (isHeadingItemType(item)) {
return (
<li
key={itemID}
className={`pds-dropdown__heading ${baseClass}__listbox-heading`}
role='presentation'
>
{item.label}
</li>
);
}
if (isNodeItemType(item)) {
return (
<li key={itemID} role='presentation'>
{item.nodeContent}
</li>
);
}
if (isLinkItemType(item) || ('label' in item && item.label)) {
const isCritical = 'isCritical' in item && item.isCritical;
const criticalLabel =
'criticalLabel' in item ? item.criticalLabel : undefined;
const itemLabel: ReactNode[] = [];
if ('label' in item && item.label) {
itemLabel.push(item.label);
}
if (isLinkItemType(item) && item.linkContent) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const linkElement = item.linkContent as ReactElement<any>;
itemLabel.push(linkElement.props.children);
}
if (item.iconName) {
itemLabel.unshift(
<Icon
key={`icon-${triggerID}-${index}`}
iconName={item.iconName}
size={item.iconSize ?? 'xs'}
/>,
);
}
if (isCritical && criticalLabel) {
itemLabel.unshift(
<span
key={`critical-${triggerID}-${index}`}
className='visually-hidden'
>
{criticalLabel}
</span>,
);
}
const itemDescription = item.description ? (
<span className='pds-dropdown__item-description'>
{item.description}
</span>
) : null;
let itemContent: ReactNode;
if (isLinkItemType(item) && item.linkContent) {
const linkElement = item.linkContent as ReactElement<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
JSXElementConstructor<any>
>;
const LinkElement = linkElement.type;
itemContent = (
<LinkElement tabIndex={-1} {...linkElement.props}>
<span className='pds-dropdown__item-label'>
<span className={`${baseClass}__item-label`}>{itemLabel}</span>
{itemDescription}
</span>
</LinkElement>
);
} else {
itemContent = (
<span className='pds-dropdown__item-label'>
<span className={`${baseClass}__item-label`}>{itemLabel}</span>
{itemDescription}
</span>
);
}
return (
<li
key={itemID}
aria-disabled={item.disabled || undefined}
className={mergeClasses([
'pds-dropdown__item',
isCritical ? 'pds-dropdown__item--critical' : null,
])}
id={itemID}
onClick={() => {
if (!isLinkItemType(item)) {
activateItem(item);
}
}}
role='menuitem'
tabIndex={item.disabled ? -1 : 0}
>
{itemContent}
</li>
);
}
};
// Render menu items with group support (Floating UI dropdown).
const renderMenuItems = (items: MenuItem[]) => {
const menuItemBreaks = items.filter(
isHeadingItemType || isSeparatorItemType,
);
const haveBreaks = menuItemBreaks.length > 0;
const listboxClasses = mergeClasses([
'pds-dropdown__panel',
`${baseClass}__listbox`,
iconOnly ? `${baseClass}__listbox--icon-only` : null,
]);
let flatIndex = 0;
if (haveBreaks) {
const groupedItems = renderMenuItemsGrouped(items, menuItemBreaks);
groupedItems.forEach((_, index) => {
groupIDs.push(`${index}`);
});
return (
<div
ref={refs.setFloating}
aria-labelledby={triggerID}
className={listboxClasses}
id={listboxID}
role='menu'
style={{
...floatingStyles,
...transitionStyles,
zIndex,
}}
{...getFloatingProps()}
>
{groupedItems.map((group: MenuItem[], groupIndex: number) => {
const currentGroupID = groupIDs[groupIndex];
const hasHeading = 'isHeading' in group[0] && group[0].isHeading;
const groupHeadingID = hasHeading
? `${triggerID}-group-${currentGroupID}-heading`
: '';
const keyID = `${triggerID}-group-${currentGroupID}`;
const groupLabel = hasHeading
? undefined
: `${undefinedLabel} ${groupIndex + 1}`;
return (
<ul
key={keyID}
aria-label={groupLabel}
aria-labelledby={groupHeadingID || undefined}
role='group'
>
{group.map((item: MenuItem) => {
const rendered = renderItem(
item,
flatIndex,
currentGroupID,
groupHeadingID,
);
if (
!isSeparatorItemType(item) &&
!isHeadingItemType(item) &&
!isNodeItemType(item)
) {
flatIndex++;
}
return rendered;
})}
</ul>
);
})}
</div>
);
} else {
return (
<ul
ref={refs.setFloating}
aria-labelledby={triggerID}
className={listboxClasses}
id={listboxID}
role='menu'
style={{
...floatingStyles,
...transitionStyles,
zIndex,
}}
{...getFloatingProps()}
>
{items.map((item: MenuItem) => {
const rendered = renderItem(item, flatIndex);
if (
!isSeparatorItemType(item) &&
!isHeadingItemType(item) &&
!isNodeItemType(item)
) {
flatIndex++;
}
return rendered;
})}
</ul>
);
}
};
// Group items at separator/heading boundaries.
const renderMenuItemsGrouped = (
items: MenuItem[],
menuItemBreaks: MenuItem[],
) => {
const groupedItems: MenuItem[][] = [];
const breaks = [...menuItemBreaks];
let breakItem = breaks.shift();
let group: MenuItem[] = [];
items.forEach((item: MenuItem, index: number) => {
if (item !== breakItem) {
group.push(item);
if (index + 1 === items.length) {
groupedItems.push(group);
}
} else {
groupedItems.push(group);
group = [];
group.push(item);
breakItem = breaks.shift();
}
});
return groupedItems.filter((group) => group.length > 0);
};
// Floating UI trigger button.
let triggerButton: ReactElement | undefined;
// Tally size scaled to button size — 'm' gets the larger tally, 'xs'/'s'
// both get the smallest since Tally has no size below 'xs'.
const tallySize = size === 'm' ? 's' : 'xs';
// Label for the desktop trigger — tally sits between label text and angle-down.
const triggerLabelWithTally = tally ? (
<>
{label}
<Tally
className='pds-button__tally'
label={tally.label}
size={tallySize}
type={tally.type}
/>
</>
) : (
label
);
// Label for the mobile accordion — wrapped in a span to keep text and tally
// together as one flex item, and uses a larger tally size to match the mobile text size.
const accordionLabelWithTally = tally ? (
<span className={`${baseClass}__accordion-label`}>
{label}
<Tally
className={`${baseClass}__tally`}
label={tally.label}
size='s'
type={tally.type}
/>
</span>
) : (
label
);
if (!iconOnly) {
triggerButton = (
<Button
ref={refs.setReference}
aria-controls={listboxID}
aria-expanded={isOpen}
aria-haspopup='true'
className={mergeClasses([
`${baseClass}__trigger`,
isNavbar ? `${baseClass}__trigger--navbar` : null,
])}
disabled={disabled}
displayType='icon-end'
iconName='angleDown'
id={triggerID}
isAgentic={isAgentic}
isSplitButton={isSplitButton}
label={triggerLabelWithTally}
size={size}
statusDot={statusDot}
type='button'
variant={buttonVariant}
{...getReferenceProps({
onClick: handleTriggerClick,
})}
/>
);
}
if (iconOnly) {
const labelText = extractText(label);
triggerButton = (
<IconButton
ref={refs.setReference}
aria-controls={listboxID}
aria-expanded={isOpen}
aria-haspopup='true'
ariaLabel={labelText}
className={`${baseClass}__trigger ${baseClass}__trigger--icon-only`}
disabled={disabled}
hasBorder={isAgentic}
hasTooltip={false}
iconName={iconName}
id={triggerID}
isAgentic={isAgentic}
size={size}
type='button'
variant='standard'
{...getReferenceProps({
onClick: handleTriggerClick,
})}
/>
);
}
// Label content for the mobile accordion toggle.
const accordionLabel = iconOnly ? (
<span className={`${baseClass}__icon-label`}>
<Icon iconName={iconName} /> {label}
</span>
) : (
accordionLabelWithTally
);
// Set up expanded class.
const expandedClass = isOpen ? `${baseClass}--expanded` : null;
// Render only the active responsive variant so per-item ids exist once. The
// mobile presentation is an inline accordion; the desktop presentation is a
// Floating UI dropdown. Only mounts the accordion when `isMobileAccordion`
// is set — otherwise always renders the normal trigger + dropdown.
return (
<span
className={mergeClasses([baseClass, expandedClass, className])}
data-testid={testId}
{...props}
>
{showMobileAccordion ? (
<NavAccordion disabled={disabled} label={accordionLabel}>
<ul className={`${baseClass}__mobile-menu`} role='menu'>
{menuItems.map((item, index) => renderAccordionItem(item, index))}
</ul>
</NavAccordion>
) : (
<>
{triggerButton}
<FloatingNode id={nodeId}>
{isMounted && (
<FloatingPortal>{renderMenuItems(menuItems)}</FloatingPortal>
)}
</FloatingNode>
</>
)}
</span>
);
};
Menu Button
Starting point for a MenuButton proposal.
Extends a PDS componentOpen in Storybook