@zag-js/menu
Version:
Core logic for the menu widget implemented as a state machine
1,332 lines (1,328 loc) • 43.7 kB
JavaScript
import { createAnatomy } from '@zag-js/anatomy';
import { createGuards, createMachine, mergeProps } from '@zag-js/core';
import { isEditableElement, raf, getInitialFocus, contains, observeAttributes, addDomEvent, getEventTarget, getByTypeahead, clickIfLink, getWindow, scrollIntoView, queryAll, dataAttr, isDownloadingEvent, isOpeningInNewTab, isSelfTarget, isValidTabEvent, getEventKey, isPrintableKey, isModifierKey, isContextMenuEvent, getEventPoint, ariaAttr, isAnchorElement, isHTMLElement } from '@zag-js/dom-query';
import { getPlacementSide, getPlacement, getPlacementStyles } from '@zag-js/popper';
import { last, first, isEqual, createSplitProps, prev, next, cast, hasProp } from '@zag-js/utils';
import { trackDismissableElement } from '@zag-js/dismissable';
import { getElementPolygon, isPointInPolygon } from '@zag-js/rect-utils';
import { createProps } from '@zag-js/types';
// src/menu.anatomy.ts
var anatomy = createAnatomy("menu").parts(
"arrow",
"arrowTip",
"content",
"contextTrigger",
"indicator",
"item",
"itemGroup",
"itemGroupLabel",
"itemIndicator",
"itemText",
"positioner",
"separator",
"trigger",
"triggerItem"
);
var parts = anatomy.build();
var getTriggerId = (ctx) => ctx.ids?.trigger ?? `menu:${ctx.id}:trigger`;
var getContextTriggerId = (ctx) => ctx.ids?.contextTrigger ?? `menu:${ctx.id}:ctx-trigger`;
var getContentId = (ctx) => ctx.ids?.content ?? `menu:${ctx.id}:content`;
var getArrowId = (ctx) => ctx.ids?.arrow ?? `menu:${ctx.id}:arrow`;
var getPositionerId = (ctx) => ctx.ids?.positioner ?? `menu:${ctx.id}:popper`;
var getGroupId = (ctx, id) => ctx.ids?.group?.(id) ?? `menu:${ctx.id}:group:${id}`;
var getItemId = (ctx, id) => `${ctx.id}/${id}`;
var getItemValue = (el) => el?.dataset.value ?? null;
var getGroupLabelId = (ctx, id) => ctx.ids?.groupLabel?.(id) ?? `menu:${ctx.id}:group-label:${id}`;
var getContentEl = (ctx) => ctx.getById(getContentId(ctx));
var getPositionerEl = (ctx) => ctx.getById(getPositionerId(ctx));
var getTriggerEl = (ctx) => ctx.getById(getTriggerId(ctx));
var getItemEl = (ctx, value) => value ? ctx.getById(getItemId(ctx, value)) : null;
var getContextTriggerEl = (ctx) => ctx.getById(getContextTriggerId(ctx));
var getElements = (ctx) => {
const ownerId = CSS.escape(getContentId(ctx));
const selector = `[role^="menuitem"][data-ownedby=${ownerId}]:not([data-disabled])`;
return queryAll(getContentEl(ctx), selector);
};
var getFirstEl = (ctx) => first(getElements(ctx));
var getLastEl = (ctx) => last(getElements(ctx));
var isMatch = (el, value) => {
if (!value) return false;
return el.id === value || el.dataset.value === value;
};
var getNextEl = (ctx, opts) => {
const items = getElements(ctx);
const index = items.findIndex((el) => isMatch(el, opts.value));
return next(items, index, { loop: opts.loop ?? opts.loopFocus });
};
var getPrevEl = (ctx, opts) => {
const items = getElements(ctx);
const index = items.findIndex((el) => isMatch(el, opts.value));
return prev(items, index, { loop: opts.loop ?? opts.loopFocus });
};
var getElemByKey = (ctx, opts) => {
const items = getElements(ctx);
const item = items.find((el) => isMatch(el, opts.value));
return getByTypeahead(items, { state: opts.typeaheadState, key: opts.key, activeId: item?.id ?? null });
};
var isTargetDisabled = (v) => {
return isHTMLElement(v) && (v.dataset.disabled === "" || v.hasAttribute("disabled"));
};
var isTriggerItem = (el) => {
return !!el?.getAttribute("role")?.startsWith("menuitem") && !!el?.hasAttribute("aria-controls");
};
var itemSelectEvent = "menu:select";
function dispatchSelectionEvent(el, value) {
if (!el) return;
const win = getWindow(el);
const event = new win.CustomEvent(itemSelectEvent, { detail: { value } });
el.dispatchEvent(event);
}
// src/menu.connect.ts
function connect(service, normalize) {
const { context, send, state, computed, prop, scope } = service;
const open = state.hasTag("open");
const isSubmenu = computed("isSubmenu");
const isTypingAhead = computed("isTypingAhead");
const composite = prop("composite");
const currentPlacement = context.get("currentPlacement");
const anchorPoint = context.get("anchorPoint");
const highlightedValue = context.get("highlightedValue");
const popperStyles = getPlacementStyles({
...prop("positioning"),
placement: anchorPoint ? "bottom" : currentPlacement
});
function getItemState(props2) {
return {
id: getItemId(scope, props2.value),
disabled: !!props2.disabled,
highlighted: highlightedValue === props2.value
};
}
function getOptionItemProps(props2) {
const valueText = props2.valueText ?? props2.value;
return { ...props2, id: props2.value, valueText };
}
function getOptionItemState(props2) {
const itemState = getItemState(getOptionItemProps(props2));
return {
...itemState,
checked: !!props2.checked
};
}
function getItemProps(props2) {
const { closeOnSelect, valueText, value } = props2;
const itemState = getItemState(props2);
const id = getItemId(scope, value);
return normalize.element({
...parts.item.attrs,
id,
role: "menuitem",
"aria-disabled": ariaAttr(itemState.disabled),
"data-disabled": dataAttr(itemState.disabled),
"data-ownedby": getContentId(scope),
"data-highlighted": dataAttr(itemState.highlighted),
"data-value": value,
"data-valuetext": valueText,
onDragStart(event) {
const isLink = event.currentTarget.matches("a[href]");
if (isLink) event.preventDefault();
},
onPointerMove(event) {
if (itemState.disabled) return;
if (event.pointerType !== "mouse") return;
const target = event.currentTarget;
if (itemState.highlighted) return;
send({ type: "ITEM_POINTERMOVE", id, target, closeOnSelect });
},
onPointerLeave(event) {
if (itemState.disabled) return;
if (event.pointerType !== "mouse") return;
const pointerMoved = service.event.previous()?.type.includes("POINTER");
if (!pointerMoved) return;
const target = event.currentTarget;
send({ type: "ITEM_POINTERLEAVE", id, target, closeOnSelect });
},
onPointerDown(event) {
if (itemState.disabled) return;
const target = event.currentTarget;
send({ type: "ITEM_POINTERDOWN", target, id, closeOnSelect });
},
onClick(event) {
if (isDownloadingEvent(event)) return;
if (isOpeningInNewTab(event)) return;
if (itemState.disabled) return;
const target = event.currentTarget;
send({ type: "ITEM_CLICK", target, id, closeOnSelect });
}
});
}
return {
highlightedValue,
open,
setOpen(nextOpen) {
const open2 = state.hasTag("open");
if (open2 === nextOpen) return;
send({ type: nextOpen ? "OPEN" : "CLOSE" });
},
setHighlightedValue(value) {
send({ type: "HIGHLIGHTED.SET", value });
},
setParent(parent) {
send({ type: "PARENT.SET", value: parent, id: parent.prop("id") });
},
setChild(child) {
send({ type: "CHILD.SET", value: child, id: child.prop("id") });
},
reposition(options = {}) {
send({ type: "POSITIONING.SET", options });
},
addItemListener(props2) {
const node = scope.getById(props2.id);
if (!node) return;
const listener = () => props2.onSelect?.();
node.addEventListener(itemSelectEvent, listener);
return () => node.removeEventListener(itemSelectEvent, listener);
},
getContextTriggerProps() {
return normalize.element({
...parts.contextTrigger.attrs,
dir: prop("dir"),
id: getContextTriggerId(scope),
onPointerDown(event) {
if (event.pointerType === "mouse") return;
const point = getEventPoint(event);
send({ type: "CONTEXT_MENU_START", point });
},
onPointerCancel(event) {
if (event.pointerType === "mouse") return;
send({ type: "CONTEXT_MENU_CANCEL" });
},
onPointerMove(event) {
if (event.pointerType === "mouse") return;
send({ type: "CONTEXT_MENU_CANCEL" });
},
onPointerUp(event) {
if (event.pointerType === "mouse") return;
send({ type: "CONTEXT_MENU_CANCEL" });
},
onContextMenu(event) {
const point = getEventPoint(event);
send({ type: "CONTEXT_MENU", point });
event.preventDefault();
},
style: {
WebkitTouchCallout: "none",
WebkitUserSelect: "none",
userSelect: "none"
}
});
},
getTriggerItemProps(childApi) {
const triggerProps = childApi.getTriggerProps();
return mergeProps(getItemProps({ value: triggerProps.id }), triggerProps);
},
getTriggerProps() {
return normalize.button({
...isSubmenu ? parts.triggerItem.attrs : parts.trigger.attrs,
"data-placement": context.get("currentPlacement"),
type: "button",
dir: prop("dir"),
id: getTriggerId(scope),
"data-uid": prop("id"),
"aria-haspopup": composite ? "menu" : "dialog",
"aria-controls": getContentId(scope),
"aria-expanded": open || void 0,
"data-state": open ? "open" : "closed",
onPointerMove(event) {
if (event.pointerType !== "mouse") return;
const disabled = isTargetDisabled(event.currentTarget);
if (disabled || !isSubmenu) return;
const point = getEventPoint(event);
send({ type: "TRIGGER_POINTERMOVE", target: event.currentTarget, point });
},
onPointerLeave(event) {
if (isTargetDisabled(event.currentTarget)) return;
if (event.pointerType !== "mouse") return;
if (!isSubmenu) return;
const point = getEventPoint(event);
send({
type: "TRIGGER_POINTERLEAVE",
target: event.currentTarget,
point
});
},
onPointerDown(event) {
if (isTargetDisabled(event.currentTarget)) return;
if (isContextMenuEvent(event)) return;
event.preventDefault();
},
onClick(event) {
if (event.defaultPrevented) return;
if (isTargetDisabled(event.currentTarget)) return;
send({ type: "TRIGGER_CLICK", target: event.currentTarget });
},
onBlur() {
send({ type: "TRIGGER_BLUR" });
},
onFocus() {
send({ type: "TRIGGER_FOCUS" });
},
onKeyDown(event) {
if (event.defaultPrevented) return;
const keyMap = {
ArrowDown() {
send({ type: "ARROW_DOWN" });
},
ArrowUp() {
send({ type: "ARROW_UP" });
},
Enter() {
send({ type: "ARROW_DOWN", src: "enter" });
},
Space() {
send({ type: "ARROW_DOWN", src: "space" });
}
};
const key = getEventKey(event, {
orientation: "vertical",
dir: prop("dir")
});
const exec = keyMap[key];
if (exec) {
event.preventDefault();
exec(event);
}
}
});
},
getIndicatorProps() {
return normalize.element({
...parts.indicator.attrs,
dir: prop("dir"),
"data-state": open ? "open" : "closed"
});
},
getPositionerProps() {
return normalize.element({
...parts.positioner.attrs,
dir: prop("dir"),
id: getPositionerId(scope),
style: popperStyles.floating
});
},
getArrowProps() {
return normalize.element({
id: getArrowId(scope),
...parts.arrow.attrs,
dir: prop("dir"),
style: popperStyles.arrow
});
},
getArrowTipProps() {
return normalize.element({
...parts.arrowTip.attrs,
dir: prop("dir"),
style: popperStyles.arrowTip
});
},
getContentProps() {
return normalize.element({
...parts.content.attrs,
id: getContentId(scope),
"aria-label": prop("aria-label"),
hidden: !open,
"data-state": open ? "open" : "closed",
role: composite ? "menu" : "dialog",
tabIndex: 0,
dir: prop("dir"),
"aria-activedescendant": computed("highlightedId") || void 0,
"aria-labelledby": getTriggerId(scope),
"data-placement": currentPlacement,
onPointerEnter(event) {
if (event.pointerType !== "mouse") return;
send({ type: "MENU_POINTERENTER" });
},
onKeyDown(event) {
if (event.defaultPrevented) return;
if (!isSelfTarget(event)) return;
const target = getEventTarget(event);
const sameMenu = target?.closest("[role=menu]") === event.currentTarget || target === event.currentTarget;
if (!sameMenu) return;
if (event.key === "Tab") {
const valid = isValidTabEvent(event);
if (!valid) {
event.preventDefault();
return;
}
}
const item = getItemEl(scope, highlightedValue);
const keyMap = {
ArrowDown() {
send({ type: "ARROW_DOWN" });
},
ArrowUp() {
send({ type: "ARROW_UP" });
},
ArrowLeft() {
send({ type: "ARROW_LEFT" });
},
ArrowRight() {
send({ type: "ARROW_RIGHT" });
},
Enter() {
send({ type: "ENTER" });
if (highlightedValue == null) return;
if (isAnchorElement(item)) {
prop("navigate")?.({ value: highlightedValue, node: item, href: item.href });
}
},
Space(event2) {
if (isTypingAhead) {
send({ type: "TYPEAHEAD", key: event2.key });
} else {
keyMap.Enter?.(event2);
}
},
Home() {
send({ type: "HOME" });
},
End() {
send({ type: "END" });
}
};
const key = getEventKey(event, { dir: prop("dir") });
const exec = keyMap[key];
if (exec) {
exec(event);
event.stopPropagation();
event.preventDefault();
return;
}
if (!prop("typeahead")) return;
if (!isPrintableKey(event)) return;
if (isModifierKey(event)) return;
if (isEditableElement(target)) return;
send({ type: "TYPEAHEAD", key: event.key });
event.preventDefault();
}
});
},
getSeparatorProps() {
return normalize.element({
...parts.separator.attrs,
role: "separator",
dir: prop("dir"),
"aria-orientation": "horizontal"
});
},
getItemState,
getItemProps,
getOptionItemState,
getOptionItemProps(props2) {
const { type, disabled, onCheckedChange, closeOnSelect } = props2;
const option = getOptionItemProps(props2);
const itemState = getOptionItemState(props2);
return {
...getItemProps(option),
...normalize.element({
"data-type": type,
...parts.item.attrs,
dir: prop("dir"),
"data-value": option.value,
role: `menuitem${type}`,
"aria-checked": !!itemState.checked,
"data-state": itemState.checked ? "checked" : "unchecked",
onClick(event) {
if (disabled) return;
if (isDownloadingEvent(event)) return;
if (isOpeningInNewTab(event)) return;
const target = event.currentTarget;
send({ type: "ITEM_CLICK", target, option, closeOnSelect });
onCheckedChange?.(!itemState.checked);
}
})
};
},
getItemIndicatorProps(props2) {
const itemState = getOptionItemState(cast(props2));
const dataState = itemState.checked ? "checked" : "unchecked";
return normalize.element({
...parts.itemIndicator.attrs,
dir: prop("dir"),
"data-disabled": dataAttr(itemState.disabled),
"data-highlighted": dataAttr(itemState.highlighted),
"data-state": hasProp(props2, "checked") ? dataState : void 0,
hidden: hasProp(props2, "checked") ? !itemState.checked : void 0
});
},
getItemTextProps(props2) {
const itemState = getOptionItemState(cast(props2));
const dataState = itemState.checked ? "checked" : "unchecked";
return normalize.element({
...parts.itemText.attrs,
dir: prop("dir"),
"data-disabled": dataAttr(itemState.disabled),
"data-highlighted": dataAttr(itemState.highlighted),
"data-state": hasProp(props2, "checked") ? dataState : void 0
});
},
getItemGroupLabelProps(props2) {
return normalize.element({
...parts.itemGroupLabel.attrs,
id: getGroupLabelId(scope, props2.htmlFor),
dir: prop("dir")
});
},
getItemGroupProps(props2) {
return normalize.element({
id: getGroupId(scope, props2.id),
...parts.itemGroup.attrs,
dir: prop("dir"),
"aria-labelledby": getGroupLabelId(scope, props2.id),
role: "group"
});
}
};
}
var { not, and, or } = createGuards();
var machine = createMachine({
props({ props: props2 }) {
return {
closeOnSelect: true,
typeahead: true,
composite: true,
loopFocus: false,
navigate(details) {
clickIfLink(details.node);
},
...props2,
positioning: {
placement: "bottom-start",
gutter: 8,
...props2.positioning
}
};
},
initialState({ prop }) {
const open = prop("open") || prop("defaultOpen");
return open ? "open" : "idle";
},
context({ bindable, prop }) {
return {
suspendPointer: bindable(() => ({
defaultValue: false
})),
highlightedValue: bindable(() => ({
defaultValue: prop("defaultHighlightedValue") || null,
value: prop("highlightedValue"),
onChange(value) {
prop("onHighlightChange")?.({ highlightedValue: value });
}
})),
lastHighlightedValue: bindable(() => ({
defaultValue: null
})),
currentPlacement: bindable(() => ({
defaultValue: void 0
})),
intentPolygon: bindable(() => ({
defaultValue: null
})),
anchorPoint: bindable(() => ({
defaultValue: null,
hash(value) {
return `x: ${value?.x}, y: ${value?.y}`;
}
}))
};
},
refs() {
return {
parent: null,
children: {},
typeaheadState: { ...getByTypeahead.defaultOptions },
positioningOverride: {}
};
},
computed: {
isSubmenu: ({ refs }) => refs.get("parent") != null,
isRtl: ({ prop }) => prop("dir") === "rtl",
isTypingAhead: ({ refs }) => refs.get("typeaheadState").keysSoFar !== "",
highlightedId: ({ context, scope, refs }) => resolveItemId(refs.get("children"), context.get("highlightedValue"), scope)
},
watch({ track, action, context, computed, prop }) {
track([() => computed("isSubmenu")], () => {
action(["setSubmenuPlacement"]);
});
track([() => context.hash("anchorPoint")], () => {
action(["reposition"]);
});
track([() => prop("open")], () => {
action(["toggleVisibility"]);
});
},
on: {
"PARENT.SET": {
actions: ["setParentMenu"]
},
"CHILD.SET": {
actions: ["setChildMenu"]
},
OPEN: [
{
guard: "isOpenControlled",
actions: ["invokeOnOpen"]
},
{
target: "open",
actions: ["invokeOnOpen"]
}
],
OPEN_AUTOFOCUS: [
{
guard: "isOpenControlled",
actions: ["invokeOnOpen"]
},
{
// internal: true,
target: "open",
actions: ["highlightFirstItem", "invokeOnOpen"]
}
],
CLOSE: [
{
guard: "isOpenControlled",
actions: ["invokeOnClose"]
},
{
target: "closed",
actions: ["invokeOnClose"]
}
],
"HIGHLIGHTED.RESTORE": {
actions: ["restoreHighlightedItem"]
},
"HIGHLIGHTED.SET": {
actions: ["setHighlightedItem"]
}
},
states: {
idle: {
tags: ["closed"],
on: {
"CONTROLLED.OPEN": {
target: "open"
},
"CONTROLLED.CLOSE": {
target: "closed"
},
CONTEXT_MENU_START: {
target: "opening:contextmenu",
actions: ["setAnchorPoint"]
},
CONTEXT_MENU: [
{
guard: "isOpenControlled",
actions: ["setAnchorPoint", "invokeOnOpen"]
},
{
target: "open",
actions: ["setAnchorPoint", "invokeOnOpen"]
}
],
TRIGGER_CLICK: [
{
guard: "isOpenControlled",
actions: ["invokeOnOpen"]
},
{
target: "open",
actions: ["invokeOnOpen"]
}
],
TRIGGER_FOCUS: {
guard: not("isSubmenu"),
target: "closed"
},
TRIGGER_POINTERMOVE: {
guard: "isSubmenu",
target: "opening"
}
}
},
"opening:contextmenu": {
tags: ["closed"],
effects: ["waitForLongPress"],
on: {
"CONTROLLED.OPEN": { target: "open" },
"CONTROLLED.CLOSE": { target: "closed" },
CONTEXT_MENU_CANCEL: [
{
guard: "isOpenControlled",
actions: ["invokeOnClose"]
},
{
target: "closed",
actions: ["invokeOnClose"]
}
],
"LONG_PRESS.OPEN": [
{
guard: "isOpenControlled",
actions: ["invokeOnOpen"]
},
{
target: "open",
actions: ["invokeOnOpen"]
}
]
}
},
opening: {
tags: ["closed"],
effects: ["waitForOpenDelay"],
on: {
"CONTROLLED.OPEN": {
target: "open"
},
"CONTROLLED.CLOSE": {
target: "closed"
},
BLUR: [
{
guard: "isOpenControlled",
actions: ["invokeOnClose"]
},
{
target: "closed",
actions: ["invokeOnClose"]
}
],
TRIGGER_POINTERLEAVE: [
{
guard: "isOpenControlled",
actions: ["invokeOnClose"]
},
{
target: "closed",
actions: ["invokeOnClose"]
}
],
"DELAY.OPEN": [
{
guard: "isOpenControlled",
actions: ["invokeOnOpen"]
},
{
target: "open",
actions: ["invokeOnOpen"]
}
]
}
},
closing: {
tags: ["open"],
effects: ["trackPointerMove", "trackInteractOutside", "waitForCloseDelay"],
on: {
"CONTROLLED.OPEN": {
target: "open"
},
"CONTROLLED.CLOSE": {
target: "closed",
actions: ["focusParentMenu", "restoreParentHighlightedItem"]
},
// don't invoke on open here since the menu is still open (we're only keeping it open)
MENU_POINTERENTER: {
target: "open",
actions: ["clearIntentPolygon"]
},
POINTER_MOVED_AWAY_FROM_SUBMENU: [
{
guard: "isOpenControlled",
actions: ["invokeOnClose"]
},
{
target: "closed",
actions: ["focusParentMenu", "restoreParentHighlightedItem"]
}
],
"DELAY.CLOSE": [
{
guard: "isOpenControlled",
actions: ["invokeOnClose"]
},
{
target: "closed",
actions: ["focusParentMenu", "restoreParentHighlightedItem", "invokeOnClose"]
}
]
}
},
closed: {
tags: ["closed"],
entry: ["clearHighlightedItem", "focusTrigger", "resumePointer"],
on: {
"CONTROLLED.OPEN": [
{
guard: or("isOpenAutoFocusEvent", "isArrowDownEvent"),
target: "open",
actions: ["highlightFirstItem"]
},
{
guard: "isArrowUpEvent",
target: "open",
actions: ["highlightLastItem"]
},
{
target: "open"
}
],
CONTEXT_MENU_START: {
target: "opening:contextmenu",
actions: ["setAnchorPoint"]
},
CONTEXT_MENU: [
{
guard: "isOpenControlled",
actions: ["setAnchorPoint", "invokeOnOpen"]
},
{
target: "open",
actions: ["setAnchorPoint", "invokeOnOpen"]
}
],
TRIGGER_CLICK: [
{
guard: "isOpenControlled",
actions: ["invokeOnOpen"]
},
{
target: "open",
actions: ["invokeOnOpen"]
}
],
TRIGGER_POINTERMOVE: {
guard: "isTriggerItem",
target: "opening"
},
TRIGGER_BLUR: { target: "idle" },
ARROW_DOWN: [
{
guard: "isOpenControlled",
actions: ["invokeOnOpen"]
},
{
target: "open",
actions: ["highlightFirstItem", "invokeOnOpen"]
}
],
ARROW_UP: [
{
guard: "isOpenControlled",
actions: ["invokeOnOpen"]
},
{
target: "open",
actions: ["highlightLastItem", "invokeOnOpen"]
}
]
}
},
open: {
tags: ["open"],
effects: ["trackInteractOutside", "trackPositioning", "scrollToHighlightedItem"],
entry: ["focusMenu", "resumePointer"],
on: {
"CONTROLLED.CLOSE": [
{
target: "closed",
guard: "isArrowLeftEvent",
actions: ["focusParentMenu"]
},
{
target: "closed"
}
],
TRIGGER_CLICK: [
{
guard: and(not("isTriggerItem"), "isOpenControlled"),
actions: ["invokeOnClose"]
},
{
guard: not("isTriggerItem"),
target: "closed",
actions: ["invokeOnClose"]
}
],
CONTEXT_MENU: {
actions: ["setAnchorPoint", "focusMenu"]
},
ARROW_UP: {
actions: ["highlightPrevItem", "focusMenu"]
},
ARROW_DOWN: {
actions: ["highlightNextItem", "focusMenu"]
},
ARROW_LEFT: [
{
guard: and("isSubmenu", "isOpenControlled"),
actions: ["invokeOnClose"]
},
{
guard: "isSubmenu",
target: "closed",
actions: ["focusParentMenu", "invokeOnClose"]
}
],
HOME: {
actions: ["highlightFirstItem", "focusMenu"]
},
END: {
actions: ["highlightLastItem", "focusMenu"]
},
ARROW_RIGHT: {
guard: "isTriggerItemHighlighted",
actions: ["openSubmenu"]
},
ENTER: [
{
guard: "isTriggerItemHighlighted",
actions: ["openSubmenu"]
},
{
actions: ["clickHighlightedItem"]
}
],
ITEM_POINTERMOVE: [
{
guard: not("isPointerSuspended"),
actions: ["setHighlightedItem", "focusMenu"]
},
{
actions: ["setLastHighlightedItem"]
}
],
ITEM_POINTERLEAVE: {
guard: and(not("isPointerSuspended"), not("isTriggerItem")),
actions: ["clearHighlightedItem"]
},
ITEM_CLICK: [
// == grouped ==
{
guard: and(
not("isTriggerItemHighlighted"),
not("isHighlightedItemEditable"),
"closeOnSelect",
"isOpenControlled"
),
actions: ["invokeOnSelect", "setOptionState", "closeRootMenu", "invokeOnClose"]
},
{
guard: and(not("isTriggerItemHighlighted"), not("isHighlightedItemEditable"), "closeOnSelect"),
target: "closed",
actions: ["invokeOnSelect", "setOptionState", "closeRootMenu", "invokeOnClose"]
},
//
{
guard: and(not("isTriggerItemHighlighted"), not("isHighlightedItemEditable")),
actions: ["invokeOnSelect", "setOptionState"]
},
{ actions: ["setHighlightedItem"] }
],
TRIGGER_POINTERMOVE: {
guard: "isTriggerItem",
actions: ["setIntentPolygon"]
},
TRIGGER_POINTERLEAVE: {
target: "closing"
},
ITEM_POINTERDOWN: {
actions: ["setHighlightedItem"]
},
TYPEAHEAD: {
actions: ["highlightMatchedItem"]
},
FOCUS_MENU: {
actions: ["focusMenu"]
},
"POSITIONING.SET": {
actions: ["reposition"]
}
}
}
},
implementations: {
guards: {
closeOnSelect: ({ prop, event }) => !!(event?.closeOnSelect ?? prop("closeOnSelect")),
// whether the trigger is also a menu item
isTriggerItem: ({ event }) => isTriggerItem(event.target),
// whether the trigger item is the active item
isTriggerItemHighlighted: ({ event, scope, computed }) => {
const target = event.target ?? scope.getById(computed("highlightedId"));
return !!target?.hasAttribute("aria-controls");
},
isSubmenu: ({ computed }) => computed("isSubmenu"),
isPointerSuspended: ({ context }) => context.get("suspendPointer"),
isHighlightedItemEditable: ({ scope, computed }) => isEditableElement(scope.getById(computed("highlightedId"))),
// guard assertions (for controlled mode)
isOpenControlled: ({ prop }) => prop("open") !== void 0,
isArrowLeftEvent: ({ event }) => event.previousEvent?.type === "ARROW_LEFT",
isArrowUpEvent: ({ event }) => event.previousEvent?.type === "ARROW_UP",
isArrowDownEvent: ({ event }) => event.previousEvent?.type === "ARROW_DOWN",
isOpenAutoFocusEvent: ({ event }) => event.previousEvent?.type === "OPEN_AUTOFOCUS"
},
effects: {
waitForOpenDelay({ send }) {
const timer = setTimeout(() => {
send({ type: "DELAY.OPEN" });
}, 100);
return () => clearTimeout(timer);
},
waitForCloseDelay({ send }) {
const timer = setTimeout(() => {
send({ type: "DELAY.CLOSE" });
}, 300);
return () => clearTimeout(timer);
},
waitForLongPress({ send }) {
const timer = setTimeout(() => {
send({ type: "LONG_PRESS.OPEN" });
}, 700);
return () => clearTimeout(timer);
},
trackPositioning({ context, prop, scope, refs }) {
if (!!getContextTriggerEl(scope)) return;
const positioning = {
...prop("positioning"),
...refs.get("positioningOverride")
};
context.set("currentPlacement", positioning.placement);
const getPositionerEl2 = () => getPositionerEl(scope);
return getPlacement(getTriggerEl(scope), getPositionerEl2, {
...positioning,
defer: true,
onComplete(data) {
context.set("currentPlacement", data.placement);
}
});
},
trackInteractOutside({ refs, scope, prop, computed, send }) {
const getContentEl2 = () => getContentEl(scope);
let restoreFocus = true;
return trackDismissableElement(getContentEl2, {
defer: true,
exclude: [getTriggerEl(scope)],
onInteractOutside: prop("onInteractOutside"),
onFocusOutside(event) {
prop("onFocusOutside")?.(event);
const target = getEventTarget(event.detail.originalEvent);
const isWithinContextTrigger = contains(getContextTriggerEl(scope), target);
if (isWithinContextTrigger) {
event.preventDefault();
return;
}
},
onEscapeKeyDown(event) {
prop("onEscapeKeyDown")?.(event);
if (computed("isSubmenu")) event.preventDefault();
closeRootMenu({ parent: refs.get("parent") });
},
onPointerDownOutside(event) {
prop("onPointerDownOutside")?.(event);
const target = getEventTarget(event.detail.originalEvent);
const isWithinContextTrigger = contains(getContextTriggerEl(scope), target);
if (isWithinContextTrigger && event.detail.contextmenu) {
event.preventDefault();
return;
}
restoreFocus = !event.detail.focusable;
},
onDismiss() {
send({ type: "CLOSE", src: "interact-outside", restoreFocus });
}
});
},
trackPointerMove({ context, scope, send, refs, flush }) {
const parent = refs.get("parent");
flush(() => {
parent.context.set("suspendPointer", true);
});
const doc = scope.getDoc();
return addDomEvent(doc, "pointermove", (e) => {
const isMovingToSubmenu = isWithinPolygon(context.get("intentPolygon"), {
x: e.clientX,
y: e.clientY
});
if (!isMovingToSubmenu) {
send({ type: "POINTER_MOVED_AWAY_FROM_SUBMENU" });
parent.context.set("suspendPointer", false);
}
});
},
scrollToHighlightedItem({ event, scope, computed }) {
const exec = () => {
if (event.type.startsWith("ITEM_POINTER")) return;
const itemEl = scope.getById(computed("highlightedId"));
const contentEl2 = getContentEl(scope);
scrollIntoView(itemEl, { rootEl: contentEl2, block: "nearest" });
};
raf(() => exec());
const contentEl = () => getContentEl(scope);
return observeAttributes(contentEl, {
defer: true,
attributes: ["aria-activedescendant"],
callback: exec
});
}
},
actions: {
setAnchorPoint({ context, event }) {
context.set("anchorPoint", (prev2) => isEqual(prev2, event.point) ? prev2 : event.point);
},
setSubmenuPlacement({ computed, refs }) {
if (!computed("isSubmenu")) return;
const placement = computed("isRtl") ? "left-start" : "right-start";
refs.set("positioningOverride", { placement, gutter: 0 });
},
reposition({ context, scope, prop, event, refs }) {
const getPositionerEl2 = () => getPositionerEl(scope);
const anchorPoint = context.get("anchorPoint");
const getAnchorRect = anchorPoint ? () => ({ width: 0, height: 0, ...anchorPoint }) : void 0;
const positioning = {
...prop("positioning"),
...refs.get("positioningOverride")
};
getPlacement(getTriggerEl(scope), getPositionerEl2, {
...positioning,
defer: true,
getAnchorRect,
...event.options ?? {},
listeners: false,
onComplete(data) {
context.set("currentPlacement", data.placement);
}
});
},
setOptionState({ event }) {
if (!event.option) return;
const { checked, onCheckedChange, type } = event.option;
if (type === "radio") {
onCheckedChange?.(true);
} else if (type === "checkbox") {
onCheckedChange?.(!checked);
}
},
clickHighlightedItem({ scope, computed }) {
const itemEl = scope.getById(computed("highlightedId"));
if (!itemEl || itemEl.dataset.disabled) return;
queueMicrotask(() => itemEl.click());
},
setIntentPolygon({ context, scope, event }) {
const menu = getContentEl(scope);
const placement = context.get("currentPlacement");
if (!menu || !placement) return;
const rect = menu.getBoundingClientRect();
const polygon = getElementPolygon(rect, placement);
if (!polygon) return;
const rightSide = getPlacementSide(placement) === "right";
const bleed = rightSide ? -5 : 5;
context.set("intentPolygon", [{ ...event.point, x: event.point.x + bleed }, ...polygon]);
},
clearIntentPolygon({ context }) {
context.set("intentPolygon", null);
},
resumePointer({ refs, flush }) {
const parent = refs.get("parent");
if (!parent) return;
flush(() => {
parent.context.set("suspendPointer", false);
});
},
setHighlightedItem({ context, event }) {
const value = event.value || getItemValue(event.target);
context.set("highlightedValue", value);
},
clearHighlightedItem({ context }) {
context.set("highlightedValue", null);
},
focusMenu({ scope }) {
raf(() => {
const contentEl = getContentEl(scope);
const initialFocusEl = getInitialFocus({
root: contentEl,
enabled: !contains(contentEl, scope.getActiveElement()),
filter(node) {
return !node.role?.startsWith("menuitem");
}
});
initialFocusEl?.focus({ preventScroll: true });
});
},
highlightFirstItem({ context, scope }) {
const fn = getContentEl(scope) ? queueMicrotask : raf;
fn(() => {
const first2 = getFirstEl(scope);
if (!first2) return;
context.set("highlightedValue", getItemValue(first2));
});
},
highlightLastItem({ context, scope }) {
const fn = getContentEl(scope) ? queueMicrotask : raf;
fn(() => {
const last2 = getLastEl(scope);
if (!last2) return;
context.set("highlightedValue", getItemValue(last2));
});
},
highlightNextItem({ context, scope, event, prop }) {
const next2 = getNextEl(scope, {
loop: event.loop,
value: context.get("highlightedValue"),
loopFocus: prop("loopFocus")
});
context.set("highlightedValue", getItemValue(next2));
},
highlightPrevItem({ context, scope, event, prop }) {
const prev2 = getPrevEl(scope, {
loop: event.loop,
value: context.get("highlightedValue"),
loopFocus: prop("loopFocus")
});
context.set("highlightedValue", getItemValue(prev2));
},
invokeOnSelect({ context, prop, scope }) {
const value = context.get("highlightedValue");
if (value == null) return;
const node = getItemEl(scope, value);
dispatchSelectionEvent(node, value);
prop("onSelect")?.({ value });
},
focusTrigger({ scope, context, event, computed }) {
if (computed("isSubmenu") || context.get("anchorPoint") || event.restoreFocus === false) return;
queueMicrotask(() => getTriggerEl(scope)?.focus({ preventScroll: true }));
},
highlightMatchedItem({ scope, context, event, refs }) {
const node = getElemByKey(scope, {
key: event.key,
value: context.get("highlightedValue"),
typeaheadState: refs.get("typeaheadState")
});
if (!node) return;
context.set("highlightedValue", getItemValue(node));
},
setParentMenu({ refs, event }) {
refs.set("parent", event.value);
},
setChildMenu({ refs, event }) {
const children = refs.get("children");
children[event.id] = event.value;
refs.set("children", children);
},
closeRootMenu({ refs }) {
closeRootMenu({ parent: refs.get("parent") });
},
openSubmenu({ refs, scope, computed }) {
const item = scope.getById(computed("highlightedId"));
const id = item?.getAttribute("data-uid");
const children = refs.get("children");
const child = id ? children[id] : null;
child?.send({ type: "OPEN_AUTOFOCUS" });
},
focusParentMenu({ refs }) {
refs.get("parent")?.send({ type: "FOCUS_MENU" });
},
setLastHighlightedItem({ context, event }) {
context.set("lastHighlightedValue", getItemValue(event.target));
},
restoreHighlightedItem({ context }) {
if (!context.get("lastHighlightedValue")) return;
context.set("highlightedValue", context.get("lastHighlightedValue"));
context.set("lastHighlightedValue", null);
},
restoreParentHighlightedItem({ refs }) {
refs.get("parent")?.send({ type: "HIGHLIGHTED.RESTORE" });
},
invokeOnOpen({ prop }) {
prop("onOpenChange")?.({ open: true });
},
invokeOnClose({ prop }) {
prop("onOpenChange")?.({ open: false });
},
toggleVisibility({ prop, event, send }) {
send({
type: prop("open") ? "CONTROLLED.OPEN" : "CONTROLLED.CLOSE",
previousEvent: event
});
}
}
}
});
function closeRootMenu(ctx) {
let parent = ctx.parent;
while (parent && parent.computed("isSubmenu")) {
parent = parent.refs.get("parent");
}
parent?.send({ type: "CLOSE" });
}
function isWithinPolygon(polygon, point) {
if (!polygon) return false;
return isPointInPolygon(polygon, point);
}
function resolveItemId(children, value, scope) {
const hasChildren = Object.keys(children).length > 0;
if (!value) return null;
if (!hasChildren) {
return getItemId(scope, value);
}
for (const id in children) {
const childMenu = children[id];
const childTriggerId = getTriggerId(childMenu.scope);
if (childTriggerId === value) {
return childTriggerId;
}
}
return getItemId(scope, value);
}
var props = createProps()([
"anchorPoint",
"aria-label",
"closeOnSelect",
"composite",
"defaultHighlightedValue",
"defaultOpen",
"dir",
"getRootNode",
"highlightedValue",
"id",
"ids",
"loopFocus",
"navigate",
"onEscapeKeyDown",
"onFocusOutside",
"onHighlightChange",
"onInteractOutside",
"onOpenChange",
"onPointerDownOutside",
"onSelect",
"open",
"positioning",
"typeahead"
]);
var splitProps = createSplitProps(props);
var itemProps = createProps()(["closeOnSelect", "disabled", "value", "valueText"]);
var splitItemProps = createSplitProps(itemProps);
var itemGroupLabelProps = createProps()(["htmlFor"]);
var splitItemGroupLabelProps = createSplitProps(itemGroupLabelProps);
var itemGroupProps = createProps()(["id"]);
var splitItemGroupProps = createSplitProps(itemGroupProps);
var optionItemProps = createProps()([
"checked",
"closeOnSelect",
"disabled",
"onCheckedChange",
"type",
"value",
"valueText"
]);
var splitOptionItemProps = createSplitProps(optionItemProps);
export { anatomy, connect, itemGroupLabelProps, itemGroupProps, itemProps, machine, optionItemProps, props, splitItemGroupLabelProps, splitItemGroupProps, splitItemProps, splitOptionItemProps, splitProps };