shadimage

Menubar

Horizontal menu bar for Tile UI.

packages/react/src/components/menubar/menubar.tsx

import React, { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { getMenubarCheckState, getMenubarPosition, getMenubarState, menubarStyleKeys } from '../lib/core';
import type {
	MenubarBaseProps,
	MenubarCheckboxItemBaseProps,
	MenubarContentBaseProps,
	MenubarItemBaseProps,
	MenubarLabelBaseProps,
	MenubarMenuBaseProps,
	MenubarPosition,
	MenubarRadioGroupBaseProps,
	MenubarRadioItemBaseProps,
	MenubarSubBaseProps,
	MenubarSubTriggerBaseProps,
	MenubarTriggerBaseProps,
} from '../lib/core';
import { PortalProvider, usePortalContainer, type PortalContainer } from '../portal';
import styles from './menubar.module.scss';

interface MenubarContextValue {
	activeValue: string | undefined;
	setActiveValue: (value: string | undefined) => void;
}

const MenubarContext = createContext<MenubarContextValue | null>(null);

function useMenubarContext(): MenubarContextValue {
	const context = useContext(MenubarContext);
	if (!context) {
		throw new Error('Menubar 子组件必须位于 <Menubar> 内部。');
	}
	return context;
}

interface MenubarMenuContextValue {
	value: string;
	open: boolean;
	triggerRef: React.RefObject<HTMLElement | null>;
	contentId: string;
	setOpen: (open: boolean) => void;
}

const MenubarMenuContext = createContext<MenubarMenuContextValue | null>(null);

function useMenubarMenuContext(): MenubarMenuContextValue {
	const context = useContext(MenubarMenuContext);
	if (!context) {
		throw new Error('Menubar 菜单子组件必须位于 <MenubarMenu> 内部。');
	}
	return context;
}

interface MenubarContentContextValue {
	itemsRef: React.RefObject<HTMLElement[]>;
	close: () => void;
}

const MenubarContentContext = createContext<MenubarContentContextValue | null>(null);

function useMenubarContentContext(): MenubarContentContextValue {
	const context = useContext(MenubarContentContext);
	if (!context) {
		throw new Error('Menubar 菜单项必须位于 <MenubarContent> 或 <MenubarSubContent> 内部。');
	}
	return context;
}

interface MenubarSubContextValue {
	open: boolean;
	setOpen: (open: boolean) => void;
	triggerRef: React.RefObject<HTMLElement | null>;
}

const MenubarSubContext = createContext<MenubarSubContextValue | null>(null);

function useMenubarSubContext(): MenubarSubContextValue {
	const context = useContext(MenubarSubContext);
	if (!context) {
		throw new Error('Menubar 子菜单组件必须位于 <MenubarSub> 内部。');
	}
	return context;
}

interface MenubarRadioGroupContextValue {
	value: string | undefined;
	setValue: (value: string) => void;
}

const MenubarRadioGroupContext = createContext<MenubarRadioGroupContextValue | null>(null);

function useMenubarRadioGroupContext(): MenubarRadioGroupContextValue {
	const context = useContext(MenubarRadioGroupContext);
	if (!context) {
		throw new Error('MenubarRadioItem 必须位于 <MenubarRadioGroup> 内部。');
	}
	return context;
}

function MenubarCheckIcon() {
	return (
		<svg
			className={styles[menubarStyleKeys.checkIcon]}
			xmlns="http://www.w3.org/2000/svg"
			width="16"
			height="16"
			viewBox="0 0 24 24"
			fill="none"
			stroke="currentColor"
			strokeWidth="2"
			strokeLinecap="round"
			strokeLinejoin="round"
			aria-hidden="true">
			<path d="M20 6 9 17l-5-5" />
		</svg>
	);
}

function MenubarRadioIcon() {
	return (
		<svg className={styles[menubarStyleKeys.radioIcon]} xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
			<circle cx="12" cy="12" r="6" />
		</svg>
	);
}

function MenubarChevronIcon() {
	return (
		<svg
			className={styles[menubarStyleKeys.chevron]}
			xmlns="http://www.w3.org/2000/svg"
			width="16"
			height="16"
			viewBox="0 0 24 24"
			fill="none"
			stroke="currentColor"
			strokeWidth="2"
			strokeLinecap="round"
			strokeLinejoin="round"
			aria-hidden="true">
			<path d="m9 18 6-6-6-6" />
		</svg>
	);
}

export interface MenubarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'defaultValue'>, MenubarBaseProps {}

const Menubar = React.forwardRef<HTMLDivElement, MenubarProps>(({ className = '', value, defaultValue, onValueChange, children, ...props }, ref) => {
	const [internalValue, setInternalValue] = useState(defaultValue);
	const activeValue = value !== undefined ? value : internalValue;

	const setActiveValue = useCallback(
		(next: string | undefined) => {
			if (value === undefined) {
				setInternalValue(next);
			}
			onValueChange?.(next);
		},
		[value, onValueChange],
	);

	return (
		<MenubarContext.Provider value={{ activeValue, setActiveValue }}>
			<div ref={ref} className={`${styles[menubarStyleKeys.root]} ${className}`} {...props}>
				{children}
			</div>
		</MenubarContext.Provider>
	);
});
Menubar.displayName = 'Menubar';

export interface MenubarPortalProps {
	container?: PortalContainer;
	children?: React.ReactNode;
}

const MenubarPortal = ({ container, children }: MenubarPortalProps) => {
	return <PortalProvider container={container}>{children}</PortalProvider>;
};
MenubarPortal.displayName = 'MenubarPortal';

export interface MenubarMenuProps extends MenubarMenuBaseProps {
	children?: React.ReactNode;
}

const MenubarMenu = ({ value, children }: MenubarMenuProps) => {
	const { activeValue, setActiveValue } = useMenubarContext();
	const open = activeValue === value;
	const triggerRef = useRef<HTMLElement | null>(null);
	const contentId = useId();

	const setOpen = useCallback(
		(next: boolean) => {
			setActiveValue(next ? value : undefined);
		},
		[value, setActiveValue],
	);

	return (
		<MenubarMenuContext.Provider value={{ value, open, triggerRef, contentId, setOpen }}>
			<div className={styles[menubarStyleKeys.menu]}>{children}</div>
		</MenubarMenuContext.Provider>
	);
};
MenubarMenu.displayName = 'MenubarMenu';

export interface MenubarTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, MenubarTriggerBaseProps {}

const MenubarTrigger = React.forwardRef<HTMLButtonElement, MenubarTriggerProps>(({ className = '', disabled = false, children, onClick, onKeyDown, ...props }, ref) => {
	const context = useMenubarMenuContext();

	function setRef(element: HTMLButtonElement | null) {
		context.triggerRef.current = element;
		if (typeof ref === 'function') {
			ref(element);
		} else if (ref) {
			ref.current = element;
		}
	}

	function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
		onClick?.(event);
		if (disabled) {
			return;
		}
		context.setOpen(!context.open);
	}

	function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
		onKeyDown?.(event);
		if (event.defaultPrevented || disabled) {
			return;
		}
		if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
			event.preventDefault();
			context.setOpen(true);
		}
	}

	const state = getMenubarState(context.open);

	return (
		<button
			ref={setRef}
			type="button"
			aria-haspopup="menu"
			aria-expanded={context.open}
			aria-controls={context.contentId}
			data-state={state}
			data-disabled={disabled}
			className={`${styles[menubarStyleKeys.trigger]} ${className}`}
			onClick={handleClick}
			onKeyDown={handleKeyDown}
			{...props}>
			{children}
		</button>
	);
});
MenubarTrigger.displayName = 'MenubarTrigger';

export interface MenubarContentProps extends React.HTMLAttributes<HTMLDivElement>, MenubarContentBaseProps {
	container?: PortalContainer;
}

const MenubarContent = React.forwardRef<HTMLDivElement, MenubarContentProps>(
	({ className = '', side = 'bottom', align = 'start', sideOffset = 8, alignOffset = -4, container, children, ...props }, ref) => {
		const { open, triggerRef, contentId, setOpen } = useMenubarMenuContext();
		const portalContainer = usePortalContainer(container);
		const [position, setPosition] = useState<MenubarPosition | null>(null);
		const contentRef = useRef<HTMLDivElement | null>(null);
		const itemsRef = useRef<HTMLElement[]>([]);

		function setRef(element: HTMLDivElement | null) {
			contentRef.current = element;
			if (typeof ref === 'function') {
				ref(element);
			} else if (ref) {
				ref.current = element;
			}
		}

		useLayoutEffect(() => {
			if (!open) {
				return;
			}

			function updatePosition() {
				const trigger = triggerRef.current;
				const content = contentRef.current;
				if (!trigger || !content) {
					return;
				}

				const triggerRect = trigger.getBoundingClientRect();
				const contentSize = { width: content.offsetWidth, height: content.offsetHeight };
				const viewport = { width: window.innerWidth, height: window.innerHeight };
				setPosition(getMenubarPosition({ triggerRect, contentSize, side, align, sideOffset, alignOffset, viewport }));
			}

			updatePosition();
			window.addEventListener('resize', updatePosition);
			document.addEventListener('scroll', updatePosition, true);

			return () => {
				window.removeEventListener('resize', updatePosition);
				document.removeEventListener('scroll', updatePosition, true);
			};
		}, [open, side, align, sideOffset, alignOffset, triggerRef]);

		useEffect(() => {
			if (!open) {
				return;
			}
			const items = itemsRef.current;
			if (items.length === 0) {
				return;
			}
			items.forEach((item) => item.removeAttribute('data-highlighted'));
			items[0].setAttribute('data-highlighted', 'true');
			items[0].focus();
		}, [open]);

		useEffect(() => {
			if (!open) {
				return;
			}

			function handlePointerDown(event: PointerEvent) {
				const target = event.target as Node | null;
				if (!target) {
					return;
				}
				const content = contentRef.current;
				const trigger = triggerRef.current;
				if (content && content.contains(target)) {
					return;
				}
				if (trigger && trigger.contains(target)) {
					return;
				}
				setOpen(false);
			}

			document.addEventListener('pointerdown', handlePointerDown);
			return () => document.removeEventListener('pointerdown', handlePointerDown);
		}, [open, triggerRef, setOpen]);

		function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
			const items = itemsRef.current.filter((item) => item.getAttribute('data-disabled') !== 'true');
			if (items.length === 0) {
				if (event.key === 'Escape') {
					event.preventDefault();
					setOpen(false);
				}
				return;
			}

			const currentIndex = items.findIndex((item) => item.getAttribute('data-highlighted') === 'true');

			const highlight = (next: number) => {
				items.forEach((item) => item.removeAttribute('data-highlighted'));
				items[next].setAttribute('data-highlighted', 'true');
				items[next].focus();
			};

			switch (event.key) {
				case 'ArrowDown':
					event.preventDefault();
					highlight(currentIndex < 0 ? 0 : (currentIndex + 1) % items.length);
					break;
				case 'ArrowUp':
					event.preventDefault();
					highlight(currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length);
					break;
				case 'Home':
					event.preventDefault();
					highlight(0);
					break;
				case 'End':
					event.preventDefault();
					highlight(items.length - 1);
					break;
				case 'Enter':
				case ' ':
					event.preventDefault();
					(currentIndex >= 0 ? items[currentIndex] : items[0]).click();
					break;
				case 'Escape':
					event.preventDefault();
					setOpen(false);
					triggerRef.current?.focus();
					break;
			}
		}

		const state = getMenubarState(open);
		const classes = [styles[menubarStyleKeys.content], className].filter(Boolean).join(' ');

		const content = (
			<MenubarContentContext.Provider value={{ itemsRef, close: () => setOpen(false) }}>
				<div
					ref={setRef}
					id={contentId}
					role="menu"
					tabIndex={-1}
					data-state={state}
					data-side={side}
					data-align={align}
					className={classes}
					style={position ? { top: `${position.top}px`, left: `${position.left}px` } : undefined}
					onKeyDown={handleKeyDown}
					{...props}>
					{children}
				</div>
			</MenubarContentContext.Provider>
		);

		if (!open || !portalContainer) {
			return null;
		}

		return createPortal(content, portalContainer);
	},
);
MenubarContent.displayName = 'MenubarContent';

export interface MenubarGroupProps extends React.HTMLAttributes<HTMLDivElement> {}

const MenubarGroup = React.forwardRef<HTMLDivElement, MenubarGroupProps>(({ className = '', children, ...props }, ref) => {
	return (
		<div ref={ref} role="group" className={`${styles[menubarStyleKeys.group]} ${className}`} {...props}>
			{children}
		</div>
	);
});
MenubarGroup.displayName = 'MenubarGroup';

export interface MenubarLabelProps extends React.HTMLAttributes<HTMLDivElement>, MenubarLabelBaseProps {}

const MenubarLabel = React.forwardRef<HTMLDivElement, MenubarLabelProps>(({ className = '', inset = false, children, ...props }, ref) => {
	const classes = [styles[menubarStyleKeys.label], className].filter(Boolean).join(' ');
	return (
		<div ref={ref} data-inset={inset} className={classes} {...props}>
			{children}
		</div>
	);
});
MenubarLabel.displayName = 'MenubarLabel';

export interface MenubarSeparatorProps extends React.HTMLAttributes<HTMLDivElement> {}

const MenubarSeparator = React.forwardRef<HTMLDivElement, MenubarSeparatorProps>(({ className = '', ...props }, ref) => {
	const classes = [styles[menubarStyleKeys.separator], className].filter(Boolean).join(' ');
	return <div ref={ref} role="separator" className={classes} {...props} />;
});
MenubarSeparator.displayName = 'MenubarSeparator';

export interface MenubarShortcutProps extends React.HTMLAttributes<HTMLSpanElement> {}

const MenubarShortcut = React.forwardRef<HTMLSpanElement, MenubarShortcutProps>(({ className = '', children, ...props }, ref) => {
	const classes = [styles[menubarStyleKeys.shortcut], className].filter(Boolean).join(' ');
	return (
		<span ref={ref} className={classes} {...props}>
			{children}
		</span>
	);
});
MenubarShortcut.displayName = 'MenubarShortcut';

export interface MenubarItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onSelect'>, MenubarItemBaseProps {}

const MenubarItem = React.forwardRef<HTMLDivElement, MenubarItemProps>(
	({ className = '', inset = false, variant = 'default', disabled = false, onSelect, children, ...props }, ref) => {
		const { itemsRef, close } = useMenubarContentContext();
		const itemRef = useRef<HTMLDivElement | null>(null);

		useEffect(() => {
			const element = itemRef.current;
			if (!element) {
				return;
			}
			itemsRef.current.push(element);
			return () => {
				itemsRef.current = itemsRef.current.filter((item) => item !== element);
			};
		}, [itemsRef]);

		function setRef(element: HTMLDivElement | null) {
			itemRef.current = element;
			if (typeof ref === 'function') {
				ref(element);
			} else if (ref) {
				ref.current = element;
			}
		}

		function handleClick(event: React.MouseEvent<HTMLDivElement>) {
			if (disabled) {
				return;
			}
			onSelect?.(event.nativeEvent);
			close();
		}

		const classes = [styles[menubarStyleKeys.item], className].filter(Boolean).join(' ');
		return (
			<div ref={setRef} role="menuitem" tabIndex={-1} data-inset={inset} data-variant={variant} data-disabled={disabled} className={classes} onClick={handleClick} {...props}>
				{children}
			</div>
		);
	},
);
MenubarItem.displayName = 'MenubarItem';

export interface MenubarCheckboxItemProps extends React.HTMLAttributes<HTMLDivElement>, MenubarCheckboxItemBaseProps {}

const MenubarCheckboxItem = React.forwardRef<HTMLDivElement, MenubarCheckboxItemProps>(
	({ className = '', checked, defaultChecked = false, onCheckedChange, disabled = false, children, ...props }, ref) => {
		const { itemsRef } = useMenubarContentContext();
		const itemRef = useRef<HTMLDivElement | null>(null);
		const [internalChecked, setInternalChecked] = useState(defaultChecked);
		const isChecked = checked !== undefined ? checked : internalChecked;

		useEffect(() => {
			const element = itemRef.current;
			if (!element) {
				return;
			}
			itemsRef.current.push(element);
			return () => {
				itemsRef.current = itemsRef.current.filter((item) => item !== element);
			};
		}, [itemsRef]);

		function setRef(element: HTMLDivElement | null) {
			itemRef.current = element;
			if (typeof ref === 'function') {
				ref(element);
			} else if (ref) {
				ref.current = element;
			}
		}

		function handleClick() {
			if (disabled) {
				return;
			}
			const next = !isChecked;
			if (checked === undefined) {
				setInternalChecked(next);
			}
			onCheckedChange?.(next);
		}

		const classes = [styles[menubarStyleKeys.checkboxItem], className].filter(Boolean).join(' ');
		return (
			<div
				ref={setRef}
				role="menuitemcheckbox"
				tabIndex={-1}
				aria-checked={isChecked}
				data-checked={getMenubarCheckState(isChecked)}
				data-disabled={disabled}
				className={classes}
				onClick={handleClick}
				{...props}>
				<span className={styles[menubarStyleKeys.indicator]}>{isChecked && <MenubarCheckIcon />}</span>
				{children}
			</div>
		);
	},
);
MenubarCheckboxItem.displayName = 'MenubarCheckboxItem';

export interface MenubarRadioGroupProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'defaultValue'>, MenubarRadioGroupBaseProps {}

const MenubarRadioGroup = React.forwardRef<HTMLDivElement, MenubarRadioGroupProps>(({ className = '', value, defaultValue, onValueChange, children, ...props }, ref) => {
	const [internalValue, setInternalValue] = useState(defaultValue);
	const resolvedValue = value !== undefined ? value : internalValue;

	const setValue = useCallback(
		(next: string) => {
			if (value === undefined) {
				setInternalValue(next);
			}
			onValueChange?.(next);
		},
		[value, onValueChange],
	);

	const classes = [styles[menubarStyleKeys.radioGroup], className].filter(Boolean).join(' ');
	return (
		<MenubarRadioGroupContext.Provider value={{ value: resolvedValue, setValue }}>
			<div ref={ref} role="group" className={classes} {...props}>
				{children}
			</div>
		</MenubarRadioGroupContext.Provider>
	);
});
MenubarRadioGroup.displayName = 'MenubarRadioGroup';

export interface MenubarRadioItemProps extends React.HTMLAttributes<HTMLDivElement>, MenubarRadioItemBaseProps {}

const MenubarRadioItem = React.forwardRef<HTMLDivElement, MenubarRadioItemProps>(({ className = '', value, disabled = false, children, ...props }, ref) => {
	const { itemsRef } = useMenubarContentContext();
	const { value: groupValue, setValue } = useMenubarRadioGroupContext();
	const itemRef = useRef<HTMLDivElement | null>(null);
	const isChecked = groupValue === value;

	useEffect(() => {
		const element = itemRef.current;
		if (!element) {
			return;
		}
		itemsRef.current.push(element);
		return () => {
			itemsRef.current = itemsRef.current.filter((item) => item !== element);
		};
	}, [itemsRef]);

	function setRef(element: HTMLDivElement | null) {
		itemRef.current = element;
		if (typeof ref === 'function') {
			ref(element);
		} else if (ref) {
			ref.current = element;
		}
	}

	function handleClick() {
		if (disabled) {
			return;
		}
		setValue(value);
	}

	const classes = [styles[menubarStyleKeys.radioItem], className].filter(Boolean).join(' ');
	return (
		<div
			ref={setRef}
			role="menuitemradio"
			tabIndex={-1}
			aria-checked={isChecked}
			data-checked={getMenubarCheckState(isChecked)}
			data-disabled={disabled}
			className={classes}
			onClick={handleClick}
			{...props}>
			<span className={styles[menubarStyleKeys.indicator]}>{isChecked && <MenubarRadioIcon />}</span>
			{children}
		</div>
	);
});
MenubarRadioItem.displayName = 'MenubarRadioItem';

export interface MenubarSubProps extends MenubarSubBaseProps {
	children?: React.ReactNode;
}

const MenubarSub = ({ open, defaultOpen = false, onOpenChange, children }: MenubarSubProps) => {
	const [internalOpen, setInternalOpen] = useState(defaultOpen);
	const isOpen = open !== undefined ? open : internalOpen;
	const triggerRef = useRef<HTMLElement | null>(null);

	const setOpen = useCallback(
		(next: boolean) => {
			if (open === undefined) {
				setInternalOpen(next);
			}
			onOpenChange?.(next);
		},
		[open, onOpenChange],
	);

	return <MenubarSubContext.Provider value={{ open: isOpen, setOpen, triggerRef }}>{children}</MenubarSubContext.Provider>;
};
MenubarSub.displayName = 'MenubarSub';

export interface MenubarSubTriggerProps extends React.HTMLAttributes<HTMLDivElement>, MenubarSubTriggerBaseProps {}

const MenubarSubTrigger = React.forwardRef<HTMLDivElement, MenubarSubTriggerProps>(({ className = '', inset = false, disabled = false, children, ...props }, ref) => {
	const { itemsRef } = useMenubarContentContext();
	const { open, setOpen, triggerRef } = useMenubarSubContext();
	const itemRef = useRef<HTMLDivElement | null>(null);

	useEffect(() => {
		const element = itemRef.current;
		if (!element) {
			return;
		}
		itemsRef.current.push(element);
		return () => {
			itemsRef.current = itemsRef.current.filter((item) => item !== element);
		};
	}, [itemsRef]);

	function setRef(element: HTMLDivElement | null) {
		itemRef.current = element;
		triggerRef.current = element;
		if (typeof ref === 'function') {
			ref(element);
		} else if (ref) {
			ref.current = element;
		}
	}

	function handleMouseEnter() {
		if (disabled) {
			return;
		}
		setOpen(true);
	}

	function handleClick() {
		if (disabled) {
			return;
		}
		setOpen(!open);
	}

	function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
		if (disabled) {
			return;
		}
		if (event.key === 'ArrowRight' || event.key === 'Enter' || event.key === ' ') {
			event.preventDefault();
			setOpen(true);
		}
	}

	const state = getMenubarState(open);
	const classes = [styles[menubarStyleKeys.subTrigger], className].filter(Boolean).join(' ');
	return (
		<div
			ref={setRef}
			role="menuitem"
			tabIndex={-1}
			aria-haspopup="menu"
			aria-expanded={open}
			data-state={state}
			data-inset={inset}
			data-disabled={disabled}
			className={classes}
			onMouseEnter={handleMouseEnter}
			onClick={handleClick}
			onKeyDown={handleKeyDown}
			{...props}>
			{children}
			<MenubarChevronIcon />
		</div>
	);
});
MenubarSubTrigger.displayName = 'MenubarSubTrigger';

export interface MenubarSubContentProps extends React.HTMLAttributes<HTMLDivElement>, MenubarContentBaseProps {
	container?: PortalContainer;
}

const MenubarSubContent = React.forwardRef<HTMLDivElement, MenubarSubContentProps>(
	({ className = '', side = 'right', align = 'start', sideOffset = 0, alignOffset = 0, container, children, ...props }, ref) => {
		const { open, setOpen, triggerRef } = useMenubarSubContext();
		const portalContainer = usePortalContainer(container);
		const [position, setPosition] = useState<MenubarPosition | null>(null);
		const contentRef = useRef<HTMLDivElement | null>(null);
		const itemsRef = useRef<HTMLElement[]>([]);

		function setRef(element: HTMLDivElement | null) {
			contentRef.current = element;
			if (typeof ref === 'function') {
				ref(element);
			} else if (ref) {
				ref.current = element;
			}
		}

		useLayoutEffect(() => {
			if (!open) {
				return;
			}

			function updatePosition() {
				const trigger = triggerRef.current;
				const content = contentRef.current;
				if (!trigger || !content) {
					return;
				}

				const triggerRect = trigger.getBoundingClientRect();
				const contentSize = { width: content.offsetWidth, height: content.offsetHeight };
				const viewport = { width: window.innerWidth, height: window.innerHeight };
				setPosition(getMenubarPosition({ triggerRect, contentSize, side, align, sideOffset, alignOffset, viewport }));
			}

			updatePosition();
			window.addEventListener('resize', updatePosition);
			document.addEventListener('scroll', updatePosition, true);

			return () => {
				window.removeEventListener('resize', updatePosition);
				document.removeEventListener('scroll', updatePosition, true);
			};
		}, [open, side, align, sideOffset, alignOffset, triggerRef]);

		useEffect(() => {
			if (!open) {
				return;
			}

			function handlePointerDown(event: PointerEvent) {
				const target = event.target as Node | null;
				if (!target) {
					return;
				}
				const content = contentRef.current;
				const trigger = triggerRef.current;
				if (content && content.contains(target)) {
					return;
				}
				if (trigger && trigger.contains(target)) {
					return;
				}
				setOpen(false);
			}

			document.addEventListener('pointerdown', handlePointerDown);
			return () => document.removeEventListener('pointerdown', handlePointerDown);
		}, [open, triggerRef, setOpen]);

		function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
			const items = itemsRef.current.filter((item) => item.getAttribute('data-disabled') !== 'true');

			if (event.key === 'Escape' || event.key === 'ArrowLeft') {
				event.preventDefault();
				setOpen(false);
				triggerRef.current?.focus();
				return;
			}

			if (items.length === 0) {
				return;
			}

			const currentIndex = items.findIndex((item) => item.getAttribute('data-highlighted') === 'true');
			const highlight = (next: number) => {
				items.forEach((item) => item.removeAttribute('data-highlighted'));
				items[next].setAttribute('data-highlighted', 'true');
				items[next].focus();
			};

			switch (event.key) {
				case 'ArrowDown':
					event.preventDefault();
					highlight(currentIndex < 0 ? 0 : (currentIndex + 1) % items.length);
					break;
				case 'ArrowUp':
					event.preventDefault();
					highlight(currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length);
					break;
				case 'Enter':
				case ' ':
					event.preventDefault();
					(currentIndex >= 0 ? items[currentIndex] : items[0]).click();
					break;
			}
		}

		const state = getMenubarState(open);
		const classes = [styles[menubarStyleKeys.subContent], className].filter(Boolean).join(' ');

		const content = (
			<MenubarContentContext.Provider value={{ itemsRef, close: () => setOpen(false) }}>
				<div
					ref={setRef}
					role="menu"
					tabIndex={-1}
					data-state={state}
					data-side={side}
					data-align={align}
					className={classes}
					style={position ? { top: `${position.top}px`, left: `${position.left}px` } : undefined}
					onKeyDown={handleKeyDown}
					{...props}>
					{children}
				</div>
			</MenubarContentContext.Provider>
		);

		if (!open || !portalContainer) {
			return null;
		}

		return createPortal(content, portalContainer);
	},
);
MenubarSubContent.displayName = 'MenubarSubContent';

export {
	Menubar,
	MenubarPortal,
	MenubarMenu,
	MenubarTrigger,
	MenubarContent,
	MenubarGroup,
	MenubarLabel,
	MenubarItem,
	MenubarCheckboxItem,
	MenubarRadioGroup,
	MenubarRadioItem,
	MenubarSeparator,
	MenubarShortcut,
	MenubarSub,
	MenubarSubTrigger,
	MenubarSubContent,
};
export default Menubar;
Interaction
dropdown

DependenciesBundle weight · light

react-dom