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 ( + <> +
+ +
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ + Pricing + +
+
+ + + +
+
+
+
+ +
+ +
+ +
+ +
+ +
+
+
+
+ + + + {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");