diff --git a/.xstate/menu.js b/.xstate/menu.js index d3910c2fb0..e9c9bc02c6 100644 --- a/.xstate/menu.js +++ b/.xstate/menu.js @@ -14,18 +14,38 @@ const fetchMachine = createMachine({ initial: ctx.open ? "open" : "idle", context: { "isOpenControlled": false, + "isOpenControlled": false, + "isOpenControlled": false, + "isOpenControlled": false, + "isOpenControlled": false, + "!isSubmenu && isOpenControlled": false, "!isSubmenu": false, "isSubmenu": false, + "isOpenControlled": false, + "isOpenControlled": false, + "isOpenControlled": false, + "isOpenControlled": false, + "isOpenControlled": false, + "isOpenControlled": false, + "isOpenControlled": false, + "isOpenControlled": false, + "isOpenControlled": false, "isTriggerItem": false, + "isOpenControlled": false, + "isOpenControlled": false, + "!isTriggerItem && isOpenControlled": false, "!isTriggerItem": false, "isForwardTabNavigation": false, + "isSubmenu && isOpenControlled": false, "isSubmenu": false, "isTriggerItemHighlighted": false, "isTriggerItemHighlighted": false, + "closeOnSelect && isOpenControlled": false, "closeOnSelect": false, "!suspendPointer && !isTargetFocused": false, "!isTargetFocused": false, "!suspendPointer && !isTriggerItem": false, + "!isTriggerItemHighlighted && !isHighlightedItemEditable && closeOnSelect && isOpenControlled": false, "!isTriggerItemHighlighted && !isHighlightedItemEditable && closeOnSelect": false, "!isTriggerItemHighlighted && !isHighlightedItemEditable": false }, @@ -43,15 +63,22 @@ const fetchMachine = createMachine({ target: "open", actions: "invokeOnOpen" }], - OPEN_AUTOFOCUS: { + OPEN_AUTOFOCUS: [{ + internal: true, + cond: "isOpenControlled", + actions: ["invokeOnOpen"] + }, { internal: true, target: "open", actions: ["highlightFirstItem", "invokeOnOpen"] - }, - CLOSE: { + }], + CLOSE: [{ + cond: "isOpenControlled", + actions: "invokeOnClose" + }, { target: "closed", actions: "invokeOnClose" - }, + }], RESTORE_FOCUS: { actions: "restoreFocus" }, @@ -71,22 +98,33 @@ const fetchMachine = createMachine({ idle: { tags: ["closed"], on: { + "CONTROLLED.OPEN": "open", + "CONTROLLED.CLOSE": "closed", CONTEXT_MENU_START: { target: "opening:contextmenu", actions: "setAnchorPoint" }, - CONTEXT_MENU: { + CONTEXT_MENU: [{ + cond: "isOpenControlled", + actions: ["setAnchorPoint", "invokeOnOpen"] + }, { target: "open", actions: ["setAnchorPoint", "invokeOnOpen"] - }, - TRIGGER_CLICK: { + }], + TRIGGER_CLICK: [{ + cond: "isOpenControlled", + actions: "invokeOnOpen" + }, { target: "open", actions: "invokeOnOpen" - }, - TRIGGER_FOCUS: { + }], + TRIGGER_FOCUS: [{ + cond: "!isSubmenu && isOpenControlled", + actions: "invokeOnClose" + }, { cond: "!isSubmenu", target: "closed" - }, + }], TRIGGER_POINTERMOVE: { cond: "isSubmenu", target: "opening" @@ -96,86 +134,134 @@ const fetchMachine = createMachine({ "opening:contextmenu": { tags: ["closed"], after: { - LONG_PRESS_DELAY: { + LONG_PRESS_DELAY: [{ + cond: "isOpenControlled", + actions: "invokeOnOpen" + }, { target: "open", actions: "invokeOnOpen" - } + }] }, on: { - CONTEXT_MENU_CANCEL: { + "CONTROLLED.OPEN": "open", + "CONTROLLED.CLOSE": "closed", + CONTEXT_MENU_CANCEL: [{ + cond: "isOpenControlled", + actions: "invokeOnClose" + }, { target: "closed", actions: "invokeOnClose" - } + }] } }, opening: { tags: ["closed"], after: { - SUBMENU_OPEN_DELAY: { + SUBMENU_OPEN_DELAY: [{ + cond: "isOpenControlled", + actions: "invokeOnOpen" + }, { target: "open", actions: "invokeOnOpen" - } + }] }, on: { - BLUR: { + "CONTROLLED.OPEN": "open", + "CONTROLLED.CLOSE": "closed", + BLUR: [{ + cond: "isOpenControlled", + actions: "invokeOnClose" + }, { target: "closed", actions: "invokeOnClose" - }, - TRIGGER_POINTERLEAVE: { + }], + TRIGGER_POINTERLEAVE: [{ + cond: "isOpenControlled", + actions: "invokeOnClose" + }, { target: "closed", actions: "invokeOnClose" - } + }] } }, closing: { tags: ["open"], activities: ["trackPointerMove", "trackInteractOutside"], after: { - SUBMENU_CLOSE_DELAY: { + SUBMENU_CLOSE_DELAY: [{ + cond: "isOpenControlled", + actions: ["invokeOnClose"] + }, { target: "closed", actions: ["focusParentMenu", "restoreParentFocus", "invokeOnClose"] - } + }] }, on: { + "CONTROLLED.OPEN": { + target: "open" + }, + "CONTROLLED.CLOSE": { + target: "closed", + actions: ["focusParentMenu", "restoreParentFocus"] + }, + // 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: { + POINTER_MOVED_AWAY_FROM_SUBMENU: [{ + cond: "isOpenControlled", + actions: "invokeOnClose" + }, { target: "closed", actions: ["focusParentMenu", "restoreParentFocus"] - } + }] } }, closed: { tags: ["closed"], entry: ["clearHighlightedItem", "focusTrigger", "clearAnchorPoint", "resumePointer"], on: { + "CONTROLLED.OPEN": { + target: "open", + actions: ["highlightBasedOnPreviousEvent"] + }, CONTEXT_MENU_START: { target: "opening:contextmenu", actions: "setAnchorPoint" }, - CONTEXT_MENU: { + CONTEXT_MENU: [{ + cond: "isOpenControlled", + actions: ["setAnchorPoint", "invokeOnOpen"] + }, { target: "open", actions: ["setAnchorPoint", "invokeOnOpen"] - }, - TRIGGER_CLICK: { + }], + TRIGGER_CLICK: [{ + cond: "isOpenControlled", + actions: "invokeOnOpen" + }, { target: "open", actions: "invokeOnOpen" - }, + }], TRIGGER_POINTERMOVE: { cond: "isTriggerItem", target: "opening" }, - TRIGGER_BLUR: "idle", - ARROW_DOWN: { + ARROW_DOWN: [{ + cond: "isOpenControlled", + actions: "invokeOnOpen" + }, { target: "open", actions: ["highlightFirstItem", "invokeOnOpen"] - }, - ARROW_UP: { + }], + ARROW_UP: [{ + cond: "isOpenControlled", + actions: "invokeOnOpen" + }, { target: "open", actions: ["highlightLastItem", "invokeOnOpen"] - } + }] } }, open: { @@ -183,11 +269,18 @@ const fetchMachine = createMachine({ activities: ["trackInteractOutside", "trackPositioning", "scrollToHighlightedItem"], entry: ["focusMenu", "resumePointer"], on: { - TRIGGER_CLICK: { + "CONTROLLED.CLOSE": { + target: "closed", + actions: ["focusBasedOnPreviousEvent"] + }, + TRIGGER_CLICK: [{ + cond: "!isTriggerItem && isOpenControlled", + actions: "invokeOnClose" + }, { cond: "!isTriggerItem", target: "closed", actions: "invokeOnClose" - }, + }], TAB: [{ cond: "isForwardTabNavigation", actions: ["highlightNextItem"] @@ -200,21 +293,20 @@ const fetchMachine = createMachine({ ARROW_DOWN: { actions: ["highlightNextItem", "focusMenu"] }, - ARROW_LEFT: { + ARROW_LEFT: [{ + cond: "isSubmenu && isOpenControlled", + actions: "invokeOnClose" + }, { cond: "isSubmenu", target: "closed", actions: ["focusParentMenu", "invokeOnClose"] - }, + }], HOME: { actions: ["highlightFirstItem", "focusMenu"] }, END: { actions: ["highlightLastItem", "focusMenu"] }, - REQUEST_CLOSE: { - target: "closed", - actions: "invokeOnClose" - }, ARROW_RIGHT: { cond: "isTriggerItemHighlighted", actions: "openSubmenu" @@ -222,11 +314,18 @@ const fetchMachine = createMachine({ ENTER: [{ cond: "isTriggerItemHighlighted", actions: "openSubmenu" + }, + // == grouped == + { + cond: "closeOnSelect && isOpenControlled", + actions: ["clickHighlightedItem", "invokeOnClose"] }, { cond: "closeOnSelect", target: "closed", actions: "clickHighlightedItem" - }, { + }, + // + { actions: "clickHighlightedItem" }], ITEM_POINTERMOVE: [{ @@ -240,11 +339,18 @@ const fetchMachine = createMachine({ cond: "!suspendPointer && !isTriggerItem", actions: "clearHighlightedItem" }, - ITEM_CLICK: [{ + ITEM_CLICK: [ + // == grouped == + { + cond: "!isTriggerItemHighlighted && !isHighlightedItemEditable && closeOnSelect && isOpenControlled", + actions: ["invokeOnSelect", "changeOptionValue", "invokeOnValueChange", "closeRootMenu", "invokeOnClose"] + }, { cond: "!isTriggerItemHighlighted && !isHighlightedItemEditable && closeOnSelect", target: "closed", actions: ["invokeOnSelect", "changeOptionValue", "invokeOnValueChange", "closeRootMenu", "invokeOnClose"] - }, { + }, + // + { cond: "!isTriggerItemHighlighted && !isHighlightedItemEditable", actions: ["invokeOnSelect", "changeOptionValue", "invokeOnValueChange"] }, { @@ -284,16 +390,21 @@ const fetchMachine = createMachine({ }, guards: { "isOpenControlled": ctx => ctx["isOpenControlled"], + "!isSubmenu && isOpenControlled": ctx => ctx["!isSubmenu && isOpenControlled"], "!isSubmenu": ctx => ctx["!isSubmenu"], "isSubmenu": ctx => ctx["isSubmenu"], "isTriggerItem": ctx => ctx["isTriggerItem"], + "!isTriggerItem && isOpenControlled": ctx => ctx["!isTriggerItem && isOpenControlled"], "!isTriggerItem": ctx => ctx["!isTriggerItem"], "isForwardTabNavigation": ctx => ctx["isForwardTabNavigation"], + "isSubmenu && isOpenControlled": ctx => ctx["isSubmenu && isOpenControlled"], "isTriggerItemHighlighted": ctx => ctx["isTriggerItemHighlighted"], + "closeOnSelect && isOpenControlled": ctx => ctx["closeOnSelect && isOpenControlled"], "closeOnSelect": ctx => ctx["closeOnSelect"], "!suspendPointer && !isTargetFocused": ctx => ctx["!suspendPointer && !isTargetFocused"], "!isTargetFocused": ctx => ctx["!isTargetFocused"], "!suspendPointer && !isTriggerItem": ctx => ctx["!suspendPointer && !isTriggerItem"], + "!isTriggerItemHighlighted && !isHighlightedItemEditable && closeOnSelect && isOpenControlled": ctx => ctx["!isTriggerItemHighlighted && !isHighlightedItemEditable && closeOnSelect && isOpenControlled"], "!isTriggerItemHighlighted && !isHighlightedItemEditable && closeOnSelect": ctx => ctx["!isTriggerItemHighlighted && !isHighlightedItemEditable && closeOnSelect"], "!isTriggerItemHighlighted && !isHighlightedItemEditable": ctx => ctx["!isTriggerItemHighlighted && !isHighlightedItemEditable"] } diff --git a/examples/next-app/app/layout.tsx b/examples/next-app/app/layout.tsx index 236a9497de..626093a197 100644 --- a/examples/next-app/app/layout.tsx +++ b/examples/next-app/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next" import { Inter } from "next/font/google" import "../../../shared/src/style.css" +import Link from "next/link" const inter = Inter({ subsets: ["latin"] }) @@ -12,7 +13,22 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} +
+ + ⬅ Back to Home + +
+ ) } diff --git a/examples/next-app/app/menu/controlled/page.tsx b/examples/next-app/app/menu/controlled/page.tsx new file mode 100644 index 0000000000..392f011150 --- /dev/null +++ b/examples/next-app/app/menu/controlled/page.tsx @@ -0,0 +1,37 @@ +"use client" + +import { Menu } from "@/components/menu" +import { useState } from "react" + +export default function Page() { + const [open, setOpen] = useState(false) + + return ( +
+

Menu Controlled

+

{String(open)}

+ +
+ + +
+ + setOpen(open)} + items={[ + { label: "Edit", value: "edit" }, + { label: "Duplicate", value: "duplicate" }, + { label: "Delete", value: "delete" }, + { label: "Export...", value: "export" }, + ]} + > + + +
+ ) +} diff --git a/examples/next-app/app/page.tsx b/examples/next-app/app/page.tsx index 4ca80c1058..c1d63882b0 100644 --- a/examples/next-app/app/page.tsx +++ b/examples/next-app/app/page.tsx @@ -10,6 +10,7 @@ const routes = [ { path: "/date-picker/controlled-range-picker", name: "DateRangePicker - Controlled" }, { path: "/hover-card/controlled", name: "HoverCard - Controlled" }, { path: "/tooltip/controlled", name: "Tooltip - Controlled" }, + { path: "/menu/controlled", name: "Menu - Controlled" }, ] export default function Page() { @@ -18,8 +19,8 @@ export default function Page() {

Zag.js + Next App

diff --git a/examples/next-app/components/color-picker.tsx b/examples/next-app/components/color-picker.tsx index 49fbad9077..14f6d4470e 100644 --- a/examples/next-app/components/color-picker.tsx +++ b/examples/next-app/components/color-picker.tsx @@ -25,22 +25,22 @@ interface Props extends Omit { } export function ColorPicker(props: Props) { - const { defaultOpen, defaultValue, open, value, ...contextProps } = props + const { defaultOpen, defaultValue = colorPicker.parse("hsl(0, 100%, 50%)"), open, value, ...contextProps } = props const [state, send] = useMachine( colorPicker.machine({ id: useId(), name: "color", format: "hsla", - value: colorPicker.parse("hsl(0, 100%, 50%)"), + value: value ?? defaultValue, open: open ?? defaultOpen, }), { context: { ...contextProps, __controlled: open !== undefined, - open: open ?? defaultOpen, - value: value ?? defaultValue, + open, + value, }, }, ) diff --git a/examples/next-app/components/date-range-picker.tsx b/examples/next-app/components/date-range-picker.tsx index 9f98deabe4..af4add54e9 100644 --- a/examples/next-app/components/date-range-picker.tsx +++ b/examples/next-app/components/date-range-picker.tsx @@ -24,8 +24,8 @@ export function DateRangePicker(props: Props) { ...contextProps, selectionMode: "range", __controlled: open !== undefined, - open: open ?? defaultOpen, - value: value ?? defaultValue, + open, + value, }, }, ) diff --git a/examples/next-app/components/dialog.tsx b/examples/next-app/components/dialog.tsx index 802ae009eb..15cfdc3e47 100644 --- a/examples/next-app/components/dialog.tsx +++ b/examples/next-app/components/dialog.tsx @@ -18,7 +18,7 @@ export function Dialog(props: Props) { context: { ...context, __controlled: open !== undefined, - open: open ?? defaultOpen, + open, }, }, ) diff --git a/examples/next-app/components/hover-card.tsx b/examples/next-app/components/hover-card.tsx index 941c5cf301..6b30a77d52 100644 --- a/examples/next-app/components/hover-card.tsx +++ b/examples/next-app/components/hover-card.tsx @@ -19,7 +19,7 @@ export function HoverCard(props: Props) { context: { ...context, __controlled: open !== undefined, - open: open ?? defaultOpen, + open, }, }, ) diff --git a/examples/next-app/components/menu.tsx b/examples/next-app/components/menu.tsx new file mode 100644 index 0000000000..fb0072fa5f --- /dev/null +++ b/examples/next-app/components/menu.tsx @@ -0,0 +1,46 @@ +import * as menu from "@zag-js/menu" +import { normalizeProps, Portal, useMachine } from "@zag-js/react" +import { cloneElement, isValidElement, useId } from "react" + +interface Props extends Omit { + defaultOpen?: boolean + children: React.ReactNode + items: Array<{ value: string; label: React.ReactNode }> +} + +export function Menu(props: Props) { + const { defaultOpen, open, items, children, ...context } = props + + const [state, send] = useMachine( + menu.machine({ + id: useId(), + open: open ?? defaultOpen, + }), + { + context: { + ...context, + open, + __controlled: open !== undefined, + }, + }, + ) + + const api = menu.connect(state, send, normalizeProps) + + return ( +
+ {isValidElement(children) ? cloneElement(children, api.triggerProps) : children} + +
+
    + {items.map((item) => ( +
  • + {item.label} +
  • + ))} +
+
+
+
+ ) +} diff --git a/examples/next-app/components/popover.tsx b/examples/next-app/components/popover.tsx index d3faa83799..863e0a2321 100644 --- a/examples/next-app/components/popover.tsx +++ b/examples/next-app/components/popover.tsx @@ -18,7 +18,7 @@ export function Popover(props: Props) { context: { ...context, __controlled: open !== undefined, - open: open ?? defaultOpen, + open, }, }, ) diff --git a/examples/next-app/components/select.tsx b/examples/next-app/components/select.tsx index d3d7c266df..5232986e7d 100644 --- a/examples/next-app/components/select.tsx +++ b/examples/next-app/components/select.tsx @@ -20,6 +20,8 @@ interface SelectProps extends Omit void } +const toArray = (value: string | null | undefined) => (value ? [value] : undefined) + export function Select(props: SelectProps) { const { value, defaultValue, onValueChange, defaultOpen, open, ...contextProps } = props @@ -27,15 +29,15 @@ export function Select(props: SelectProps) { select.machine({ id: useId(), collection, - value: defaultValue ? [defaultValue] : undefined, + value: toArray(value) ?? toArray(defaultValue), open: open ?? defaultOpen, }), { context: { ...contextProps, - open: open ?? defaultOpen, + open, __controlled: open !== undefined, - value: value ? [value] : undefined, + value: toArray(value), onValueChange(details: any) { onValueChange?.(details.value[0]) }, diff --git a/examples/next-app/components/tooltip.tsx b/examples/next-app/components/tooltip.tsx index dc524f8aea..21ed2db45b 100644 --- a/examples/next-app/components/tooltip.tsx +++ b/examples/next-app/components/tooltip.tsx @@ -19,8 +19,8 @@ export function Tooltip(props: Props) { { context: { ...context, - open: open ?? defaultOpen, __controlled: open !== undefined, + open, }, }, ) diff --git a/packages/machines/menu/src/menu.connect.ts b/packages/machines/menu/src/menu.connect.ts index 193c5db242..569f63e093 100644 --- a/packages/machines/menu/src/menu.connect.ts +++ b/packages/machines/menu/src/menu.connect.ts @@ -21,7 +21,7 @@ export function connect(state: State, send: Send, normalize const values = state.context.value const isTypingAhead = state.context.isTypingAhead - const isOpen = state.hasTag("visible") + const isOpen = state.hasTag("open") const popperStyles = getPlacementStyles({ ...state.context.positioning, @@ -111,17 +111,17 @@ export function connect(state: State, send: Send, normalize send("CLOSE") }, setHighlightedId(id) { - send({ type: "SET_HIGHLIGHTED_ID", id }) + send({ type: "HIGHLIGHTED.SET", id }) }, setParent(parent) { - send({ type: "SET_PARENT", value: parent, id: parent.state.context.id }) + send({ type: "PARENT.SET", value: parent, id: parent.state.context.id }) }, setChild(child) { - send({ type: "SET_CHILD", value: child, id: child.state.context.id }) + send({ type: "CHILD.SET", value: child, id: child.state.context.id }) }, value: values, setValue(name, value) { - send({ type: "SET_VALUE", name, value }) + send({ type: "VALUE.SET", name, value }) }, reposition(options = {}) { send({ type: "POSITIONING.SET", options }) @@ -206,9 +206,6 @@ export function connect(state: State, send: Send, normalize send({ type: "TRIGGER_CLICK", target: event.currentTarget }) } }, - onBlur() { - send("TRIGGER_BLUR") - }, onFocus() { send("TRIGGER_FOCUS") }, @@ -286,7 +283,7 @@ export function connect(state: State, send: Send, normalize if (!isSelfEvent(event) || !isKeyDownInside) return - const item = dom.getFocusedItem(state.context) + const item = dom.getHighlightedItemEl(state.context) const isLink = !!item?.matches("a[href]") const keyMap: EventKeyMap = { diff --git a/packages/machines/menu/src/menu.dom.ts b/packages/machines/menu/src/menu.dom.ts index 6d591edbca..8e7ecef471 100644 --- a/packages/machines/menu/src/menu.dom.ts +++ b/packages/machines/menu/src/menu.dom.ts @@ -14,7 +14,7 @@ export const dom = createScope({ getContentEl: (ctx: Ctx) => dom.getById(ctx, dom.getContentId(ctx)), getPositionerEl: (ctx: Ctx) => dom.getById(ctx, dom.getPositionerId(ctx)), getTriggerEl: (ctx: Ctx) => dom.getById(ctx, dom.getTriggerId(ctx)), - getFocusedItem: (ctx: Ctx) => (ctx.highlightedId ? dom.getById(ctx, ctx.highlightedId) : null), + getHighlightedItemEl: (ctx: Ctx) => (ctx.highlightedId ? dom.getById(ctx, ctx.highlightedId) : null), getArrowEl: (ctx: Ctx) => dom.getById(ctx, dom.getArrowId(ctx)), getElements: (ctx: Ctx) => { @@ -36,8 +36,14 @@ export const dom = createScope({ isTriggerItem: (el: HTMLElement | null) => { return !!el?.getAttribute("role")?.startsWith("menuitem") && !!el?.hasAttribute("aria-controls") }, - getHighlightedOptionEl(ctx: Ctx) { - if (!ctx.highlightedId) return null - return dom.getById(ctx, ctx.highlightedId) + + getOptionFromItemEl(el: HTMLElement) { + return { + id: el.id, + name: el.dataset.name, + value: el.dataset.value, + valueText: el.dataset.valueText, + type: el.dataset.type, + } }, }) diff --git a/packages/machines/menu/src/menu.machine.ts b/packages/machines/menu/src/menu.machine.ts index ececef1cc0..a151487cbd 100644 --- a/packages/machines/menu/src/menu.machine.ts +++ b/packages/machines/menu/src/menu.machine.ts @@ -27,7 +27,7 @@ export function machine(userContext: UserDefinedContext) { suspendPointer: false, anchorPoint: null, closeOnSelect: true, - focusTriggerOnClose: true, + restoreFocus: true, ...ctx, typeahead: getByTypeahead.defaultOptions, positioning: { @@ -50,55 +50,95 @@ export function machine(userContext: UserDefinedContext) { }, on: { - SET_PARENT: { + "PARENT.SET": { actions: "setParentMenu", }, - SET_CHILD: { + "CHILD.SET": { actions: "setChildMenu", }, - OPEN: { - target: "open", - actions: "invokeOnOpen", - }, - OPEN_AUTOFOCUS: { - internal: true, - target: "open", - actions: ["focusFirstItem", "invokeOnOpen"], - }, - CLOSE: { - target: "closed", - actions: "invokeOnClose", - }, + OPEN: [ + { + guard: "isOpenControlled", + actions: "invokeOnOpen", + }, + { + target: "open", + actions: "invokeOnOpen", + }, + ], + OPEN_AUTOFOCUS: [ + { + internal: true, + guard: "isOpenControlled", + actions: ["invokeOnOpen"], + }, + { + internal: true, + target: "open", + actions: ["highlightFirstItem", "invokeOnOpen"], + }, + ], + CLOSE: [ + { + guard: "isOpenControlled", + actions: "invokeOnClose", + }, + { + target: "closed", + actions: "invokeOnClose", + }, + ], RESTORE_FOCUS: { actions: "restoreFocus", }, - SET_VALUE: { + "VALUE.SET": { actions: ["setOptionValue", "invokeOnValueChange"], }, - SET_HIGHLIGHTED_ID: { - actions: "setFocusedItem", + "HIGHLIGHTED.SET": { + actions: "setHighlightedItem", }, }, states: { idle: { + tags: ["closed"], on: { + "CONTROLLED.OPEN": "open", + "CONTROLLED.CLOSE": "closed", CONTEXT_MENU_START: { target: "opening:contextmenu", actions: "setAnchorPoint", }, - CONTEXT_MENU: { - target: "open", - actions: ["setAnchorPoint", "invokeOnOpen"], - }, - TRIGGER_CLICK: { - target: "open", - actions: "invokeOnOpen", - }, - TRIGGER_FOCUS: { - guard: not("isSubmenu"), - target: "closed", - }, + 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: and(not("isSubmenu"), "isOpenControlled"), + actions: "invokeOnClose", + }, + { + guard: not("isSubmenu"), + target: "closed", + }, + ], TRIGGER_POINTERMOVE: { guard: "isSubmenu", target: "opening", @@ -107,151 +147,255 @@ export function machine(userContext: UserDefinedContext) { }, "opening:contextmenu": { + tags: ["closed"], after: { - LONG_PRESS_DELAY: { - target: "open", - actions: "invokeOnOpen", - }, + LONG_PRESS_DELAY: [ + { + guard: "isOpenControlled", + actions: "invokeOnOpen", + }, + { + target: "open", + actions: "invokeOnOpen", + }, + ], }, on: { - CONTEXT_MENU_CANCEL: { - target: "closed", - actions: "invokeOnClose", - }, + "CONTROLLED.OPEN": "open", + "CONTROLLED.CLOSE": "closed", + CONTEXT_MENU_CANCEL: [ + { + guard: "isOpenControlled", + actions: "invokeOnClose", + }, + { + target: "closed", + actions: "invokeOnClose", + }, + ], }, }, opening: { + tags: ["closed"], after: { - SUBMENU_OPEN_DELAY: { - target: "open", - actions: "invokeOnOpen", - }, + SUBMENU_OPEN_DELAY: [ + { + guard: "isOpenControlled", + actions: "invokeOnOpen", + }, + { + target: "open", + actions: "invokeOnOpen", + }, + ], }, on: { - BLUR: { - target: "closed", - actions: "invokeOnClose", - }, - TRIGGER_POINTERLEAVE: { - target: "closed", - actions: "invokeOnClose", - }, + "CONTROLLED.OPEN": "open", + "CONTROLLED.CLOSE": "closed", + BLUR: [ + { + guard: "isOpenControlled", + actions: "invokeOnClose", + }, + { + target: "closed", + actions: "invokeOnClose", + }, + ], + TRIGGER_POINTERLEAVE: [ + { + guard: "isOpenControlled", + actions: "invokeOnClose", + }, + { + target: "closed", + actions: "invokeOnClose", + }, + ], }, }, closing: { - tags: ["visible"], + tags: ["open"], activities: ["trackPointerMove", "trackInteractOutside"], after: { - SUBMENU_CLOSE_DELAY: { - target: "closed", - actions: ["focusParentMenu", "restoreParentFocus", "invokeOnClose"], - }, + SUBMENU_CLOSE_DELAY: [ + { + guard: "isOpenControlled", + actions: ["invokeOnClose"], + }, + { + target: "closed", + actions: ["focusParentMenu", "restoreParentFocus", "invokeOnClose"], + }, + ], }, on: { - MENU_POINTERENTER: { + "CONTROLLED.OPEN": { target: "open", - actions: "clearIntentPolygon", }, - POINTER_MOVED_AWAY_FROM_SUBMENU: { + "CONTROLLED.CLOSE": { target: "closed", actions: ["focusParentMenu", "restoreParentFocus"], }, + // 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", "restoreParentFocus"], + }, + ], }, }, closed: { - entry: ["clearFocusedItem", "focusTrigger", "clearAnchorPoint", "resumePointer"], + tags: ["closed"], + entry: ["clearHighlightedItem", "focusTrigger", "clearAnchorPoint", "resumePointer"], on: { + "CONTROLLED.OPEN": { + target: "open", + actions: ["highlightBasedOnPreviousEvent"], + }, CONTEXT_MENU_START: { target: "opening:contextmenu", actions: "setAnchorPoint", }, - CONTEXT_MENU: { - target: "open", - actions: ["setAnchorPoint", "invokeOnOpen"], - }, - TRIGGER_CLICK: { - target: "open", - actions: "invokeOnOpen", - }, + 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: "idle", - ARROW_DOWN: { - target: "open", - actions: ["focusFirstItem", "invokeOnOpen"], - }, - ARROW_UP: { - target: "open", - actions: ["focusLastItem", "invokeOnOpen"], - }, + ARROW_DOWN: [ + { + guard: "isOpenControlled", + actions: "invokeOnOpen", + }, + { + target: "open", + actions: ["highlightFirstItem", "invokeOnOpen"], + }, + ], + ARROW_UP: [ + { + guard: "isOpenControlled", + actions: "invokeOnOpen", + }, + { + target: "open", + actions: ["highlightLastItem", "invokeOnOpen"], + }, + ], }, }, open: { - tags: ["visible"], + tags: ["open"], activities: ["trackInteractOutside", "trackPositioning", "scrollToHighlightedItem"], entry: ["focusMenu", "resumePointer"], on: { - TRIGGER_CLICK: { - guard: not("isTriggerItem"), + "CONTROLLED.CLOSE": { target: "closed", - actions: "invokeOnClose", + actions: ["focusBasedOnPreviousEvent"], }, + TRIGGER_CLICK: [ + { + guard: and(not("isTriggerItem"), "isOpenControlled"), + actions: "invokeOnClose", + }, + { + guard: not("isTriggerItem"), + target: "closed", + actions: "invokeOnClose", + }, + ], TAB: [ { guard: "isForwardTabNavigation", - actions: ["focusNextItem"], + actions: ["highlightNextItem"], + }, + { + actions: ["highlightPrevItem"], }, - { actions: ["focusPrevItem"] }, ], ARROW_UP: { - actions: ["focusPrevItem", "focusMenu"], + actions: ["highlightPrevItem", "focusMenu"], }, ARROW_DOWN: { - actions: ["focusNextItem", "focusMenu"], - }, - ARROW_LEFT: { - guard: "isSubmenu", - target: "closed", - actions: ["focusParentMenu", "invokeOnClose"], + actions: ["highlightNextItem", "focusMenu"], }, + ARROW_LEFT: [ + { + guard: and("isSubmenu", "isOpenControlled"), + actions: "invokeOnClose", + }, + { + guard: "isSubmenu", + target: "closed", + actions: ["focusParentMenu", "invokeOnClose"], + }, + ], HOME: { - actions: ["focusFirstItem", "focusMenu"], + actions: ["highlightFirstItem", "focusMenu"], }, END: { - actions: ["focusLastItem", "focusMenu"], - }, - REQUEST_CLOSE: { - target: "closed", - actions: "invokeOnClose", + actions: ["highlightLastItem", "focusMenu"], }, ARROW_RIGHT: { - guard: "isTriggerItemFocused", + guard: "isTriggerItemHighlighted", actions: "openSubmenu", }, ENTER: [ { - guard: "isTriggerItemFocused", + guard: "isTriggerItemHighlighted", actions: "openSubmenu", }, + // == grouped == + { + guard: and("closeOnSelect", "isOpenControlled"), + actions: ["clickHighlightedItem", "invokeOnClose"], + }, { guard: "closeOnSelect", target: "closed", - actions: "clickFocusedItem", + actions: "clickHighlightedItem", }, + // { - actions: "clickFocusedItem", + actions: "clickHighlightedItem", }, ], ITEM_POINTERMOVE: [ { guard: and(not("suspendPointer"), not("isTargetFocused")), - actions: ["focusItem", "focusMenu"], + actions: ["highlightItem", "focusMenu"], }, { guard: not("isTargetFocused"), @@ -260,11 +404,27 @@ export function machine(userContext: UserDefinedContext) { ], ITEM_POINTERLEAVE: { guard: and(not("suspendPointer"), not("isTriggerItem")), - actions: "clearFocusedItem", + actions: "clearHighlightedItem", }, ITEM_CLICK: [ + // == grouped == + { + guard: and( + not("isTriggerItemHighlighted"), + not("isHighlightedItemEditable"), + "closeOnSelect", + "isOpenControlled", + ), + actions: [ + "invokeOnSelect", + "changeOptionValue", + "invokeOnValueChange", + "closeRootMenu", + "invokeOnClose", + ], + }, { - guard: and(not("isTriggerItemFocused"), not("isFocusedItemEditable"), "closeOnSelect"), + guard: and(not("isTriggerItemHighlighted"), not("isHighlightedItemEditable"), "closeOnSelect"), target: "closed", actions: [ "invokeOnSelect", @@ -274,21 +434,22 @@ export function machine(userContext: UserDefinedContext) { "invokeOnClose", ], }, + // { - guard: and(not("isTriggerItemFocused"), not("isFocusedItemEditable")), + guard: and(not("isTriggerItemHighlighted"), not("isHighlightedItemEditable")), actions: ["invokeOnSelect", "changeOptionValue", "invokeOnValueChange"], }, - { actions: "focusItem" }, + { actions: "highlightItem" }, ], TRIGGER_POINTERLEAVE: { target: "closing", actions: "setIntentPolygon", }, ITEM_POINTERDOWN: { - actions: "focusItem", + actions: "highlightItem", }, TYPEAHEAD: { - actions: "focusMatchedItem", + actions: "highlightMatchedItem", }, FOCUS_MENU: { actions: "focusMenu", @@ -314,18 +475,19 @@ export function machine(userContext: UserDefinedContext) { // whether the trigger is also a menu item isTriggerItem: (_ctx, evt) => dom.isTriggerItem(evt.target), // whether the trigger item is the active item - isTriggerItemFocused: (ctx, evt) => { - const target = (evt.target ?? dom.getFocusedItem(ctx)) as HTMLElement | null + isTriggerItemHighlighted: (ctx, evt) => { + const target = (evt.target ?? dom.getHighlightedItemEl(ctx)) as HTMLElement | null return !!target?.hasAttribute("aria-controls") }, isForwardTabNavigation: (_ctx, evt) => !evt.shiftKey, isSubmenu: (ctx) => ctx.isSubmenu, suspendPointer: (ctx) => ctx.suspendPointer, - isFocusedItemEditable: (ctx) => isEditableElement(dom.getFocusedItem(ctx)), + isHighlightedItemEditable: (ctx) => isEditableElement(dom.getHighlightedItemEl(ctx)), isWithinPolygon: (ctx, evt) => { if (!ctx.intentPolygon) return false return isPointInPolygon(ctx.intentPolygon, evt.point) }, + isOpenControlled: (ctx) => ctx.__controlled !== undefined, }, activities: { @@ -353,11 +515,11 @@ export function machine(userContext: UserDefinedContext) { closeRootMenu(ctx) }, onPointerDownOutside(event) { - ctx.focusTriggerOnClose = !event.detail.focusable + ctx.restoreFocus = !event.detail.focusable ctx.onPointerDownOutside?.(event) }, onDismiss() { - send({ type: "REQUEST_CLOSE", src: "interact-outside" }) + send({ type: "CLOSE", src: "interact-outside" }) }, }) }, @@ -381,8 +543,8 @@ export function machine(userContext: UserDefinedContext) { const exec = () => { const state = getState() if (state.event.type.startsWith("ITEM_POINTER")) return - const optionEl = dom.getHighlightedOptionEl(ctx) - optionEl?.scrollIntoView({ block: "nearest" }) + const itemEl = dom.getHighlightedItemEl(ctx) + itemEl?.scrollIntoView({ block: "nearest" }) } raf(() => exec()) return observeAttributes(dom.getContentEl(ctx), ["aria-activedescendant"], exec) @@ -436,16 +598,10 @@ export function machine(userContext: UserDefinedContext) { ctx.value[name] = value } }, - clickFocusedItem(ctx, _evt, { send }) { - const itemEl = dom.getFocusedItem(ctx) + clickHighlightedItem(ctx, _evt, { send }) { + const itemEl = dom.getHighlightedItemEl(ctx) if (!itemEl || itemEl.dataset.disabled) return - const option = { - id: itemEl.id, - name: itemEl.dataset.name, - value: itemEl.dataset.value, - valueText: itemEl.dataset.valueText, - type: itemEl.dataset.type, - } + const option = dom.getOptionFromItemEl(itemEl) send({ type: "ITEM_CLICK", src: "enter", @@ -477,12 +633,40 @@ export function machine(userContext: UserDefinedContext) { if (!ctx.parent) return ctx.parent.state.context.suspendPointer = false }, - setFocusedItem(ctx, evt) { + setHighlightedItem(ctx, evt) { ctx.highlightedId = evt.id }, - clearFocusedItem(ctx) { + clearHighlightedItem(ctx) { ctx.highlightedId = null }, + focusBasedOnPreviousEvent(ctx, evt, meta) { + const eventType = evt.previousEvent?.type + if (!eventType) return + + const actionType = { + ARROW_LEFT: "focusParentMenu", + }[eventType] + + if (!actionType) return + + const action = meta.getAction(actionType) + action(ctx, evt, meta) + }, + highlightBasedOnPreviousEvent(ctx, evt, meta) { + const eventType = evt.previousEvent?.type + if (!eventType) return + + const actionType = { + OPEN_AUTOFOCUS: "highlightFirstItem", + ARROW_DOWN: "highlightFirstItem", + ARROW_UP: "highlightLastItem", + }[eventType] + + if (!actionType) return + + const action = meta.getAction(actionType) + action(ctx, evt, meta) + }, focusMenu(ctx) { raf(() => { const activeEl = dom.getActiveElement(ctx) @@ -491,21 +675,21 @@ export function machine(userContext: UserDefinedContext) { contentEl?.focus({ preventScroll: true }) }) }, - focusFirstItem(ctx) { + highlightFirstItem(ctx) { const first = dom.getFirstEl(ctx) if (!first) return ctx.highlightedId = first.id }, - focusLastItem(ctx) { + highlightLastItem(ctx) { const last = dom.getLastEl(ctx) if (!last) return ctx.highlightedId = last.id }, - focusNextItem(ctx, evt) { + highlightNextItem(ctx, evt) { const next = dom.getNextEl(ctx, evt.loop) ctx.highlightedId = next?.id ?? null }, - focusPrevItem(ctx, evt) { + highlightPrevItem(ctx, evt) { const prev = dom.getPrevEl(ctx, evt.loop) ctx.highlightedId = prev?.id ?? null }, @@ -513,14 +697,14 @@ export function machine(userContext: UserDefinedContext) { if (!ctx.highlightedId) return ctx.onSelect?.({ value: ctx.highlightedId }) }, - focusItem(ctx, evt) { + highlightItem(ctx, evt) { ctx.highlightedId = evt.id }, focusTrigger(ctx) { - if (ctx.isSubmenu || ctx.anchorPoint || !ctx.focusTriggerOnClose) return + if (ctx.isSubmenu || ctx.anchorPoint || !ctx.restoreFocus) return raf(() => dom.getTriggerEl(ctx)?.focus({ preventScroll: true })) }, - focusMatchedItem(ctx, evt) { + highlightMatchedItem(ctx, evt) { const node = dom.getElemByKey(ctx, evt.key) if (node) ctx.highlightedId = node.id }, @@ -534,7 +718,7 @@ export function machine(userContext: UserDefinedContext) { closeRootMenu(ctx) }, openSubmenu(ctx) { - const item = dom.getFocusedItem(ctx) + const item = dom.getHighlightedItemEl(ctx) const id = item?.getAttribute("data-uid") const child = id ? ctx.children[id] : null child?.send("OPEN_AUTOFOCUS") @@ -559,8 +743,8 @@ export function machine(userContext: UserDefinedContext) { invokeOnClose(ctx) { ctx.onOpenChange?.({ open: false }) }, - toggleVisibility(ctx, _evt, { send }) { - send({ type: ctx.open ? "OPEN" : "CLOSE" }) + toggleVisibility(ctx, evt, { send }) { + send({ type: ctx.open ? "CONTROLLED.OPEN" : "CONTROLLED.CLOSE", previousEvent: evt }) }, }, }, diff --git a/packages/machines/menu/src/menu.types.ts b/packages/machines/menu/src/menu.types.ts index be87217baa..07dcd13c40 100644 --- a/packages/machines/menu/src/menu.types.ts +++ b/packages/machines/menu/src/menu.types.ts @@ -85,6 +85,10 @@ interface PublicContext extends DirectionProperty, CommonProperties, InteractOut * Function called when the menu opens or closes */ onOpenChange?: (details: OpenChangeDetails) => void + /** + * Whether the menu's open state is controlled by the user + */ + __controlled?: boolean } export type UserDefinedContext = RequiredBy @@ -148,14 +152,14 @@ type PrivateContext = Context<{ * @internal * Whether to return focus to the trigger when the menu is closed */ - focusTriggerOnClose?: boolean + restoreFocus?: boolean }> export interface MachineContext extends PublicContext, PrivateContext, ComputedContext {} export interface MachineState { value: "idle" | "open" | "closed" | "opening" | "closing" | "opening:contextmenu" - tags: "visible" + tags: "open" | "closed" } export type State = S.State