import { LitElement, html, css, nothing } from 'lit';
import { property, state, query } from 'lit/decorators.js';
import { AgButton } from '../../Button/core/Button.js';
import type { ButtonProps } from '../../Button/core/Button.js';
// Event type definitions
export interface MenuOpenEventDetail {
open: boolean;
}
export type MenuOpenEvent = CustomEvent;
export interface MenuCloseEventDetail {
open: boolean;
}
export type MenuCloseEvent = CustomEvent;
export interface MenuSelectEventDetail {
value: string;
}
export type MenuSelectEvent = CustomEvent;
// Extend ButtonProps and add menu-specific props.
// We rename `variant` from ButtonProps to `buttonVariant` to avoid conflict
// with the structural `variant` prop of the MenuButton.
export interface MenuButtonProps extends Omit {
menuVariant?: 'chevron' | 'button' | 'icon';
// We bring back `variant` from ButtonProps but rename it to `buttonVariant`
// to clearly distinguish it as the color/style variant for the button part.
buttonVariant?: ButtonProps['variant'];
// Unicode character for icon variant
unicode?: string;
menuAlign?: 'left' | 'right';
// Additional vertical spacing beyond the trigger button's height when positioning the menu
additionalGutter?: string;
// Menu-specific event handlers
onMenuOpen?: (event: MenuOpenEvent) => void;
onMenuClose?: (event: MenuCloseEvent) => void;
// We omit some ButtonProps that are controlled by the menu logic
// e.g. `type`, `toggle`, `pressed`, `onClick` etc. are handled internally.
}
export interface MenuItemProps {
value?: string;
disabled?: boolean;
href?: string;
target?: string;
checked?: boolean;
variant?: 'default' | 'monochrome';
onClick?: (event: MouseEvent) => void;
onMenuSelect?: (event: MenuSelectEvent) => void;
}
export interface MenuProps {
open?: boolean;
placement?: string;
ariaLabel?: string;
selectedValue?: string;
type?: 'default' | 'single-select';
checkHiddenItems?: boolean;
onKeyDown?: (event: KeyboardEvent) => void;
}
export class AgMenuButton extends LitElement implements MenuButtonProps {
@query('ag-button')
declare _trigger: AgButton;
declare _menu: AgMenu | null;
declare _clickOutsideHandler: (event: Event) => void;
// PROPS FROM ButtonProps
@property({ type: String, reflect: true })
declare size: 'x-sm' | 'sm' | 'md' | 'lg' | 'xl';
@property({ type: String, reflect: true })
declare shape: 'capsule' | 'rounded' | 'circle' | 'square' | 'rounded-square' | '';
@property({ type: Boolean, reflect: true })
declare bordered: boolean;
@property({ type: Boolean, reflect: true })
declare ghost: boolean;
@property({ type: Boolean, reflect: true })
declare link: boolean;
@property({ type: Boolean, reflect: true })
declare grouped: boolean;
@property({ type: Boolean })
declare disabled: boolean;
@property({ type: Boolean })
declare loading: boolean;
@property({ type: String, reflect: true, attribute: 'aria-label' })
declare ariaLabel: string;
// MENU-SPECIFIC PROPS
@property({ type: String, reflect: true, attribute: 'menu-variant' })
declare menuVariant: 'chevron' | 'button' | 'icon';
@property({ type: String, reflect: true, attribute: 'button-variant' })
declare buttonVariant: 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'monochrome' | '';
@property({ type: String })
declare unicode: string;
@property({ type: String, reflect: true, attribute: 'menu-align' })
declare menuAlign: 'left' | 'right';
@property({ type: String, reflect: true, attribute: 'additional-gutter' })
declare additionalGutter: string;
@property({ attribute: false })
declare onMenuOpen?: (event: MenuOpenEvent) => void;
@property({ attribute: false })
declare onMenuClose?: (event: MenuCloseEvent) => void;
@property({ attribute: false })
declare onClick?: (event: MouseEvent) => void;
@property({ attribute: false })
declare onFocus?: (event: FocusEvent) => void;
@property({ attribute: false })
declare onBlur?: (event: FocusEvent) => void;
@state()
declare _menuOpen: boolean;
constructor() {
super();
// Defaults from ButtonProps
this.size = 'md';
this.shape = 'rounded';
this.bordered = false;
this.ghost = false;
this.link = false;
this.grouped = false;
this.disabled = false;
this.loading = false;
this.ariaLabel = '';
// Defaults for MenuButton
this.menuVariant = 'chevron';
this.buttonVariant = '';
this.unicode = '';
this._menuOpen = false;
this.menuAlign = 'left';
this.additionalGutter = '';
this._clickOutsideHandler = this._handleClickOutside.bind(this);
}
static styles = css`
:host {
display: inline-flex;
position: relative;
background-color: inherit;
}
.chevron-icon {
width: var(--ag-space-4);
height: var(--ag-space-4);
color: var(--ag-text-secondary);
transition: transform var(--ag-motion-fast) ease;
flex-shrink: 0;
}
:host([button-variant="primary"]) .chevron-icon,
:host([button-variant="secondary"]) .chevron-icon,
:host([button-variant="success"]) .chevron-icon,
:host([button-variant="warning"]) .chevron-icon,
:host([button-variant="danger"]) .chevron-icon,
:host([button-variant="monochrome"]) .chevron-icon {
color: var(--ag-white);
}
/* When a button is ghost, we want the chevron to be the variant color */
:host([ghost][button-variant="primary"]) .chevron-icon {
color: var(--ag-primary);
}
:host([ghost][button-variant="secondary"]) .chevron-icon {
color: var(--ag-secondary);
}
:host([ghost][button-variant="success"]) .chevron-icon {
color: var(--ag-success);
}
:host([ghost][button-variant="warning"]) .chevron-icon {
color: var(--ag-warning);
}
:host([ghost][button-variant="danger"]) .chevron-icon {
color: var(--ag-danger);
}
:host([ghost][button-variant="monochrome"]) .chevron-icon {
color: var(--ag-text-primary);
}
.label {
display: inline-flex;
align-items: center;
gap: var(--ag-space-2);
}
ag-button[aria-expanded="true"] .chevron-icon {
transform: rotate(180deg);
}
::slotted(ag-menu) {
position: absolute;
background-color: var(--ag-background-primary);
margin-block-start: var(--ag-space-1);
z-index: var(--ag-z-index-dropdown);
}
/* Left alignment - menu left aligns with button left */
:host([menu-align="left"]) ::slotted(ag-menu) {
inset-inline-start: 0;
inset-inline-end: auto;
}
/* Right alignment - menu right aligns with button right */
:host([menu-align="right"]) ::slotted(ag-menu) {
inset-inline-end: 0;
inset-inline-start: auto;
}
`;
connectedCallback() {
super.connectedCallback();
document.addEventListener('click', this._clickOutsideHandler);
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('click', this._clickOutsideHandler);
}
firstUpdated() {
this._updateMenuReference();
}
updated(changedProperties: Map) {
super.updated(changedProperties);
if (changedProperties.has('_menuOpen')) {
this._updateMenuReference();
}
}
private _updateMenuReference() {
this._menu = this.querySelector('ag-menu') as AgMenu;
}
private _handleClickOutside(event: Event) {
if (!this._menuOpen) return;
const composedPath = event.composedPath();
// Check if the click is within this component (including shadow DOM)
if (composedPath.includes(this)) {
return;
}
this._closeMenu();
}
private _handleClick(event: MouseEvent) {
if (this.disabled) return;
// Invoke onClick callback if provided
if (this.onClick) {
this.onClick(event);
}
// Re-dispatch click event from host for addEventListener pattern
const clickEvent = new MouseEvent('click', {
bubbles: true,
composed: true,
cancelable: event.cancelable,
});
this.dispatchEvent(clickEvent);
if (this._menuOpen) {
this._closeMenu();
} else {
this._openMenu();
}
}
private _handleFocus(event: FocusEvent) {
// Invoke onFocus callback if provided
if (this.onFocus) {
this.onFocus(event);
}
// Re-dispatch focus event from host for addEventListener pattern
const focusEvent = new FocusEvent('focus', {
bubbles: true,
composed: true,
});
this.dispatchEvent(focusEvent);
}
private _handleBlur(event: FocusEvent) {
// Invoke onBlur callback if provided
if (this.onBlur) {
this.onBlur(event);
}
// Re-dispatch blur event from host for addEventListener pattern
const blurEvent = new FocusEvent('blur', {
bubbles: true,
composed: true,
});
this.dispatchEvent(blurEvent);
}
private _handleKeydown(event: KeyboardEvent) {
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault();
this._openMenu();
break;
case 'ArrowDown':
event.preventDefault();
this._openMenu(true);
break;
case 'ArrowUp':
event.preventDefault();
this._openMenu(false);
break;
}
}
// Add method to update menu position based on button height:
private _updateMenuPosition() {
if (!this._menu || !this._trigger) return;
const triggerButton = this._trigger.shadowRoot?.querySelector('button');
if (!triggerButton) return;
const buttonHeight = triggerButton.offsetHeight;
// Position menu below button, optionally adding extra gutter
if (this.additionalGutter) {
this._menu.style.top = `calc(${buttonHeight}px + ${this.additionalGutter})`;
} else {
this._menu.style.top = `${buttonHeight}px`;
}
}
_openMenu(focusFirst = true) {
if (this._menuOpen) return;
this._menuOpen = true;
this._updateMenuReference();
if (this._menu) {
this._menu.open = true;
// Update position before showing
this._updateMenuPosition();
this._menu._updateMenuItems();
requestAnimationFrame(() => {
if (focusFirst) {
this._menu?._focusFirstItem();
} else {
this._menu?._focusLastItem();
}
});
}
const menuOpenEvent = new CustomEvent('menu-open', {
detail: { open: true },
bubbles: true,
composed: true,
});
this.dispatchEvent(menuOpenEvent);
if (this.onMenuOpen) this.onMenuOpen(menuOpenEvent);
}
_closeMenu() {
if (!this._menuOpen) return;
this._menuOpen = false;
if (this._menu) {
this._menu.open = false;
}
this._trigger?.focus();
const menuCloseEvent = new CustomEvent('menu-close', {
detail: { open: false },
bubbles: true,
composed: true,
});
this.dispatchEvent(menuCloseEvent);
if (this.onMenuClose) this.onMenuClose(menuCloseEvent);
}
private _renderChevronIcon() {
return html`
`;
}
render() {
// For dynamic icon switching, we expose the menu state via a data attribute
// that consumers can use in their slot content
this.setAttribute('data-menu-open', this._menuOpen ? 'true' : 'false');
const content = this.unicode
? html`${this.unicode}`
: html``;
const isIconOnly = this.menuVariant === 'icon';
return html`
${this.menuVariant === 'chevron'
? html`${content} ${this._renderChevronIcon()}`
: content}
`;
}
}
export class AgMenu extends LitElement implements MenuProps {
@property({ type: Boolean })
declare open: boolean;
@property()
declare placement: string;
@property({ reflect: true, attribute: 'aria-label' })
declare ariaLabel: string;
@property({ attribute: 'selected-value' })
declare selectedValue?: string;
@property({ type: String })
declare type: 'default' | 'single-select';
/*
// Opt in checking for hidden items e.g. Tailwind responsive utilities
Always visible
*/
@property({ type: Boolean, attribute: 'check-hidden-items' })
declare checkHiddenItems: boolean;
@property({ attribute: false })
declare onKeyDown?: (event: KeyboardEvent) => void;
@state()
declare _focusedIndex: number;
@state()
declare _menuItems: AgMenuItem[];
constructor() {
super();
this.open = false;
this.placement = 'bottom-start';
this.ariaLabel = '';
this.type = 'default';
this.checkHiddenItems = false;
this._focusedIndex = 0;
this._menuItems = [];
}
static styles = css`
:host {
position: absolute;
background-color: var(--ag-background-primary);
border: 1px solid var(--ag-border-subtle);
border-radius: var(--ag-radius-md);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
margin-block-start: var(--ag-space-1);
min-inline-size: 12rem;
max-inline-size: 16rem;
width: max-content;
z-index: var(--ag-z-index-dropdown);
overflow: hidden;
text-overflow: ellipsis;
}
.menu {
padding: var(--ag-space-2);
}
:host([hidden]) {
display: none;
}
`;
connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'menu');
this.setAttribute('aria-orientation', 'vertical');
this.addEventListener('keydown', this._handleKeydown);
this.addEventListener('menu-select', this._handleMenuSelect as EventListener);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('keydown', this._handleKeydown);
this.removeEventListener('menu-select', this._handleMenuSelect as EventListener);
}
firstUpdated() {
// Ensure menu is hidden by default on first render
if (!this.open) {
this.setAttribute('hidden', '');
}
}
updated(changedProperties: Map) {
if (changedProperties.has('open')) {
if (this.open) {
this.removeAttribute('hidden');
} else {
// Always set hidden when open is false, regardless of how it was set
this.setAttribute('hidden', '');
// Clear selection for default (navigation) menus when closing
if (this.type === 'default') {
this.selectedValue = undefined;
this._updateSelection();
}
}
}
if (changedProperties.has('selectedValue')) {
this._updateSelection();
}
}
private _handleMenuSelect(event: CustomEvent) {
// Handle selection tracking for this menu
// Note: We allow the event to bubble up to the menuButton
// so it can also handle the selection event
const selectedItem = event.target as AgMenuItem;
// Only persist selection for single-select menus
// For default (navigation) menus, selection is transient
if (this.type === 'single-select') {
this.selectedValue = selectedItem.value;
}
}
private _updateSelection() {
this._menuItems.forEach(item => {
// Clear all selections if selectedValue is undefined
// Otherwise, mark the matching item as checked
item.checked = this.selectedValue !== undefined && item.value === this.selectedValue;
});
}
/**
* Check if a menu item is actually navigable based on its computed styles.
* This handles Tailwind's responsive utilities (sm:hidden, md:hidden, etc.)
* which are applied to wrapper divs around menu items.
*
* Only called when checkHiddenItems prop is true.
*/
private _isElementNavigable(element: AgMenuItem): boolean {
// Respect explicit disabled/aria-hidden attributes (always checked)
if (element.disabled || element.getAttribute('aria-hidden') === 'true') {
return false;
}
// Skip visibility checks if not enabled
if (!this.checkHiddenItems) {
return true;
}
// Check the element itself
const elementStyle = window.getComputedStyle(element);
if (elementStyle.display === 'none' || elementStyle.visibility === 'hidden') {
return false;
}
// Check all parent elements up to (but not including) the menu
// This catches wrapper divs with Tailwind responsive utilities like sm:hidden
let parent = element.parentElement;
while (parent && parent !== this) {
const parentStyle = window.getComputedStyle(parent);
if (parentStyle.display === 'none' || parentStyle.visibility === 'hidden') {
return false;
}
parent = parent.parentElement;
}
return true;
}
/**
* Get currently navigable items (filtered for visibility).
* When checkHiddenItems is true, recalculates on every call to handle dynamic viewport changes.
*/
private _getNavigableItems(): AgMenuItem[] {
// Fast path: if not checking hidden items, return all items that aren't disabled/aria-hidden
if (!this.checkHiddenItems) {
return this._menuItems.filter(item =>
!item.disabled && item.getAttribute('aria-hidden') !== 'true'
);
}
// When checking hidden items, always recalculate to handle viewport/style changes
return this._menuItems.filter(item => this._isElementNavigable(item));
}
_updateMenuItems() {
// Store all menu items
this._menuItems = Array.from(this.querySelectorAll('ag-menu-item')) as AgMenuItem[];
this._updateTabIndex();
this._updateSelection();
}
private _updateTabIndex() {
this._menuItems.forEach((item, index) => {
item.setAttribute('tabindex', index === this._focusedIndex ? '0' : '-1');
});
}
_focusFirstItem() {
const navigableItems = this._getNavigableItems();
if (navigableItems.length === 0) return;
const firstItem = navigableItems[0];
this._focusedIndex = this._menuItems.indexOf(firstItem);
this._updateTabIndex();
firstItem?.focus();
}
_focusLastItem() {
const navigableItems = this._getNavigableItems();
if (navigableItems.length === 0) return;
const lastItem = navigableItems[navigableItems.length - 1];
this._focusedIndex = this._menuItems.indexOf(lastItem);
this._updateTabIndex();
lastItem?.focus();
}
private _focusNextItem() {
const navigableItems = this._getNavigableItems();
if (navigableItems.length === 0) return;
// Find current item in navigable list
const currentItem = this._menuItems[this._focusedIndex];
const currentNavIndex = navigableItems.indexOf(currentItem);
// If current item is not navigable (e.g., became hidden), start from beginning
const startIndex = currentNavIndex >= 0 ? currentNavIndex : -1;
// Find next navigable item (wraps around)
for (let i = 1; i <= navigableItems.length; i++) {
const nextIndex = (startIndex + i) % navigableItems.length;
const item = navigableItems[nextIndex];
this._focusedIndex = this._menuItems.indexOf(item);
this._updateTabIndex();
item?.focus();
return;
}
}
private _focusPreviousItem() {
const navigableItems = this._getNavigableItems();
if (navigableItems.length === 0) return;
const currentItem = this._menuItems[this._focusedIndex];
const currentNavIndex = navigableItems.indexOf(currentItem);
// If current item is not navigable, start from end
const startIndex = currentNavIndex >= 0 ? currentNavIndex : navigableItems.length;
// Find previous navigable item (wraps around)
for (let i = 1; i <= navigableItems.length; i++) {
const prevIndex = (startIndex - i + navigableItems.length) % navigableItems.length;
const item = navigableItems[prevIndex];
this._focusedIndex = this._menuItems.indexOf(item);
this._updateTabIndex();
item?.focus();
return;
}
}
private _handleKeydown(event: KeyboardEvent) {
// Invoke callback if provided (native composed event)
if (this.onKeyDown) {
this.onKeyDown(event);
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this._focusNextItem();
break;
case 'ArrowUp':
event.preventDefault();
this._focusPreviousItem();
break;
case 'Home':
event.preventDefault();
this._focusFirstItem();
break;
case 'End':
event.preventDefault();
this._focusLastItem();
break;
case 'Enter': {
event.preventDefault();
// Get currently navigable items and activate the focused one if it's navigable
const navigableItems = this._getNavigableItems();
const currentItem = this._menuItems[this._focusedIndex];
if (navigableItems.includes(currentItem)) {
currentItem?.click();
}
break;
}
case 'Escape':
case 'Tab':
event.preventDefault();
this._closeMenu();
break;
}
}
private _closeMenu() {
const menuButton = this.closest('ag-menu-button') as AgMenuButton;
if (menuButton) {
menuButton._closeMenu();
}
}
render() {
return html``;
}
}
export class AgMenuItem extends LitElement implements MenuItemProps {
@property()
declare value: string;
@property({ type: Boolean })
declare disabled: boolean;
@property()
declare href: string;
@property()
declare target: string;
@property({ type: Boolean })
declare checked: boolean;
@property({ type: String, reflect: true })
declare variant: 'default' | 'monochrome';
@property({ attribute: false })
declare onClick?: (event: MouseEvent) => void;
@property({ attribute: false })
declare onMenuSelect?: (event: MenuSelectEvent) => void;
private _menu: AgMenu | null = null;
constructor() {
super();
this.value = '';
this.disabled = false;
this.href = '';
this.target = '';
this.checked = false;
this.variant = 'default';
}
static styles = css`
:host {
display: block;
}
button {
width: 100%;
}
button,
a {
display: block;
background-color: transparent;
color: var(--ag-text-primary);
border: none;
border-radius: var(--ag-radius-sm);
padding: var(--ag-space-2) var(--ag-space-3);
text-align: start;
text-decoration: none;
font-size: inherit;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition: all var(--ag-motion-fast) ease;
}
button:hover:not([disabled]),
a:hover:not([disabled]) {
background-color: var(--ag-background-secondary);
color: var(--ag-text-primary);
}
button:focus,
a:focus {
background-color: var(--ag-background-secondary);
outline: var(--ag-focus-width) solid rgba(var(--ag-focus), 0.5);
outline-offset: 0;
}
button:active:not([disabled]),
a:active:not([disabled]) {
background-color: var(--ag-background-tertiary);
}
:host([aria-checked='true']) button:not([disabled]),
:host([aria-checked='true']) a:not([disabled]) {
background-color: var(--ag-menu-item-selected-bg, var(--ag-primary));
color: var(--ag-white);
}
:host([aria-checked='true']) button:focus:not([disabled]),
:host([aria-checked='true']) a:focus:not([disabled]),
:host([aria-checked='true']) button:hover:not([disabled]),
:host([aria-checked='true']) a:hover:not([disabled]) {
background-color: var(--ag-menu-item-selected-active-bg, var(--ag-primary-dark));
}
:host([variant="monochrome"][aria-checked='true']) button:not([disabled]),
:host([variant="monochrome"][aria-checked='true']) a:not([disabled]) {
background-color: var(--ag-background-primary-inverted);
color: var(--ag-text-primary-inverted);
}
/* hover/active + selected gets --ag-background-secondary-inverted affordance */
:host([variant="monochrome"][aria-checked='true']) button:focus:not([disabled]),
:host([variant="monochrome"][aria-checked='true']) a:focus:not([disabled]),
:host([variant="monochrome"][aria-checked='true']) button:hover:not([disabled]),
:host([variant="monochrome"][aria-checked='true']) a:hover:not([disabled]) {
background-color: var(--ag-background-secondary-inverted);
}
button[disabled],
a[disabled] {
background-color: transparent;
color: var(--ag-text-muted);
cursor: not-allowed;
opacity: 0.6;
}
`;
connectedCallback() {
super.connectedCallback();
this._menu = this.closest('ag-menu');
const role = this._menu?.type === 'single-select' ? 'menuitemradio' : 'menuitem';
this.setAttribute('role', role);
this.setAttribute('tabindex', '-1');
this.addEventListener('click', this._handleClick);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('click', this._handleClick);
}
updated(changedProperties: Map) {
if (changedProperties.has('checked')) {
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
}
}
private _handleClick(event: Event) {
if (this.disabled) {
event.preventDefault();
event.stopPropagation();
return;
}
// Invoke onClick callback if provided (native composed event)
if (this.onClick && event instanceof MouseEvent) {
this.onClick(event);
}
const menuButton = this.closest('ag-menu-button') as AgMenuButton;
// Dual-dispatch pattern for custom event
const menuSelectEvent = new CustomEvent('menu-select', {
detail: { value: this.value },
bubbles: true,
composed: true
});
this.dispatchEvent(menuSelectEvent);
// Invoke callback if provided
if (this.onMenuSelect) {
this.onMenuSelect(menuSelectEvent);
}
if (menuButton && !this.href) {
menuButton._closeMenu();
}
}
focus() {
const element = this.shadowRoot?.querySelector('button, a') as HTMLElement;
if (element) {
element.focus();
}
}
render() {
if (this.href) {
return html`
`;
}
return html`
`;
}
}
export class AgMenuSeparator extends LitElement {
static styles = css`
:host {
display: block;
}
.separator {
border-top: 1px solid var(--ag-border);
margin: var(--ag-space-2) 0;
}
`;
connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'separator');
}
render() {
return html``;
}
}