diff --git a/.xstate/navigation-menu.js b/.xstate/navigation-menu.js
new file mode 100644
index 0000000000..342d565d4a
--- /dev/null
+++ b/.xstate/navigation-menu.js
@@ -0,0 +1,163 @@
+"use strict";
+
+var _xstate = require("xstate");
+const {
+ actions,
+ createMachine,
+ assign
+} = _xstate;
+const {
+ choose
+} = actions;
+const fetchMachine = createMachine({
+ id: "navigation-menu",
+ context: {
+ "isItemOpen && isRootMenu": false,
+ "isSubmenu": false,
+ "isSubmenu": false,
+ "isOpen": false
+ },
+ initial: "closed",
+ entry: ["checkViewportNode"],
+ exit: ["cleanupObservers"],
+ on: {
+ SET_PARENT: {
+ target: "open",
+ actions: ["setParentMenu", "setActiveTriggerNode", "syncTriggerRectObserver"]
+ },
+ SET_CHILD: {
+ actions: ["setChildMenu"]
+ },
+ TRIGGER_CLICK: [{
+ cond: "isItemOpen && isRootMenu",
+ actions: ["clearValue", "setClickCloseRef"]
+ }, {
+ target: "open",
+ actions: ["setValue", "setClickCloseRef"]
+ }],
+ TRIGGER_FOCUS: {
+ actions: ["focusTopLevelEl"]
+ }
+ },
+ on: {
+ UPDATE_CONTEXT: {
+ actions: "updateContext"
+ }
+ },
+ states: {
+ closed: {
+ entry: ["cleanupObservers", "propagateClose"],
+ on: {
+ TRIGGER_ENTER: {
+ actions: ["clearCloseRefs"]
+ },
+ TRIGGER_MOVE: [{
+ cond: "isSubmenu",
+ target: "open",
+ actions: ["setValue"]
+ }, {
+ target: "opening",
+ actions: ["setPointerMoveRef"]
+ }]
+ }
+ },
+ opening: {
+ after: {
+ OPEN_DELAY: {
+ target: "open",
+ actions: ["setValue"]
+ }
+ },
+ on: {
+ TRIGGER_LEAVE: {
+ target: "closed",
+ actions: ["clearValue", "clearPointerMoveRef"]
+ },
+ CONTENT_FOCUS: {
+ actions: ["focusContent", "restoreTabOrder"]
+ },
+ LINK_FOCUS: {
+ actions: ["focusLink"]
+ }
+ }
+ },
+ open: {
+ tags: ["open"],
+ activities: ["trackEscapeKey", "trackInteractionOutside", "preserveTabOrder"],
+ on: {
+ CONTENT_LEAVE: {
+ target: "closing"
+ },
+ TRIGGER_LEAVE: {
+ target: "closing",
+ actions: ["clearPointerMoveRef"]
+ },
+ CONTENT_FOCUS: {
+ actions: ["focusContent", "restoreTabOrder"]
+ },
+ LINK_FOCUS: {
+ actions: ["focusLink"]
+ },
+ CONTENT_DISMISS: {
+ target: "closed",
+ actions: ["focusTriggerIfNeeded", "clearValue", "clearPointerMoveRef"]
+ },
+ CONTENT_ENTER: {
+ actions: ["restoreTabOrder"]
+ },
+ TRIGGER_MOVE: {
+ cond: "isSubmenu",
+ actions: ["setValue"]
+ },
+ ROOT_CLOSE: {
+ // clear the previous value so indicator doesn't animate
+ actions: ["clearPreviousValue", "cleanupObservers"]
+ }
+ }
+ },
+ closing: {
+ tags: ["open"],
+ activities: ["trackInteractionOutside"],
+ after: {
+ CLOSE_DELAY: {
+ target: "closed",
+ actions: ["clearValue"]
+ }
+ },
+ on: {
+ CONTENT_DISMISS: {
+ target: "closed",
+ actions: ["focusTriggerIfNeeded", "clearValue", "clearPointerMoveRef"]
+ },
+ CONTENT_ENTER: {
+ target: "open",
+ actions: ["restoreTabOrder"]
+ },
+ TRIGGER_ENTER: {
+ actions: ["clearCloseRefs"]
+ },
+ TRIGGER_MOVE: [{
+ cond: "isOpen",
+ target: "open",
+ actions: ["setValue", "setPointerMoveRef"]
+ }, {
+ target: "opening",
+ actions: ["setPointerMoveRef"]
+ }]
+ }
+ }
+ }
+}, {
+ actions: {
+ updateContext: assign((context, event) => {
+ return {
+ [event.contextKey]: true
+ };
+ })
+ },
+ guards: {
+ "isItemOpen && isRootMenu": ctx => ctx["isItemOpen && isRootMenu"],
+ "isSubmenu": ctx => ctx["isSubmenu"],
+ "isOpen": ctx => ctx["isOpen"]
+ }
+});
\ No newline at end of file
diff --git a/examples/lit-ts/package.json b/examples/lit-ts/package.json
index 0da0fa5013..5764281312 100644
--- a/examples/lit-ts/package.json
+++ b/examples/lit-ts/package.json
@@ -50,6 +50,7 @@
"@zag-js/interact-outside": "workspace:*",
"@zag-js/live-region": "workspace:*",
"@zag-js/menu": "workspace:*",
+ "@zag-js/navigation-menu": "workspace:*",
"@zag-js/number-input": "workspace:*",
"@zag-js/number-utils": "workspace:*",
"@zag-js/numeric-range": "workspace:*",
diff --git a/examples/next-ts/package.json b/examples/next-ts/package.json
index b65de17a1a..1a5bced8bc 100644
--- a/examples/next-ts/package.json
+++ b/examples/next-ts/package.json
@@ -50,6 +50,7 @@
"@zag-js/interact-outside": "workspace:*",
"@zag-js/live-region": "workspace:*",
"@zag-js/menu": "workspace:*",
+ "@zag-js/navigation-menu": "workspace:*",
"@zag-js/number-input": "workspace:*",
"@zag-js/number-utils": "workspace:*",
"@zag-js/numeric-range": "workspace:*",
diff --git a/examples/next-ts/pages/navigation-menu-nested.tsx b/examples/next-ts/pages/navigation-menu-nested.tsx
new file mode 100644
index 0000000000..1bca34a577
--- /dev/null
+++ b/examples/next-ts/pages/navigation-menu-nested.tsx
@@ -0,0 +1,276 @@
+import * as navigationMenu from "@zag-js/navigation-menu"
+import { normalizeProps, useMachine } from "@zag-js/react"
+import { ChevronDown } from "lucide-react"
+import { useId } from "react"
+import { Presence } from "../components/presence"
+import { StateVisualizer } from "../components/state-visualizer"
+import { Toolbar } from "../components/toolbar"
+import { useEffectOnce } from "../hooks/use-effect-once"
+
+export default function Page() {
+ const [rootState, rootSend, rootService] = useMachine(navigationMenu.machine({ id: useId() }))
+ const rootMenu = navigationMenu.connect(rootState, rootSend, normalizeProps)
+
+ const [productState, productSend, productService] = useMachine(
+ navigationMenu.machine({ id: useId(), value: "extensibility" }),
+ )
+ const productSubmenu = navigationMenu.connect(productState, productSend, normalizeProps)
+
+ useEffectOnce(() => {
+ productSubmenu.setParent(rootService)
+ rootMenu.setChild(productService)
+ })
+
+ const [companyState, companySend, companyService] = useMachine(
+ navigationMenu.machine({ id: useId(), value: "customers" }),
+ )
+ const companySubmenu = navigationMenu.connect(companyState, companySend, normalizeProps)
+
+ useEffectOnce(() => {
+ companySubmenu.setParent(rootService)
+ rootMenu.setChild(companyService)
+ })
+
+ const renderLinks = (menu: typeof rootMenu, opts: { value: string; items: string[] }) => {
+ const { value, items } = opts
+ return items.map((item, index) => (
+
+ {item}
+
+ ))
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {renderLinks(productSubmenu, {
+ value: "extensibility",
+ items: ["Donec quis dui", "Vestibulum", "Nunc dignissim"],
+ })}
+ {renderLinks(productSubmenu, {
+ value: "extensibility",
+ items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque"],
+ })}
+ {renderLinks(productSubmenu, {
+ value: "extensibility",
+ items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque"],
+ })}
+
+
+
+ {renderLinks(productSubmenu, {
+ value: "security",
+ items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque", "Vestibulum"],
+ })}
+ {renderLinks(productSubmenu, {
+ value: "security",
+ items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque"],
+ })}
+ {renderLinks(productSubmenu, {
+ value: "security",
+ items: ["Fusce pellentesque", "Aliquam porttitor"],
+ })}
+
+
+
+ {renderLinks(productSubmenu, {
+ value: "authentication",
+ items: ["Donec quis dui", "Vestibulum", "Nunc dignissim"],
+ })}
+ {renderLinks(productSubmenu, {
+ value: "authentication",
+ items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque"],
+ })}
+ {renderLinks(productSubmenu, {
+ value: "authentication",
+ items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque"],
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {renderLinks(companySubmenu, {
+ value: "customers",
+ items: ["Donec quis dui", "Vestibulum", "Nunc dignissim"],
+ })}
+ {renderLinks(companySubmenu, {
+ value: "customers",
+ items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque"],
+ })}
+
+
+
+ {renderLinks(companySubmenu, {
+ value: "partners",
+ items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque", "Vestibulum"],
+ })}
+ {renderLinks(companySubmenu, {
+ value: "partners",
+ items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque"],
+ })}
+
+
+
+ {renderLinks(companySubmenu, {
+ value: "enterprise",
+ items: ["Donec quis dui", "Vestibulum", "Nunc dignissim"],
+ })}
+ {renderLinks(companySubmenu, {
+ value: "enterprise",
+ items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque"],
+ })}
+
+
+
+
+
+
+ {renderLinks(rootMenu, {
+ value: "developers",
+ items: ["Donec quis dui", "Vestibulum", "Fusce pellentesque", "Aliquam porttitor"],
+ })}
+ {renderLinks(rootMenu, {
+ value: "developers",
+ items: ["Fusce pellentesque", "Aliquam porttitor"],
+ })}
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+const Navbar = ({ children }: { children: React.ReactNode }) => {
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/examples/next-ts/pages/navigation-menu-viewport.tsx b/examples/next-ts/pages/navigation-menu-viewport.tsx
new file mode 100644
index 0000000000..6a5b245daf
--- /dev/null
+++ b/examples/next-ts/pages/navigation-menu-viewport.tsx
@@ -0,0 +1,163 @@
+import * as navigationMenu from "@zag-js/navigation-menu"
+import { normalizeProps, useMachine } from "@zag-js/react"
+import { navigationMenuControls } from "@zag-js/shared"
+import { ChevronDown } from "lucide-react"
+import { useId } from "react"
+import { Presence } from "../components/presence"
+import { StateVisualizer } from "../components/state-visualizer"
+import { Toolbar } from "../components/toolbar"
+import { useControls } from "../hooks/use-controls"
+
+export default function Page() {
+ const controls = useControls(navigationMenuControls)
+
+ const [state, send] = useMachine(navigationMenu.machine({ id: useId() }), {
+ context: controls.context,
+ })
+
+ const api = navigationMenu.connect(state, send, normalizeProps)
+
+ const renderLinks = (opts: { value: string; items: string[] }) => {
+ const { value, items } = opts
+ return items.map((item, index) => (
+
+ {item}
+
+ ))
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {renderLinks({
+ value: "products",
+ items: [
+ "Fusce pellentesque",
+ "Aliquam porttitor",
+ "Pellentesque",
+ "Fusce pellentesque",
+ "Aliquam porttitor",
+ "Pellentesque",
+ ],
+ })}
+
+ {renderLinks({
+ value: "products",
+ items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque"],
+ })}
+
+
+
+ {renderLinks({
+ value: "company",
+ items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque", "Aliquam porttitor"],
+ })}
+
+ {renderLinks({
+ value: "company",
+ items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque"],
+ })}
+
+
+
+ {renderLinks({
+ value: "developers",
+ items: ["Donec quis dui", "Vestibulum", "Fusce pellentesque", "Aliquam porttitor"],
+ })}
+ {renderLinks({
+ value: "developers",
+ items: ["Fusce pellentesque", "Aliquam porttitor"],
+ })}
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+const Navbar = ({ children }: { children: React.ReactNode }) => {
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/examples/next-ts/pages/navigation-menu.tsx b/examples/next-ts/pages/navigation-menu.tsx
new file mode 100644
index 0000000000..ae72ad7f6c
--- /dev/null
+++ b/examples/next-ts/pages/navigation-menu.tsx
@@ -0,0 +1,103 @@
+import * as navigationMenu from "@zag-js/navigation-menu"
+import { normalizeProps, useMachine } from "@zag-js/react"
+import { navigationMenuControls } from "@zag-js/shared"
+import { ChevronDown } from "lucide-react"
+import { useId } from "react"
+import { Presence } from "../components/presence"
+import { StateVisualizer } from "../components/state-visualizer"
+import { Toolbar } from "../components/toolbar"
+import { useControls } from "../hooks/use-controls"
+
+export default function Page() {
+ const controls = useControls(navigationMenuControls)
+
+ const [state, send] = useMachine(navigationMenu.machine({ id: useId() }), {
+ context: controls.context,
+ })
+
+ const api = navigationMenu.connect(state, send, normalizeProps)
+
+ const renderLinks = (opts: { value: string; items: string[] }) => {
+ const { value, items } = opts
+ return items.map((item, index) => (
+
+ {item}
+
+ ))
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ {renderLinks({
+ value: "products",
+ items: [
+ "Fusce pellentesque",
+ "Aliquam porttitor",
+ "Pellentesque",
+ "Fusce pellentesque",
+ "Aliquam porttitor",
+ "Pellentesque",
+ ],
+ })}
+
+
+
+
+
+
+
+
+
+ {renderLinks({
+ value: "company",
+ items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque"],
+ })}
+
+
+
+
+
+
+
+
+
+ {renderLinks({
+ value: "developers",
+ items: ["Donec quis dui", "Vestibulum", "Fusce pellentesque", "Aliquam porttitor"],
+ })}
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/examples/nuxt-ts/package.json b/examples/nuxt-ts/package.json
index 4e06824241..5cc446cde9 100644
--- a/examples/nuxt-ts/package.json
+++ b/examples/nuxt-ts/package.json
@@ -47,6 +47,7 @@
"@zag-js/interact-outside": "workspace:*",
"@zag-js/live-region": "workspace:*",
"@zag-js/menu": "workspace:*",
+ "@zag-js/navigation-menu": "workspace:*",
"@zag-js/number-input": "workspace:*",
"@zag-js/number-utils": "workspace:*",
"@zag-js/numeric-range": "workspace:*",
diff --git a/examples/nuxt-ts/pages/navigation-menu.vue b/examples/nuxt-ts/pages/navigation-menu.vue
new file mode 100644
index 0000000000..dfdba2c204
--- /dev/null
+++ b/examples/nuxt-ts/pages/navigation-menu.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/preact-ts/package.json b/examples/preact-ts/package.json
index 2cdcf8e94d..980e862e83 100644
--- a/examples/preact-ts/package.json
+++ b/examples/preact-ts/package.json
@@ -46,6 +46,7 @@
"@zag-js/interact-outside": "workspace:*",
"@zag-js/live-region": "workspace:*",
"@zag-js/menu": "workspace:*",
+ "@zag-js/navigation-menu": "workspace:*",
"@zag-js/number-input": "workspace:*",
"@zag-js/number-utils": "workspace:*",
"@zag-js/numeric-range": "workspace:*",
diff --git a/examples/react-19/package.json b/examples/react-19/package.json
index abfee19c51..4a04a82afd 100644
--- a/examples/react-19/package.json
+++ b/examples/react-19/package.json
@@ -47,6 +47,7 @@
"@zag-js/interact-outside": "workspace:*",
"@zag-js/live-region": "workspace:*",
"@zag-js/menu": "workspace:*",
+ "@zag-js/navigation-menu": "workspace:*",
"@zag-js/number-input": "workspace:*",
"@zag-js/number-utils": "workspace:*",
"@zag-js/numeric-range": "workspace:*",
diff --git a/examples/solid-ts/package.json b/examples/solid-ts/package.json
index 1773a42960..4cde0811fd 100644
--- a/examples/solid-ts/package.json
+++ b/examples/solid-ts/package.json
@@ -50,6 +50,7 @@
"@zag-js/interact-outside": "workspace:*",
"@zag-js/live-region": "workspace:*",
"@zag-js/menu": "workspace:*",
+ "@zag-js/navigation-menu": "workspace:*",
"@zag-js/number-input": "workspace:*",
"@zag-js/number-utils": "workspace:*",
"@zag-js/numeric-range": "workspace:*",
diff --git a/examples/solid-ts/src/routes/navigation-menu.tsx b/examples/solid-ts/src/routes/navigation-menu.tsx
new file mode 100644
index 0000000000..260878fc8a
--- /dev/null
+++ b/examples/solid-ts/src/routes/navigation-menu.tsx
@@ -0,0 +1,29 @@
+import * as navigationMenu from "@zag-js/navigation-menu"
+import { normalizeProps, useMachine, mergeProps } from "@zag-js/solid"
+import { createMemo, createUniqueId } from "solid-js"
+import { navigationMenuControls, navigationMenuData } from "@zag-js/shared"
+import { StateVisualizer } from "../components/state-visualizer"
+import { Toolbar } from "../components/toolbar"
+import { useControls } from "../hooks/use-controls"
+
+export default function Page() {
+ const controls = useControls(navigationMenuControls)
+
+ const [state, send] = useMachine(navigationMenu.machine({ id: createUniqueId() }), {
+ context: controls.context,
+ })
+
+ const api = createMemo(() => navigationMenu.connect(state, send, normalizeProps))
+
+ return (
+ <>
+
+
+
+
+
+ >
+ )
+}
diff --git a/examples/svelte-ts/package.json b/examples/svelte-ts/package.json
index 9261be6551..0653dd11f0 100644
--- a/examples/svelte-ts/package.json
+++ b/examples/svelte-ts/package.json
@@ -48,6 +48,7 @@
"@zag-js/interact-outside": "workspace:*",
"@zag-js/live-region": "workspace:*",
"@zag-js/menu": "workspace:*",
+ "@zag-js/navigation-menu": "workspace:*",
"@zag-js/number-input": "workspace:*",
"@zag-js/number-utils": "workspace:*",
"@zag-js/numeric-range": "workspace:*",
diff --git a/examples/vanilla-ts/package.json b/examples/vanilla-ts/package.json
index 5e5e88f3f5..4135fce8f5 100644
--- a/examples/vanilla-ts/package.json
+++ b/examples/vanilla-ts/package.json
@@ -51,6 +51,7 @@
"@zag-js/interact-outside": "workspace:*",
"@zag-js/live-region": "workspace:*",
"@zag-js/menu": "workspace:*",
+ "@zag-js/navigation-menu": "workspace:*",
"@zag-js/number-input": "workspace:*",
"@zag-js/number-utils": "workspace:*",
"@zag-js/numeric-range": "workspace:*",
diff --git a/packages/machines/navigation-menu/README.md b/packages/machines/navigation-menu/README.md
new file mode 100644
index 0000000000..98bb6de162
--- /dev/null
+++ b/packages/machines/navigation-menu/README.md
@@ -0,0 +1,19 @@
+# @zag-js/navigation-menu
+
+Core logic for the navigation-menu widget implemented as a state machine
+
+## Installation
+
+```sh
+yarn add @zag-js/navigation-menu
+# or
+npm i @zag-js/navigation-menu
+```
+
+## Contribution
+
+Yes please! See the [contributing guidelines](https://github.com/chakra-ui/zag/blob/main/CONTRIBUTING.md) for details.
+
+## Licence
+
+This project is licensed under the terms of the [MIT license](https://github.com/chakra-ui/zag/blob/main/LICENSE).
diff --git a/packages/machines/navigation-menu/package.json b/packages/machines/navigation-menu/package.json
new file mode 100644
index 0000000000..55d94949ca
--- /dev/null
+++ b/packages/machines/navigation-menu/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "@zag-js/navigation-menu",
+ "version": "0.0.0",
+ "description": "Core logic for the navigation-menu widget implemented as a state machine",
+ "keywords": [
+ "js",
+ "machine",
+ "xstate",
+ "statechart",
+ "component",
+ "chakra-ui",
+ "navigation-menu"
+ ],
+ "author": "Segun Adebayo ",
+ "homepage": "https://github.com/chakra-ui/zag#readme",
+ "license": "MIT",
+ "main": "src/index.ts",
+ "repository": "https://github.com/chakra-ui/zag/tree/main/packages/navigation-menu",
+ "sideEffects": false,
+ "files": [
+ "dist",
+ "src"
+ ],
+ "scripts": {
+ "build": "tsup",
+ "lint": "eslint src",
+ "typecheck": "tsc --noEmit",
+ "prepack": "clean-package",
+ "postpack": "clean-package restore"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "bugs": {
+ "url": "https://github.com/chakra-ui/zag/issues"
+ },
+ "dependencies": {
+ "@zag-js/anatomy": "workspace:*",
+ "@zag-js/interact-outside": "workspace:*",
+ "@zag-js/core": "workspace:*",
+ "@zag-js/dom-query": "workspace:*",
+ "@zag-js/dom-event": "workspace:*",
+ "@zag-js/utils": "workspace:*",
+ "@zag-js/types": "workspace:*"
+ },
+ "devDependencies": {
+ "clean-package": "2.2.0"
+ },
+ "clean-package": "../../../clean-package.config.json"
+}
diff --git a/packages/machines/navigation-menu/src/index.ts b/packages/machines/navigation-menu/src/index.ts
new file mode 100644
index 0000000000..732518cc40
--- /dev/null
+++ b/packages/machines/navigation-menu/src/index.ts
@@ -0,0 +1,5 @@
+export { anatomy } from "./navigation-menu.anatomy"
+export { connect } from "./navigation-menu.connect"
+export { machine } from "./navigation-menu.machine"
+export * from "./navigation-menu.props"
+export type { UserDefinedContext as Context } from "./navigation-menu.types"
diff --git a/packages/machines/navigation-menu/src/navigation-menu.anatomy.ts b/packages/machines/navigation-menu/src/navigation-menu.anatomy.ts
new file mode 100644
index 0000000000..4d417e9959
--- /dev/null
+++ b/packages/machines/navigation-menu/src/navigation-menu.anatomy.ts
@@ -0,0 +1,17 @@
+import { createAnatomy } from "@zag-js/anatomy"
+
+export const anatomy = createAnatomy("navigation-menu").parts(
+ "root",
+ "viewportPositioner",
+ "viewport",
+ "trigger",
+ "content",
+ "list",
+ "item",
+ "link",
+ "indicator",
+ "indicatorTrack",
+ "arrow",
+)
+
+export const parts = anatomy.build()
diff --git a/packages/machines/navigation-menu/src/navigation-menu.connect.ts b/packages/machines/navigation-menu/src/navigation-menu.connect.ts
new file mode 100644
index 0000000000..a998c903fb
--- /dev/null
+++ b/packages/machines/navigation-menu/src/navigation-menu.connect.ts
@@ -0,0 +1,318 @@
+import { getEventKey, type EventKeyMap } from "@zag-js/dom-event"
+import { dataAttr, getWindow } from "@zag-js/dom-query"
+import type { NormalizeProps, PropTypes } from "@zag-js/types"
+import { parts } from "./navigation-menu.anatomy"
+import { dom } from "./navigation-menu.dom"
+import type { ItemProps, MachineApi, Send, State } from "./navigation-menu.types"
+
+export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi {
+ const open = Boolean(state.context.value)
+
+ const activeTriggerRect = state.context.activeTriggerRect
+ const viewportSize = state.context.viewportSize
+
+ const value = state.context.value
+ const previousValue = state.context.previousValue
+ const isViewportRendered = state.context.isViewportRendered
+ const preventTransition = state.context.value && !state.context.previousValue
+
+ function getItemState(props: ItemProps) {
+ const selected = value === props.value
+ const wasSelected = !value && previousValue === props.value
+ return {
+ triggerId: dom.getTriggerId(state.context, props.value),
+ contentId: dom.getContentId(state.context, props.value),
+ selected,
+ wasSelected,
+ open: selected || wasSelected,
+ disabled: !!props.disabled,
+ }
+ }
+
+ return {
+ open,
+ value,
+ orientation: state.context.orientation!,
+ setValue(value) {
+ send({ type: "SET_VALUE", value })
+ },
+ setParent(parent) {
+ send({ type: "SET_PARENT", parent })
+ },
+ setChild(child) {
+ send({ type: "SET_CHILD", value: child, id: child.state.context.id })
+ },
+
+ getRootProps() {
+ return normalize.element({
+ ...parts.root.attrs,
+ id: dom.getRootId(state.context),
+ "aria-label": "Main",
+ "data-orientation": state.context.orientation,
+ "data-type": state.context.isSubmenu ? "submenu" : "root",
+ dir: state.context.dir,
+ style: {
+ "--trigger-width": activeTriggerRect != null ? activeTriggerRect.width + "px" : undefined,
+ "--trigger-height": activeTriggerRect != null ? activeTriggerRect.height + "px" : undefined,
+ "--trigger-x": activeTriggerRect != null ? activeTriggerRect.x + "px" : undefined,
+ "--trigger-y": activeTriggerRect != null ? activeTriggerRect.y + "px" : undefined,
+ "--viewport-width": viewportSize != null ? viewportSize.width + "px" : undefined,
+ "--viewport-height": viewportSize != null ? viewportSize.height + "px" : undefined,
+ },
+ })
+ },
+
+ getListProps() {
+ return normalize.element({
+ ...parts.list.attrs,
+ id: dom.getListId(state.context),
+ dir: state.context.dir,
+ "data-orientation": state.context.orientation,
+ "data-type": state.context.isSubmenu ? "submenu" : "root",
+ })
+ },
+
+ getItemProps(props) {
+ const itemState = getItemState(props)
+ return normalize.element({
+ ...parts.item.attrs,
+ dir: state.context.dir,
+ "data-value": props.value,
+ "data-state": itemState.open ? "open" : "closed",
+ "data-orientation": state.context.orientation,
+ "data-disabled": dataAttr(itemState.disabled),
+ })
+ },
+
+ getIndicatorTrackProps() {
+ return normalize.element({
+ ...parts.indicatorTrack.attrs,
+ id: dom.getIndicatorTrackId(state.context),
+ dir: state.context.dir,
+ "data-orientation": state.context.orientation,
+ style: { position: "relative" },
+ })
+ },
+
+ getIndicatorProps() {
+ return normalize.element({
+ ...parts.indicator.attrs,
+ "aria-hidden": true,
+ dir: state.context.dir,
+ hidden: !open,
+ "data-state": open ? "open" : "closed",
+ "data-orientation": state.context.orientation,
+ "data-type": state.context.isSubmenu ? "submenu" : "root",
+ style: {
+ position: "absolute",
+ transition: preventTransition ? "none" : undefined,
+ },
+ })
+ },
+
+ getArrowProps() {
+ return normalize.element({
+ ...parts.arrow.attrs,
+ "aria-hidden": true,
+ dir: state.context.dir,
+ "data-orientation": state.context.orientation,
+ })
+ },
+
+ getTriggerProps(props) {
+ const itemState = getItemState(props)
+ return normalize.button({
+ ...parts.trigger.attrs,
+ id: itemState.triggerId,
+ "data-uid": state.context.id,
+ dir: state.context.dir,
+ disabled: props.disabled,
+ "data-value": props.value,
+ "data-state": itemState.selected ? "open" : "closed",
+ "data-type": state.context.isSubmenu ? "submenu" : "root",
+ "data-disabled": dataAttr(props.disabled),
+ "aria-controls": itemState.contentId,
+ "aria-expanded": itemState.selected,
+ onPointerEnter() {
+ send({ type: "TRIGGER_ENTER", value: props.value })
+ },
+ onPointerMove(event) {
+ if (event.pointerType !== "mouse") return
+ if (itemState.disabled) return
+ if (state.context.hasPointerMoveOpenedRef === props.value) return
+ if (state.context.wasClickCloseRef === props.value) return
+ if (state.context.wasEscapeCloseRef) return
+ send({ type: "TRIGGER_MOVE", value: props.value })
+ },
+ onPointerLeave(event) {
+ if (event.pointerType !== "mouse") return
+ if (props.disabled) return
+ if (state.context.isSubmenu) return
+ send({ type: "TRIGGER_LEAVE", value: props.value })
+ },
+ onClick() {
+ send({ type: "TRIGGER_CLICK", value: props.value })
+ },
+ onKeyDown(event) {
+ const keyMap: EventKeyMap = {
+ ArrowLeft() {
+ send({ type: "TRIGGER_FOCUS", target: "prev", value: props.value })
+ },
+ ArrowRight() {
+ send({ type: "TRIGGER_FOCUS", target: "next", value: props.value })
+ },
+ Home() {
+ send({ type: "TRIGGER_FOCUS", target: "first" })
+ },
+ End() {
+ send({ type: "TRIGGER_FOCUS", target: "last" })
+ },
+ Enter() {
+ send({ type: "TRIGGER_CLICK", value: props.value })
+ },
+ ArrowDown() {
+ if (!itemState.selected) return
+ send({ type: "CONTENT_FOCUS", value })
+ },
+ }
+
+ const action = keyMap[getEventKey(event)]
+
+ if (action) {
+ action(event)
+ event.preventDefault()
+ }
+ },
+ })
+ },
+
+ getLinkProps(props) {
+ return normalize.element({
+ ...parts.link.attrs,
+ dir: state.context.dir,
+ "data-value": props.value,
+ "data-current": dataAttr(props.current),
+ "aria-current": props.current ? "page" : undefined,
+ "data-ownedby": dom.getContentId(state.context, props.value),
+ onClick(event) {
+ const { currentTarget } = event
+
+ const win = getWindow(currentTarget)
+ currentTarget.addEventListener("link.select", (event: any) => props.onSelect?.(event), { once: true })
+
+ const selectEvent = new win.CustomEvent("link.select", { bubbles: true, cancelable: true })
+ currentTarget.dispatchEvent(selectEvent)
+
+ if (!selectEvent.defaultPrevented && !event.metaKey) {
+ send({ type: "CONTENT_DISMISS" })
+ }
+ },
+ onKeyDown(event) {
+ const contentMenu = event.currentTarget.closest("[data-scope=navigation-menu][data-part=content]")
+ const isWithinContent = !!contentMenu
+
+ const keyMap: EventKeyMap = {
+ ArrowLeft(event) {
+ if (isWithinContent) return
+ send({ type: "TRIGGER_FOCUS", target: "prev", value: props.value })
+ event.preventDefault()
+ },
+ ArrowRight(event) {
+ if (isWithinContent) return
+ send({ type: "TRIGGER_FOCUS", target: "next", value: props.value })
+ event.preventDefault()
+ },
+ Home(event) {
+ if (isWithinContent) return
+ send({ type: "TRIGGER_FOCUS", target: "first" })
+ event.preventDefault()
+ },
+ End(event) {
+ if (isWithinContent) return
+ send({ type: "TRIGGER_FOCUS", target: "last" })
+ event.preventDefault()
+ },
+ ArrowDown(event) {
+ if (!isWithinContent) return
+ send({ type: "LINK_FOCUS", target: "next", node: event.currentTarget, value: props.value })
+ event.preventDefault()
+ },
+ ArrowUp(event) {
+ if (!isWithinContent) return
+ send({ type: "LINK_FOCUS", target: "prev", node: event.currentTarget, value: props.value })
+ event.preventDefault()
+ },
+ }
+
+ const action = keyMap[getEventKey(event, state.context)]
+
+ if (action) {
+ action(event)
+ }
+ },
+ })
+ },
+
+ getContentProps(props) {
+ const itemState = getItemState(props)
+
+ const activeContentValue = state.context.value ?? state.context.previousValue
+ const selected = isViewportRendered ? activeContentValue === props.value : itemState.selected
+
+ return normalize.element({
+ ...parts.content.attrs,
+ id: dom.getContentId(state.context, props.value),
+ dir: state.context.dir,
+ hidden: !selected,
+ "aria-labelledby": itemState.triggerId,
+ "data-uid": state.context.id,
+ "data-state": selected ? "open" : "closed",
+ "data-type": state.context.isSubmenu ? "submenu" : "root",
+ "data-value": props.value,
+ onPointerEnter() {
+ if (state.context.isSubmenu) return
+ send({ type: "CONTENT_ENTER", value: props.value })
+ },
+ onPointerLeave(event) {
+ if (event.pointerType !== "mouse") return
+ if (state.context.isSubmenu) return
+ send({ type: "CONTENT_LEAVE", value: props.value })
+ },
+ })
+ },
+
+ getViewportPositionerProps() {
+ return normalize.element({
+ ...parts.viewportPositioner.attrs,
+ dir: state.context.dir,
+ "data-orientation": state.context.orientation,
+ })
+ },
+
+ getViewportProps() {
+ const open = Boolean(value)
+ return normalize.element({
+ ...parts.viewport.attrs,
+ id: dom.getViewportId(state.context),
+ dir: state.context.dir,
+ hidden: !open,
+ "data-state": open ? "open" : "closed",
+ "data-orientation": state.context.orientation,
+ "data-type": state.context.isSubmenu ? "submenu" : "root",
+ style: {
+ transition: preventTransition ? "none" : undefined,
+ pointerEvents: !open ? "none" : undefined,
+ },
+ onPointerEnter() {
+ if (state.context.isSubmenu) return
+ send({ type: "CONTENT_ENTER", src: "viewport" })
+ },
+ onPointerLeave(event) {
+ if (event.pointerType !== "mouse") return
+ if (state.context.isSubmenu) return
+ send({ type: "CONTENT_LEAVE", src: "viewport" })
+ },
+ })
+ },
+ }
+}
diff --git a/packages/machines/navigation-menu/src/navigation-menu.dom.ts b/packages/machines/navigation-menu/src/navigation-menu.dom.ts
new file mode 100644
index 0000000000..6a997f07c0
--- /dev/null
+++ b/packages/machines/navigation-menu/src/navigation-menu.dom.ts
@@ -0,0 +1,84 @@
+import { createScope, getTabbables, getWindow, queryAll } from "@zag-js/dom-query"
+import { first, last, next, nextIndex, prev, prevIndex } from "@zag-js/utils"
+import type { MachineContext as Ctx } from "./navigation-menu.types"
+
+export const dom = createScope({
+ getRootId: (ctx: Ctx) => `nav-menu:${ctx.id}`,
+ getTriggerId: (ctx: Ctx, value: string) => `nav-menu:${ctx.id}:trigger:${value}`,
+ getContentId: (ctx: Ctx, value: string) => `nav-menu:${ctx.id}:content:${value}`,
+ getViewportId: (ctx: Ctx) => `nav-menu:${ctx.id}:viewport`,
+ getListId: (ctx: Ctx) => `nav-menu:${ctx.id}:list`,
+ getIndicatorTrackId: (ctx: Ctx) => `nav-menu:${ctx.id}:indicator-track`,
+
+ getRootMenuEl: (ctx: Ctx) => {
+ let id = ctx.id
+ while (ctx.parentMenu != null) id = ctx.parentMenu.id
+ return dom.getById(ctx, `nav-menu:${id}`)
+ },
+
+ getTabbableEls: (ctx: Ctx, value: string) => getTabbables(dom.getContentEl(ctx, value)),
+ getIndicatorTrackEl: (ctx: Ctx) => dom.getById(ctx, dom.getIndicatorTrackId(ctx)),
+ getRootEl: (ctx: Ctx) => dom.getById(ctx, dom.getRootId(ctx)),
+ getViewportEl: (ctx: Ctx) => dom.getById(ctx, dom.getViewportId(ctx)),
+ getTriggerEl: (ctx: Ctx, value: string) => dom.getById(ctx, dom.getTriggerId(ctx, value)),
+ getListEl: (ctx: Ctx) => dom.getById(ctx, dom.getListId(ctx)),
+
+ getContentEl: (ctx: Ctx, value: string) => dom.getById(ctx, dom.getContentId(ctx, value)),
+ getContentEls: (ctx: Ctx) =>
+ queryAll(dom.getDoc(ctx), `[data-scope=navigation-menu][data-part=content][data-uid='${ctx.id}']`),
+
+ getTriggerEls: (ctx: Ctx) => queryAll(dom.getListEl(ctx), `[data-part=trigger][data-uid='${ctx.id}']`),
+ getTopLevelEls: (ctx: Ctx) => {
+ const topLevelTriggerSelector = `[data-part=trigger][data-uid='${ctx.id}']:not([data-disabled])`
+ const topLevelLinkSelector = `[data-part=item] > [data-part=link]`
+ return queryAll(dom.getListEl(ctx), `${topLevelTriggerSelector}, ${topLevelLinkSelector}`)
+ },
+ getFirstTopLevelEl: (ctx: Ctx) => first(dom.getTopLevelEls(ctx)),
+ getLastTopLevelEl: (ctx: Ctx) => last(dom.getTopLevelEls(ctx)),
+ getNextTopLevelEl: (ctx: Ctx, value: string) => {
+ const elements = dom.getTopLevelEls(ctx)
+ const values = toValues(elements)
+ const idx = nextIndex(values, values.indexOf(value), { loop: false })
+ return elements[idx]
+ },
+ getPrevTopLevelEl: (ctx: Ctx, value: string) => {
+ const elements = dom.getTopLevelEls(ctx)
+ const values = toValues(elements)
+ const idx = prevIndex(values, values.indexOf(value), { loop: false })
+ return elements[idx]
+ },
+
+ getLinkEls: (ctx: Ctx, value: string) => {
+ const contentEl = dom.getContentEl(ctx, value)
+ return queryAll(contentEl, `[data-part=link][data-ownedby="${dom.getContentId(ctx, value)}"]`)
+ },
+ getFirstLinkEl: (ctx: Ctx, value: string) => first(dom.getLinkEls(ctx, value)),
+ getLastLinkEl: (ctx: Ctx, value: string) => last(dom.getLinkEls(ctx, value)),
+ getNextLinkEl: (ctx: Ctx, value: string, node: HTMLElement) => {
+ const elements = dom.getLinkEls(ctx, value)
+ return next(elements, elements.indexOf(node), { loop: false })
+ },
+ getPrevLinkEl: (ctx: Ctx, value: string, node: HTMLElement) => {
+ const elements = dom.getLinkEls(ctx, value)
+ return prev(elements, elements.indexOf(node), { loop: false })
+ },
+})
+
+const toValues = (els: HTMLElement[]) => els.map((el) => el.getAttribute("data-value"))
+
+export function trackResizeObserver(element: HTMLElement | null, onResize: () => void) {
+ if (!element) return null
+ let frame = 0
+ const win = getWindow(element)
+ const obs = new win.ResizeObserver(() => {
+ cancelAnimationFrame(frame)
+ frame = requestAnimationFrame(onResize)
+ })
+
+ obs.observe(element)
+
+ return () => {
+ cancelAnimationFrame(frame)
+ obs.unobserve(element)
+ }
+}
diff --git a/packages/machines/navigation-menu/src/navigation-menu.machine.ts b/packages/machines/navigation-menu/src/navigation-menu.machine.ts
new file mode 100644
index 0000000000..003fa67c3b
--- /dev/null
+++ b/packages/machines/navigation-menu/src/navigation-menu.machine.ts
@@ -0,0 +1,459 @@
+import { createMachine, guards, ref } from "@zag-js/core"
+import { addDomEvent } from "@zag-js/dom-event"
+import { contains, proxyTabFocus, raf } from "@zag-js/dom-query"
+import { trackInteractOutside } from "@zag-js/interact-outside"
+import { callAll, cast, compact } from "@zag-js/utils"
+import { dom, trackResizeObserver } from "./navigation-menu.dom"
+import type { MachineContext, MachineState, UserDefinedContext } from "./navigation-menu.types"
+
+const { and } = guards
+
+export function machine(userContext: UserDefinedContext) {
+ const ctx = compact(userContext)
+ return createMachine(
+ {
+ id: "navigation-menu",
+
+ context: {
+ // value tracking
+ value: null,
+ previousValue: null,
+ // viewport
+ viewportSize: null,
+ isViewportRendered: false,
+ // timings
+ openDelay: 200,
+ closeDelay: 300,
+ // orientation
+ orientation: "horizontal",
+ // nodes for measurement
+ activeTriggerRect: null,
+ activeContentNode: null,
+ activeTriggerNode: null,
+ // close refs
+ wasEscapeCloseRef: false,
+ wasClickCloseRef: null,
+ hasPointerMoveOpenedRef: null,
+ // cleanup functions
+ activeContentCleanup: null,
+ activeTriggerCleanup: null,
+ tabOrderCleanup: null,
+ // support nesting
+ parentMenu: null,
+ childMenus: cast(ref({})),
+ ...ctx,
+ },
+
+ computed: {
+ isRootMenu: (ctx) => ctx.parentMenu == null,
+ isSubmenu: (ctx) => ctx.parentMenu != null,
+ },
+
+ watch: {
+ value: [
+ "restoreTabOrder",
+ "setActiveTriggerNode",
+ "syncTriggerRectObserver",
+ "setActiveContentNode",
+ "syncContentRectObserver",
+ "syncMotionAttribute",
+ ],
+ },
+
+ initial: "closed",
+
+ entry: ["checkViewportNode"],
+ exit: ["cleanupObservers"],
+
+ on: {
+ SET_PARENT: {
+ target: "open",
+ actions: ["setParentMenu", "setActiveTriggerNode", "syncTriggerRectObserver"],
+ },
+ SET_CHILD: {
+ actions: ["setChildMenu"],
+ },
+ TRIGGER_CLICK: [
+ {
+ guard: and("isItemOpen", "isRootMenu"),
+ actions: ["clearValue", "setClickCloseRef"],
+ },
+ {
+ target: "open",
+ actions: ["setValue", "setClickCloseRef"],
+ },
+ ],
+ TRIGGER_FOCUS: {
+ actions: ["focusTopLevelEl"],
+ },
+ },
+
+ states: {
+ closed: {
+ entry: ["cleanupObservers", "propagateClose"],
+ on: {
+ TRIGGER_ENTER: {
+ actions: ["clearCloseRefs"],
+ },
+ TRIGGER_MOVE: [
+ {
+ guard: "isSubmenu",
+ target: "open",
+ actions: ["setValue"],
+ },
+ {
+ target: "opening",
+ actions: ["setPointerMoveRef"],
+ },
+ ],
+ },
+ },
+
+ opening: {
+ after: {
+ OPEN_DELAY: {
+ target: "open",
+ actions: ["setValue"],
+ },
+ },
+ on: {
+ TRIGGER_LEAVE: {
+ target: "closed",
+ actions: ["clearValue", "clearPointerMoveRef"],
+ },
+ CONTENT_FOCUS: {
+ actions: ["focusContent", "restoreTabOrder"],
+ },
+ LINK_FOCUS: {
+ actions: ["focusLink"],
+ },
+ },
+ },
+
+ open: {
+ tags: ["open"],
+ activities: ["trackEscapeKey", "trackInteractionOutside", "preserveTabOrder"],
+ on: {
+ CONTENT_LEAVE: {
+ target: "closing",
+ },
+ TRIGGER_LEAVE: {
+ target: "closing",
+ actions: ["clearPointerMoveRef"],
+ },
+ CONTENT_FOCUS: {
+ actions: ["focusContent", "restoreTabOrder"],
+ },
+ LINK_FOCUS: {
+ actions: ["focusLink"],
+ },
+ CONTENT_DISMISS: {
+ target: "closed",
+ actions: ["focusTriggerIfNeeded", "clearValue", "clearPointerMoveRef"],
+ },
+ CONTENT_ENTER: {
+ actions: ["restoreTabOrder"],
+ },
+ TRIGGER_MOVE: {
+ guard: "isSubmenu",
+ actions: ["setValue"],
+ },
+ ROOT_CLOSE: {
+ // clear the previous value so indicator doesn't animate
+ actions: ["clearPreviousValue", "cleanupObservers"],
+ },
+ },
+ },
+
+ closing: {
+ tags: ["open"],
+ activities: ["trackInteractionOutside"],
+ after: {
+ CLOSE_DELAY: {
+ target: "closed",
+ actions: ["clearValue"],
+ },
+ },
+ on: {
+ CONTENT_DISMISS: {
+ target: "closed",
+ actions: ["focusTriggerIfNeeded", "clearValue", "clearPointerMoveRef"],
+ },
+ CONTENT_ENTER: {
+ target: "open",
+ actions: ["restoreTabOrder"],
+ },
+ TRIGGER_ENTER: {
+ actions: ["clearCloseRefs"],
+ },
+ TRIGGER_MOVE: [
+ {
+ guard: "isOpen",
+ target: "open",
+ actions: ["setValue", "setPointerMoveRef"],
+ },
+ {
+ target: "opening",
+ actions: ["setPointerMoveRef"],
+ },
+ ],
+ },
+ },
+ },
+ },
+ {
+ guards: {
+ isOpen: (ctx) => ctx.value !== null,
+ isItemOpen: (ctx, evt) => ctx.value === evt.value,
+ isRootMenu: (ctx) => ctx.isRootMenu,
+ isSubmenu: (ctx) => ctx.isSubmenu,
+ },
+ delays: {
+ OPEN_DELAY: (ctx) => ctx.openDelay,
+ CLOSE_DELAY: (ctx) => ctx.closeDelay,
+ },
+ activities: {
+ preserveTabOrder(ctx) {
+ if (!ctx.isViewportRendered) return
+ if (ctx.value == null) return
+ const contentEl = () => dom.getContentEl(ctx, ctx.value!)
+ return proxyTabFocus(contentEl, {
+ triggerElement: dom.getTriggerEl(ctx, ctx.value),
+ onFocusEnter() {
+ ctx.tabOrderCleanup?.()
+ },
+ })
+ },
+ trackInteractionOutside(ctx, _evt, { send }) {
+ if (ctx.value == null) return
+ if (ctx.isSubmenu) return
+
+ const getContentEl = () =>
+ ctx.isViewportRendered ? dom.getViewportEl(ctx) : dom.getContentEl(ctx, ctx.value!)
+
+ return trackInteractOutside(getContentEl, {
+ onFocusOutside(event) {
+ // remove tabbable elements from tab order
+ ctx.tabOrderCleanup?.()
+ ctx.tabOrderCleanup = removeFromTabOrder(dom.getTabbableEls(ctx, ctx.value!))
+
+ const { target } = event.detail.originalEvent
+ const rootEl = dom.getRootMenuEl(ctx)
+ if (contains(rootEl, target)) event.preventDefault()
+ },
+ onPointerDownOutside(event) {
+ const { target } = event.detail.originalEvent
+
+ const topLevelEls = dom.getTopLevelEls(ctx)
+ const isTrigger = topLevelEls.some((item) => contains(item, target))
+
+ const viewportEl = dom.getViewportEl(ctx)
+ const isRootViewport = ctx.isRootMenu && contains(viewportEl, target)
+ if (isTrigger || isRootViewport) event.preventDefault()
+ },
+ onInteractOutside(event) {
+ if (event.defaultPrevented) return
+ send({ type: "CONTENT_DISMISS", src: "interact-outside" })
+ },
+ })
+ },
+ trackEscapeKey(ctx, _evt, { send }) {
+ if (ctx.isSubmenu) return
+ const onKeyDown = (evt: KeyboardEvent) => {
+ if (evt.key === "Escape" && !evt.isComposing) {
+ ctx.wasEscapeCloseRef = true
+ send({ type: "CONTENT_DISMISS", src: "key.esc" })
+ }
+ }
+ return addDomEvent(dom.getDoc(ctx), "keydown", onKeyDown)
+ },
+ },
+ actions: {
+ clearCloseRefs(ctx) {
+ ctx.wasClickCloseRef = null
+ ctx.wasEscapeCloseRef = false
+ },
+ setPointerMoveRef(ctx, evt) {
+ ctx.hasPointerMoveOpenedRef = evt.value
+ },
+ clearPointerMoveRef(ctx) {
+ ctx.hasPointerMoveOpenedRef = null
+ },
+ cleanupObservers(ctx) {
+ ctx.activeContentCleanup?.()
+ ctx.activeTriggerCleanup?.()
+ ctx.tabOrderCleanup?.()
+ },
+ setActiveContentNode(ctx) {
+ ctx.activeContentNode = ctx.value != null ? dom.getContentEl(ctx, ctx.value) : null
+ },
+ setActiveTriggerNode(ctx) {
+ ctx.activeTriggerNode = ctx.value != null ? dom.getTriggerEl(ctx, ctx.value) : null
+ },
+ syncTriggerRectObserver(ctx) {
+ const node = ctx.activeTriggerNode
+ if (!node) return
+
+ ctx.activeTriggerCleanup?.()
+ const exec = () => {
+ ctx.activeTriggerRect = {
+ x: node.offsetLeft,
+ y: node.offsetTop,
+ width: node.offsetWidth,
+ height: node.offsetHeight,
+ }
+ }
+
+ ctx.activeTriggerCleanup = callAll(
+ trackResizeObserver(node, exec),
+ trackResizeObserver(dom.getIndicatorTrackEl(ctx), exec),
+ )
+ },
+ syncContentRectObserver(ctx) {
+ if (!ctx.isViewportRendered) return
+ const node = ctx.activeContentNode
+ if (!node) return
+ ctx.activeContentCleanup?.()
+ const exec = () => {
+ ctx.viewportSize = { width: node.offsetWidth, height: node.offsetHeight }
+ }
+ ctx.activeContentCleanup = trackResizeObserver(node, exec)
+ },
+ syncMotionAttribute(ctx) {
+ if (!ctx.isViewportRendered) return
+ if (ctx.isSubmenu) return
+ set.motionAttr(ctx)
+ },
+ setClickCloseRef(ctx, evt) {
+ ctx.wasClickCloseRef = evt.value
+ },
+ checkViewportNode(ctx) {
+ ctx.isViewportRendered = !!dom.getViewportEl(ctx)
+ },
+ clearPreviousValue(ctx) {
+ ctx.previousValue = null
+ },
+ clearValue(ctx) {
+ set.value(ctx, null)
+ },
+ setValue(ctx, evt) {
+ set.value(ctx, evt.value)
+ },
+ focusTopLevelEl(ctx, evt) {
+ const value = evt.value
+ if (evt.target === "next") dom.getNextTopLevelEl(ctx, value)?.focus()
+ else if (evt.target === "prev") dom.getPrevTopLevelEl(ctx, value)?.focus()
+ else if (evt.target === "first") dom.getFirstTopLevelEl(ctx)?.focus()
+ else if (evt.target === "last") dom.getLastTopLevelEl(ctx)?.focus()
+ else dom.getTriggerEl(ctx, value)?.focus()
+ },
+ focusLink(ctx, evt) {
+ const value = evt.value
+ if (evt.target === "next") dom.getNextLinkEl(ctx, value, evt.node)?.focus()
+ else if (evt.target === "prev") dom.getPrevLinkEl(ctx, value, evt.node)?.focus()
+ else if (evt.target === "first") dom.getFirstLinkEl(ctx, value)?.focus()
+ else if (evt.target === "last") dom.getLastLinkEl(ctx, value)?.focus()
+ },
+ focusContent(ctx, evt) {
+ raf(() => {
+ const tabbableEls = dom.getTabbableEls(ctx, evt.value)
+ tabbableEls[0]?.focus()
+ })
+ },
+ focusTriggerIfNeeded(ctx) {
+ if (!ctx.value) return
+ const contentEl = dom.getContentEl(ctx, ctx.value)
+ if (!contains(contentEl, dom.getActiveElement(ctx))) return
+ ctx.activeTriggerNode?.focus()
+ },
+ restoreTabOrder(ctx) {
+ ctx.tabOrderCleanup?.()
+ },
+ setParentMenu(ctx, evt) {
+ ctx.parentMenu = ref(evt.parent)
+ },
+ setChildMenu(ctx, evt) {
+ ctx.childMenus[evt.id] = evt.value
+ },
+ propagateClose(ctx) {
+ const menus = Object.values(ctx.childMenus)
+ menus.forEach((child) => {
+ child?.send({ type: "ROOT_CLOSE", src: ctx.id })
+ })
+ },
+ },
+ },
+ )
+}
+
+function removeFromTabOrder(nodes: HTMLElement[]) {
+ nodes.forEach((node) => {
+ node.dataset.tabindex = node.getAttribute("tabindex") || ""
+ node.setAttribute("tabindex", "-1")
+ })
+ return () => {
+ nodes.forEach((node) => {
+ if (node.dataset.tabindex == null) return
+ const prevTabIndex = node.dataset.tabindex
+ node.setAttribute("tabindex", prevTabIndex)
+ delete node.dataset.tabindex
+ if (node.getAttribute("tabindex") === "") node.removeAttribute("tabindex")
+ })
+ }
+}
+
+const invoke = {
+ valueChange(ctx: MachineContext) {
+ ctx.onValueChange?.({ value: ctx.value })
+ },
+}
+
+const set = {
+ value(ctx: MachineContext, value: string | null) {
+ if (ctx.value === value) return
+ ctx.previousValue = ctx.value
+ ctx.value = value
+ invoke.valueChange(ctx)
+ },
+
+ motionAttr(ctx: MachineContext) {
+ const triggers = dom.getTriggerEls(ctx)
+
+ let values = triggers.map((trigger) => trigger.getAttribute("data-value"))
+ if (ctx.dir === "rtl") values.reverse()
+
+ const index = values.indexOf(ctx.value)
+ const prevIndex = values.indexOf(ctx.previousValue)
+
+ const contentEls = dom.getContentEls(ctx)
+ contentEls.forEach((contentEl) => {
+ const value = contentEl.dataset.value!
+ const selected = ctx.value === value
+ const prevSelected = prevIndex === values.indexOf(value)
+
+ if (!selected && !prevSelected) {
+ delete contentEl.dataset.motion
+ return
+ }
+
+ const attribute = (() => {
+ // Don't provide a direction on the initial open
+ if (index !== prevIndex) {
+ // If we're moving to this item from another
+ if (selected && prevIndex !== -1) return index > prevIndex ? "from-end" : "from-start"
+ // If we're leaving this item for another
+ if (prevSelected && index !== -1) return index > prevIndex ? "to-start" : "to-end"
+ }
+ // Otherwise we're entering from closed or leaving the list
+ // entirely and should not animate in any direction
+ return undefined
+ })()
+
+ if (attribute) {
+ contentEl.dataset.motion = attribute
+ } else {
+ delete contentEl.dataset.motion
+ }
+ })
+ },
+}
diff --git a/packages/machines/navigation-menu/src/navigation-menu.props.ts b/packages/machines/navigation-menu/src/navigation-menu.props.ts
new file mode 100644
index 0000000000..cf30d2208b
--- /dev/null
+++ b/packages/machines/navigation-menu/src/navigation-menu.props.ts
@@ -0,0 +1,16 @@
+import { createProps } from "@zag-js/types"
+import { createSplitProps } from "@zag-js/utils"
+import type { UserDefinedContext } from "./navigation-menu.types"
+
+export const props = createProps()([
+ "id",
+ "dir",
+ "getRootNode",
+ "value",
+ "onValueChange",
+ "openDelay",
+ "closeDelay",
+ "orientation",
+])
+
+export const splitProps = createSplitProps>(props)
diff --git a/packages/machines/navigation-menu/src/navigation-menu.types.ts b/packages/machines/navigation-menu/src/navigation-menu.types.ts
new file mode 100644
index 0000000000..d5598019cd
--- /dev/null
+++ b/packages/machines/navigation-menu/src/navigation-menu.types.ts
@@ -0,0 +1,218 @@
+import type { Machine, StateMachine as S } from "@zag-js/core"
+import type {
+ CommonProperties,
+ DirectionProperty,
+ Orientation,
+ OrientationProperty,
+ PropTypes,
+ RequiredBy,
+} from "@zag-js/types"
+
+/* -----------------------------------------------------------------------------
+ * Callback details
+ * -----------------------------------------------------------------------------*/
+
+export interface ValueChangeDetails {
+ value: string | null
+}
+
+interface Size {
+ width: number
+ height: number
+}
+
+interface Rect extends Size {
+ y: number
+ x: number
+}
+
+/* -----------------------------------------------------------------------------
+ * Machine context
+ * -----------------------------------------------------------------------------*/
+
+interface PublicContext extends DirectionProperty, CommonProperties, OrientationProperty {
+ /**
+ * The value of the menu
+ */
+ value: string | null
+ /**
+ * Function called when the value of the menu changes
+ */
+ onValueChange?: (details: ValueChangeDetails) => void
+ /**
+ * The delay before the menu opens
+ */
+ openDelay: number
+ /**
+ * The delay before the menu closes
+ */
+ closeDelay: number
+}
+
+interface PrivateContext {
+ /**
+ * @internal
+ * The previous value of the menu
+ */
+ previousValue: string | null
+ /**
+ * @internal
+ * The size of the viewport
+ */
+ viewportSize: Size | null
+ /**
+ * @internal
+ * Whether the viewport is rendered
+ */
+ isViewportRendered: boolean
+ /**
+ * @internal
+ * Whether the menu was closed by a click
+ */
+ wasClickCloseRef: string | null
+ /**
+ * @internal
+ * Whether the menu was closed by escape key
+ */
+ wasEscapeCloseRef: boolean
+ /**
+ * @internal
+ * Whether the menu was open by pointer move
+ */
+ hasPointerMoveOpenedRef: string | null
+ /**
+ * @internal
+ * The active content node
+ */
+ activeContentNode: HTMLElement | null
+ /**
+ * @internal
+ * The cleanup function for the active content node
+ */
+ activeContentCleanup: VoidFunction | null
+ /**
+ * @internal
+ * The active trigger node
+ */
+ activeTriggerRect: Rect | null
+ /**
+ * @internal
+ * The active trigger node
+ */
+ activeTriggerNode: HTMLElement | null
+ /**
+ * @internal
+ * The cleanup function for the active trigger node
+ */
+ activeTriggerCleanup: VoidFunction | null
+ /**
+ * @internal
+ * The parent menu of this menu
+ */
+ parentMenu: Service | null
+ /**
+ * @internal
+ * The cleanup function for the inert attribute
+ */
+ tabOrderCleanup: VoidFunction | null
+ /**
+ * @internal
+ * The child menu of this menu
+ */
+ childMenus: Record
+}
+
+type ComputedContext = Readonly<{
+ isRootMenu: boolean
+ isSubmenu: boolean
+}>
+
+export type UserDefinedContext = RequiredBy
+
+export interface MachineContext extends PublicContext, PrivateContext, ComputedContext {}
+
+export interface MachineState {
+ value: "opening" | "open" | "closing" | "closed"
+}
+
+export type State = S.State
+
+export type Send = S.Send
+
+export type Service = Machine
+
+/* -----------------------------------------------------------------------------
+ * Component API
+ * -----------------------------------------------------------------------------*/
+
+export interface ItemProps {
+ /**
+ * The value of the item
+ */
+ value: string
+ /**
+ * Whether the item is disabled
+ */
+ disabled?: boolean | undefined
+}
+
+export interface ArrowProps {
+ /**
+ * The value of the item
+ */
+ value: string
+}
+
+export interface LinkProps {
+ /**
+ * The value of the item this link belongs to
+ */
+ value: string
+ /**
+ * Whether the link is the current link
+ */
+ current?: boolean | undefined
+ /**
+ * Function called when the link is selected
+ */
+ onSelect?: (event: CustomEvent) => void
+}
+
+export interface MachineApi {
+ /**
+ * The current value of the menu
+ */
+ value: string | null
+ /**
+ * Sets the value of the menu
+ */
+ setValue: (value: string) => void
+ /**
+ * Whether the menu is open
+ */
+ open: boolean
+ /**
+ * Sets the parent of the menu
+ */
+ setParent: (parent: Service) => void
+ /**
+ * Sets the child of the menu
+ */
+ setChild: (child: Service) => void
+ /**
+ * The orientation of the menu
+ */
+ orientation: Orientation
+
+ getRootProps(): T["element"]
+ getListProps(): T["element"]
+ getItemProps(props: ItemProps): T["element"]
+ getIndicatorTrackProps(): T["element"]
+ getIndicatorProps(): T["element"]
+ getArrowProps(props?: ArrowProps): T["element"]
+ getTriggerProps(props: ItemProps): T["button"]
+ getLinkProps(props: LinkProps): T["element"]
+ getContentProps(props: LinkProps): T["element"]
+ getViewportPositionerProps(): T["element"]
+ getViewportProps(): T["element"]
+}
diff --git a/packages/machines/navigation-menu/tsconfig.json b/packages/machines/navigation-menu/tsconfig.json
new file mode 100644
index 0000000000..8e781cd154
--- /dev/null
+++ b/packages/machines/navigation-menu/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../../tsconfig.json",
+ "include": ["src"],
+ "compilerOptions": {
+ "tsBuildInfoFile": "node_modules/.cache/.tsbuildinfo"
+ }
+}
diff --git a/packages/types/src/prop-types.ts b/packages/types/src/prop-types.ts
index c59159c735..668b623699 100644
--- a/packages/types/src/prop-types.ts
+++ b/packages/types/src/prop-types.ts
@@ -43,6 +43,7 @@ type DataAttr = {
}
export type PropTypes = Record<
+ | "a"
| "button"
| "label"
| "input"
diff --git a/packages/utilities/core/src/functions.ts b/packages/utilities/core/src/functions.ts
index b4bb4d8f44..1b1344911a 100644
--- a/packages/utilities/core/src/functions.ts
+++ b/packages/utilities/core/src/functions.ts
@@ -15,7 +15,7 @@ export const cast = (v: unknown): T => v as T
export const noop = () => {}
export const callAll =
- void>(...fns: (T | undefined)[]) =>
+ void>(...fns: (T | undefined | null)[]) =>
(...a: Parameters) => {
fns.forEach(function (fn) {
fn?.(...a)
diff --git a/packages/utilities/dom-query/src/proxy-tab-focus.ts b/packages/utilities/dom-query/src/proxy-tab-focus.ts
index b2694d0b6b..4d1c8dca1d 100644
--- a/packages/utilities/dom-query/src/proxy-tab-focus.ts
+++ b/packages/utilities/dom-query/src/proxy-tab-focus.ts
@@ -1,13 +1,14 @@
import { raf } from "./raf"
-import { getNextTabbable, getTabbableEdges } from "./tabbable"
+import { getNextTabbable, getTabbables } from "./tabbable"
type MaybeElement = HTMLElement | null
type NodeOrFn = MaybeElement | (() => MaybeElement)
interface ProxyTabFocusOptions {
- triggerElement?: T | undefined
- onFocus?: ((elementToFocus: HTMLElement) => void) | undefined
- defer?: boolean | undefined
+ triggerElement?: T
+ onFocus?: (elementToFocus: HTMLElement) => void
+ onFocusEnter?: () => void
+ defer?: boolean
}
/**
@@ -16,7 +17,7 @@ interface ProxyTabFocusOptions {
*/
function proxyTabFocusImpl(container: MaybeElement, options: ProxyTabFocusOptions = {}) {
- const { triggerElement, onFocus } = options
+ const { triggerElement, onFocus, onFocusEnter } = options
const doc = container?.ownerDocument || document
const body = doc.body
@@ -27,22 +28,36 @@ function proxyTabFocusImpl(container: MaybeElement, options: ProxyTabFocusOption
let elementToFocus: MaybeElement | undefined = null
// get all tabbable elements within the container
- const [firstTabbable, lastTabbable] = getTabbableEdges(container, true)
+ const tabbables = getTabbables(container, true)
+ const firstTabbable = tabbables[0]
+ const lastTabbable = tabbables[tabbables.length - 1]
+
+ const nextTabbableAfterTrigger = getNextTabbable(body, triggerElement)
const noTabbableElements = !firstTabbable && !lastTabbable
- // if we're focused on the first tabbable element and the user tabs backwards
- // we want to focus the reference element
- if (event.shiftKey && (doc.activeElement === firstTabbable || noTabbableElements)) {
+ if (event.shiftKey && nextTabbableAfterTrigger === doc.activeElement) {
+ // if we're focused on the element after the reference element and the user tabs backwards
+ // we want to focus the last tabbable element
+ onFocusEnter?.()
+ elementToFocus = lastTabbable
+ //
+ } else if (event.shiftKey && (doc.activeElement === firstTabbable || noTabbableElements)) {
+ // if we're focused on the first tabbable element and the user tabs backwards
+ // we want to focus the reference element
elementToFocus = triggerElement
+ //
} else if (!event.shiftKey && doc.activeElement === triggerElement) {
// if we're focused on the reference element and the user tabs forwards
// we want to focus the first tabbable element
+ onFocusEnter?.()
elementToFocus = firstTabbable
+ //
} else if (!event.shiftKey && (doc.activeElement === lastTabbable || noTabbableElements)) {
// if we're focused on the last tabbable element and the user tabs forwards
// we want to focus the next tabbable element after the reference element
- elementToFocus = getNextTabbable(body, triggerElement)
+ elementToFocus = nextTabbableAfterTrigger
+ //
}
if (!elementToFocus) return
@@ -72,7 +87,7 @@ export function proxyTabFocus(container: NodeOrFn, options: ProxyTabFocusOptions
func(() => {
const node = typeof container === "function" ? container() : container
const trigger = typeof triggerElement === "function" ? triggerElement() : triggerElement
- cleanups.push(proxyTabFocusImpl(node, { triggerElement: trigger, ...restOptions }))
+ cleanups.push(proxyTabFocusImpl(node, { triggerElement: trigger!, ...restOptions }))
}),
)
return () => {
diff --git a/packages/utilities/stringify-state/src/index.ts b/packages/utilities/stringify-state/src/index.ts
index 983ff9f130..fa5d5fa4d6 100644
--- a/packages/utilities/stringify-state/src/index.ts
+++ b/packages/utilities/stringify-state/src/index.ts
@@ -8,7 +8,7 @@ interface Dict {
const pick = (obj: Dict, keys: string[]) =>
Object.fromEntries(keys.filter((key) => key in obj).map((key) => [key, obj[key]]))
-const hasProp = (v: any, prop: string) => Object.prototype.hasOwnProperty.call(v, prop)
+const hasProp = (v: any, prop: string) => prop in v
const isTimeObject = (v: any) => hasProp(v, "hour") && hasProp(v, "minute") && hasProp(v, "second")
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index edf351816f..f38fc5e0df 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -263,6 +263,9 @@ importers:
'@zag-js/menu':
specifier: workspace:*
version: link:../../packages/machines/menu
+ '@zag-js/navigation-menu':
+ specifier: workspace:*
+ version: link:../../packages/machines/navigation-menu
'@zag-js/number-input':
specifier: workspace:*
version: link:../../packages/machines/number-input
@@ -513,6 +516,9 @@ importers:
'@zag-js/menu':
specifier: workspace:*
version: link:../../packages/machines/menu
+ '@zag-js/navigation-menu':
+ specifier: workspace:*
+ version: link:../../packages/machines/navigation-menu
'@zag-js/number-input':
specifier: workspace:*
version: link:../../packages/machines/number-input
@@ -799,6 +805,9 @@ importers:
'@zag-js/menu':
specifier: workspace:*
version: link:../../packages/machines/menu
+ '@zag-js/navigation-menu':
+ specifier: workspace:*
+ version: link:../../packages/machines/navigation-menu
'@zag-js/number-input':
specifier: workspace:*
version: link:../../packages/machines/number-input
@@ -1055,6 +1064,9 @@ importers:
'@zag-js/menu':
specifier: workspace:*
version: link:../../packages/machines/menu
+ '@zag-js/navigation-menu':
+ specifier: workspace:*
+ version: link:../../packages/machines/navigation-menu
'@zag-js/number-input':
specifier: workspace:*
version: link:../../packages/machines/number-input
@@ -1308,6 +1320,9 @@ importers:
'@zag-js/menu':
specifier: workspace:*
version: link:../../packages/machines/menu
+ '@zag-js/navigation-menu':
+ specifier: workspace:*
+ version: link:../../packages/machines/navigation-menu
'@zag-js/number-input':
specifier: workspace:*
version: link:../../packages/machines/number-input
@@ -1585,6 +1600,9 @@ importers:
'@zag-js/menu':
specifier: workspace:*
version: link:../../packages/machines/menu
+ '@zag-js/navigation-menu':
+ specifier: workspace:*
+ version: link:../../packages/machines/navigation-menu
'@zag-js/number-input':
specifier: workspace:*
version: link:../../packages/machines/number-input
@@ -1825,6 +1843,9 @@ importers:
'@zag-js/menu':
specifier: workspace:*
version: link:../../packages/machines/menu
+ '@zag-js/navigation-menu':
+ specifier: workspace:*
+ version: link:../../packages/machines/navigation-menu
'@zag-js/number-input':
specifier: workspace:*
version: link:../../packages/machines/number-input
@@ -2090,6 +2111,9 @@ importers:
'@zag-js/menu':
specifier: workspace:*
version: link:../../packages/machines/menu
+ '@zag-js/navigation-menu':
+ specifier: workspace:*
+ version: link:../../packages/machines/navigation-menu
'@zag-js/number-input':
specifier: workspace:*
version: link:../../packages/machines/number-input
@@ -2852,6 +2876,34 @@ importers:
specifier: 2.2.0
version: 2.2.0
+ packages/machines/navigation-menu:
+ dependencies:
+ '@zag-js/anatomy':
+ specifier: workspace:*
+ version: link:../../anatomy
+ '@zag-js/core':
+ specifier: workspace:*
+ version: link:../../core
+ '@zag-js/dom-event':
+ specifier: workspace:*
+ version: link:../../utilities/dom-event
+ '@zag-js/dom-query':
+ specifier: workspace:*
+ version: link:../../utilities/dom-query
+ '@zag-js/interact-outside':
+ specifier: workspace:*
+ version: link:../../utilities/interact-outside
+ '@zag-js/types':
+ specifier: workspace:*
+ version: link:../../types
+ '@zag-js/utils':
+ specifier: workspace:*
+ version: link:../../utilities/core
+ devDependencies:
+ clean-package:
+ specifier: 2.2.0
+ version: 2.2.0
+
packages/machines/number-input:
dependencies:
'@internationalized/number':
diff --git a/shared/src/controls.ts b/shared/src/controls.ts
index dd66aaf546..20371fd90b 100644
--- a/shared/src/controls.ts
+++ b/shared/src/controls.ts
@@ -283,3 +283,9 @@ export const stepsControls = defineControls({
linear: { type: "boolean", defaultValue: false },
orientation: { type: "select", options: ["horizontal", "vertical"] as const, defaultValue: "horizontal" },
})
+
+export const navigationMenuControls = defineControls({
+ dir: { type: "select", options: ["ltr", "rtl"] as const, defaultValue: "ltr" },
+ openDelay: { type: "number", defaultValue: 200 },
+ closeDelay: { type: "number", defaultValue: 300 },
+})
diff --git a/shared/src/css/keyframes.css b/shared/src/css/keyframes.css
new file mode 100644
index 0000000000..62e6fd35b4
--- /dev/null
+++ b/shared/src/css/keyframes.css
@@ -0,0 +1,83 @@
+@keyframes nav-menu-from-right {
+ from {
+ transform: translate3d(200px, 0, 0);
+ opacity: 0;
+ }
+ to {
+ transform: translate3d(0, 0, 0);
+ opacity: 1;
+ }
+}
+
+@keyframes nav-menu-from-left {
+ from {
+ transform: translate3d(-200px, 0, 0);
+ opacity: 0;
+ }
+ to {
+ transform: translate3d(0, 0, 0);
+ opacity: 1;
+ }
+}
+
+@keyframes nav-menu-to-right {
+ from {
+ transform: translate3d(0, 0, 0);
+ opacity: 1;
+ }
+ to {
+ transform: translate3d(200px, 0, 0);
+ opacity: 0;
+ }
+}
+
+@keyframes nav-menu-to-left {
+ from {
+ transform: translate3d(0, 0, 0);
+ opacity: 1;
+ }
+ to {
+ transform: translate3d(-200px, 0, 0);
+ opacity: 0;
+ }
+}
+
+@keyframes nav-menu-scale-in {
+ from {
+ transform: scale(0.9);
+ opacity: 0;
+ }
+ to {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
+@keyframes nav-menu-scale-out {
+ from {
+ transform: scale(1);
+ opacity: 1;
+ }
+ to {
+ transform: scale(0.95);
+ opacity: 0;
+ }
+}
+
+@keyframes nav-menu-fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes nav-menu-fade-out {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0;
+ }
+}
diff --git a/shared/src/css/navigation-menu-nested.css b/shared/src/css/navigation-menu-nested.css
new file mode 100644
index 0000000000..7044982f76
--- /dev/null
+++ b/shared/src/css/navigation-menu-nested.css
@@ -0,0 +1,203 @@
+@import url(./keyframes.css);
+
+.navigation-menu.nested {
+ [data-scope="navigation-menu"][data-part="root"] {
+ --arrow-size: 20px;
+ --indicator-size: 10px;
+
+ &[data-type="submenu"] {
+ display: grid;
+ width: 100%;
+ max-width: 800px;
+ gap: 20px;
+
+ &[data-orientation="vertical"] {
+ grid-template-columns: 0.3fr 1fr;
+ }
+
+ &[data-orientation="horizontal"] {
+ justify-items: center;
+ margin-top: -10px;
+ }
+ }
+ }
+
+ [data-scope="navigation-menu"][data-part="list"] {
+ all: unset;
+ list-style: none;
+ display: flex;
+
+ &[data-orientation="vertical"] {
+ flex-direction: column;
+ }
+ }
+
+ [data-scope="navigation-menu"][data-part="trigger"] {
+ border: 0;
+ background: transparent;
+ font-size: inherit;
+ gap: 4px;
+ padding: 10px 16px;
+ font-weight: bold;
+
+ & > svg {
+ transition: rotate 200ms ease;
+ width: 14px;
+ height: 14px;
+ }
+
+ &[data-state="open"] > svg {
+ rotate: -180deg;
+ }
+
+ &[data-type="submenu"] {
+ position: relative;
+ width: 100%;
+ border-radius: 4px;
+
+ &[data-state="open"] {
+ background-color: #f3f4f5;
+ }
+ }
+ }
+
+ [data-scope="navigation-menu"][data-part="link"] {
+ padding: 10px;
+ font-weight: bold;
+ color: inherit;
+ display: block;
+ text-decoration: none;
+ }
+
+ [data-scope="navigation-menu"][data-part="content"] {
+ &[data-type="root"] {
+ display: flex;
+ justify-content: center;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ padding-top: 35px;
+ padding-bottom: 35px;
+ &[data-motion="from-start"] {
+ animation: nav-menu-from-left 250ms ease;
+ }
+
+ &[data-motion="from-end"] {
+ animation: nav-menu-from-right 250ms ease;
+ }
+
+ &[data-motion="to-start"] {
+ animation: nav-menu-to-left 250ms ease;
+ }
+
+ &[data-motion="to-end"] {
+ animation: nav-menu-to-right 250ms ease;
+ }
+ }
+
+ &[data-type="submenu"] {
+ display: grid;
+ gap: 20px;
+ width: 100%;
+ }
+ }
+
+ [data-scope="navigation-menu"][data-part="viewport"] {
+ overflow: hidden;
+ width: 100%;
+
+ &[data-type="root"] {
+ position: absolute;
+ left: 0;
+ top: 100%;
+ border-top: 1px solid #dcdfe3;
+ transform-origin: top center;
+ background-color: white;
+ height: var(--viewport-height);
+ transition: height 300ms ease;
+ box-shadow:
+ 0 50px 100px -20px rgba(50, 50, 93, 0.1),
+ 0 30px 60px -30px rgba(0, 0, 0, 0.2);
+
+ &[data-state="open"] {
+ animation: fadeIn 250ms ease;
+ }
+
+ &[data-state="closed"] {
+ animation: fadeOut 250ms ease;
+ }
+ }
+ }
+
+ [data-scope="navigation-menu"][data-part="indicator"] {
+ &[data-orientation="horizontal"] {
+ left: 0px;
+ translate: var(--trigger-x) 0;
+ width: var(--trigger-width);
+ }
+
+ &[data-orientation="vertical"] {
+ top: 0px;
+ translate: 0 var(--trigger-y);
+ height: var(--trigger-height);
+ }
+
+ &[data-type="root"] {
+ display: flex;
+ justify-content: center;
+ height: var(--indicator-size);
+ z-index: 1;
+ transition:
+ translate 250ms ease,
+ width 250ms ease;
+ overflow: hidden;
+ bottom: calc(calc(var(--indicator-size) + var(--arrow-size)) * -1);
+
+ &[data-state="open"] {
+ animation: nav-menu-fade-in 250ms ease;
+ }
+
+ &[data-state="closed"] {
+ animation: nav-menu-fade-out 250ms ease;
+ }
+ }
+
+ &[data-type="submenu"] {
+ background-color: black;
+ border-radius: 4px;
+
+ &[data-orientation="vertical"] {
+ width: 3px;
+ transition:
+ translate 250ms ease,
+ height 250ms ease;
+
+ [dir="ltr"] & {
+ right: 0;
+ }
+ [dir="rtl"] & {
+ left: 0;
+ }
+ }
+
+ &[data-orientation="horizontal"] {
+ height: 3px;
+ bottom: 0;
+ transition:
+ translate 250ms ease,
+ width 250ms ease;
+ }
+ }
+ }
+
+ [data-scope="navigation-menu"][data-part="arrow"] {
+ position: relative;
+ top: 4px;
+ width: var(--arrow-size);
+ height: var(--arrow-size);
+ background-color: white;
+ rotate: 45deg;
+ border-radius: 3px;
+ }
+}
diff --git a/shared/src/css/navigation-menu-viewport.css b/shared/src/css/navigation-menu-viewport.css
new file mode 100644
index 0000000000..2b547fcfc7
--- /dev/null
+++ b/shared/src/css/navigation-menu-viewport.css
@@ -0,0 +1,163 @@
+@import url(./keyframes.css);
+
+.navigation-menu.viewport {
+ [data-scope="navigation-menu"][data-part="root"] {
+ --arrow-size: 20px;
+ --indicator-size: 10px;
+ }
+
+ [data-scope="navigation-menu"][data-part="list"] {
+ all: unset;
+ list-style: none;
+ display: flex;
+
+ &[data-orientation="vertical"] {
+ flex-direction: column;
+ }
+ }
+
+ [data-scope="navigation-menu"][data-part="trigger"] {
+ padding: 10px 16px;
+ font-weight: bold;
+ display: flex;
+ align-items: center;
+ border: 0;
+ background: transparent;
+ font-size: inherit;
+ gap: 4px;
+
+ & > svg {
+ transition: rotate 200ms ease;
+ width: 14px;
+ height: 14px;
+ }
+
+ &[data-state="open"] > svg {
+ rotate: -180deg;
+ }
+ }
+
+ [data-scope="navigation-menu"][data-part="link"] {
+ padding: 10px;
+ font-weight: bold;
+ color: inherit;
+ display: block;
+ text-decoration: none;
+ }
+
+ [data-scope="navigation-menu"][data-part="content"] {
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: grid;
+ gap: 20px;
+ padding: 40px;
+
+ &[data-motion="from-start"] {
+ animation: nav-menu-from-left 250ms ease;
+ }
+
+ &[data-motion="from-end"] {
+ animation: nav-menu-from-right 250ms ease;
+ }
+
+ &[data-motion="to-start"] {
+ animation: nav-menu-to-left 250ms ease;
+ }
+
+ &[data-motion="to-end"] {
+ animation: nav-menu-to-right 250ms ease;
+ }
+ }
+
+ [data-scope="navigation-menu"][data-part="viewport-positioner"] {
+ position: absolute;
+ display: flex;
+ justify-content: center;
+
+ &[data-orientation="horizontal"] {
+ width: 100%;
+ top: 100%;
+ left: 0;
+ }
+
+ &[data-orientation="vertical"] {
+ height: 100%;
+ top: 0;
+ left: 100%;
+ }
+ }
+
+ [data-scope="navigation-menu"][data-part="viewport"] {
+ position: relative;
+ top: 0;
+ left: 0;
+ display: grid;
+ gap: 20px;
+ padding: 40px;
+ background-color: white;
+ transition:
+ width,
+ height,
+ 300ms ease;
+ width: var(--viewport-width);
+ height: var(--viewport-height);
+ transform-origin: top center;
+ overflow: hidden;
+ margin-top: 15px;
+ border-radius: 8px;
+ box-shadow:
+ 0 50px 100px -20px rgba(50, 50, 93, 0.25),
+ 0 30px 60px -30px rgba(0, 0, 0, 0.3);
+
+ &[data-state="open"] {
+ animation: nav-menu-scale-in 300ms ease;
+ }
+
+ &[data-state="closed"] {
+ animation: nav-menu-scale-out 300ms ease;
+ }
+ }
+
+ [data-scope="navigation-menu"][data-part="indicator"] {
+ display: flex;
+ justify-content: center;
+ height: var(--indicator-size);
+ z-index: 1;
+ transition:
+ translate 250ms ease,
+ width 250ms ease;
+ overflow: hidden;
+ bottom: calc(calc(var(--indicator-size) + var(--arrow-size)) * -1);
+
+ &[data-orientation="horizontal"] {
+ left: 0px;
+ translate: var(--trigger-x) 0;
+ width: var(--trigger-width);
+ }
+
+ &[data-orientation="vertical"] {
+ top: 0px;
+ translate: 0 var(--trigger-y);
+ height: var(--trigger-height);
+ }
+
+ &[data-state="open"] {
+ animation: nav-menu-fade-in 250ms ease;
+ }
+
+ &[data-state="closed"] {
+ animation: nav-menu-fade-out 250ms ease;
+ }
+ }
+}
+
+[data-scope="navigation-menu"][data-part="arrow"] {
+ position: relative;
+ top: 4px;
+ width: var(--arrow-size);
+ height: var(--arrow-size);
+ background-color: white;
+ rotate: 45deg;
+ border-radius: 3px;
+}
diff --git a/shared/src/css/navigation-menu.css b/shared/src/css/navigation-menu.css
new file mode 100644
index 0000000000..4034925a5e
--- /dev/null
+++ b/shared/src/css/navigation-menu.css
@@ -0,0 +1,131 @@
+@import url(./keyframes.css);
+
+.navigation-menu.basic {
+ [data-scope="navigation-menu"][data-part="root"] {
+ --arrow-size: 20px;
+ --indicator-size: 10px;
+ }
+
+ [data-scope="navigation-menu"][data-part="list"] {
+ all: unset;
+ list-style: none;
+ display: flex;
+
+ &[data-orientation="vertical"] {
+ flex-direction: column;
+ }
+ }
+
+ [data-scope="navigation-menu"][data-part="item"] {
+ position: relative;
+ }
+
+ [data-scope="navigation-menu"][data-part="trigger"] {
+ padding: 10px 16px;
+ font-weight: bold;
+ display: flex;
+ align-items: center;
+ border: 0;
+ background: transparent;
+ font-size: inherit;
+ gap: 4px;
+
+ & > svg {
+ transition: rotate 200ms ease;
+ width: 14px;
+ height: 14px;
+ }
+
+ &[data-state="open"] > svg {
+ rotate: -180deg;
+ }
+ }
+
+ [data-scope="navigation-menu"][data-part="link"] {
+ padding: 10px 16px;
+ font-weight: bold;
+ display: block;
+ color: inherit;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ [data-scope="navigation-menu"][data-part="content"] {
+ position: absolute;
+ top: 100%;
+ width: max-content;
+ left: 0;
+ margin-top: 5px;
+ gap: 20px;
+ border-radius: 10px;
+ background-color: white;
+ padding: 20px;
+ transform-origin: top left;
+ box-shadow:
+ 0 10px 100px -20px rgba(50, 50, 93, 0.25),
+ 0 30px 60px -30px rgba(0, 0, 0, 0.3);
+ z-index: 1;
+
+ &[dir="rtl"] {
+ left: unset;
+ right: 0;
+ transform-origin: top right;
+ }
+
+ &[data-state="open"] {
+ animation: nav-menu-scale-in 250ms ease;
+ }
+
+ &[data-state="closed"] {
+ animation: nav-menu-scale-out 250ms ease;
+ }
+ }
+
+ /* Indicator + Arrow */
+
+ [data-scope="navigation-menu"][data-part="arrow"] {
+ position: relative;
+ top: 4px;
+ width: var(--arrow-size);
+ height: var(--arrow-size);
+ background-color: white;
+ rotate: 45deg;
+ border-radius: 3px;
+ }
+
+ [data-scope="navigation-menu"][data-part="indicator"] {
+ display: flex;
+ justify-content: center;
+ height: var(--indicator-size);
+ z-index: 1;
+ transition:
+ translate 250ms ease,
+ width 250ms ease;
+ overflow: hidden;
+
+ &[data-orientation="horizontal"] {
+ left: 0px;
+ translate: var(--trigger-x) 0;
+ top: calc(var(--indicator-size) * -1);
+ width: var(--trigger-width);
+ }
+
+ &[data-orientation="vertical"] {
+ top: 0px;
+ left: calc(var(--indicator-size) * -1);
+ translate: 0 var(--trigger-y);
+ height: var(--trigger-height);
+ }
+
+ &[data-state="open"] {
+ animation: nav-menu-fade-in 250ms ease;
+ }
+
+ &[data-state="closed"] {
+ animation: nav-menu-fade-out 250ms ease;
+ }
+ }
+}
diff --git a/shared/src/routes.ts b/shared/src/routes.ts
index af9a34e271..a2100baa8c 100644
--- a/shared/src/routes.ts
+++ b/shared/src/routes.ts
@@ -4,6 +4,9 @@ type RouteData = {
}
export const routesData: RouteData[] = [
+ { label: "Navigation Menu", path: "/navigation-menu" },
+ { label: "Navigation Menu (Viewport)", path: "/navigation-menu-viewport" },
+ { label: "Navigation Menu (Nested)", path: "/navigation-menu-nested" },
{ label: "Steps", path: "/steps" },
{ label: "QR Code", path: "/qr-code" },
{ label: "Time Picker", path: "/time-picker" },
diff --git a/shared/src/style.css b/shared/src/style.css
index d5c786f177..b467859127 100644
--- a/shared/src/style.css
+++ b/shared/src/style.css
@@ -23,6 +23,10 @@
@import url("./css/menu.css");
+@import url("./css/navigation-menu.css");
+@import url("./css/navigation-menu-viewport.css");
+@import url("./css/navigation-menu-nested.css");
+
@import url("./css/number-input.css");
@import url("./css/steps.css");