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)}
+
+
+
+
+
+
+
+
+ )
+}
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