diff --git a/packages/machines/menu/package.json b/packages/machines/menu/package.json index 7d213f7cbe..7ed7f51816 100644 --- a/packages/machines/menu/package.json +++ b/packages/machines/menu/package.json @@ -27,13 +27,16 @@ }, "dependencies": { "@zag-js/anatomy": "workspace:*", + "@zag-js/aria-hidden": "workspace:*", "@zag-js/core": "workspace:*", - "@zag-js/dom-query": "workspace:*", - "@zag-js/rect-utils": "workspace:*", - "@zag-js/utils": "workspace:*", "@zag-js/dismissable": "workspace:*", + "@zag-js/dom-query": "workspace:*", + "@zag-js/focus-trap": "workspace:*", "@zag-js/popper": "workspace:*", - "@zag-js/types": "workspace:*" + "@zag-js/rect-utils": "workspace:*", + "@zag-js/remove-scroll": "workspace:*", + "@zag-js/types": "workspace:*", + "@zag-js/utils": "workspace:*" }, "devDependencies": { "clean-package": "2.2.0" diff --git a/packages/machines/menu/src/menu.connect.ts b/packages/machines/menu/src/menu.connect.ts index eddcf23473..079d77c8f9 100644 --- a/packages/machines/menu/src/menu.connect.ts +++ b/packages/machines/menu/src/menu.connect.ts @@ -299,6 +299,7 @@ export function connect(service: Service, norma ...parts.content.attrs, id: dom.getContentId(scope), "aria-label": prop("aria-label"), + "aria-modal": ariaAttr(prop("modal")), hidden: !open, "data-state": open ? "open" : "closed", role: composite ? "menu" : "dialog", diff --git a/packages/machines/menu/src/menu.machine.ts b/packages/machines/menu/src/menu.machine.ts index 2b2cc3a346..561e33cb4f 100644 --- a/packages/machines/menu/src/menu.machine.ts +++ b/packages/machines/menu/src/menu.machine.ts @@ -16,6 +16,9 @@ import { import { getPlacement, getPlacementSide, type Placement } from "@zag-js/popper" import { getElementPolygon, isPointInPolygon, type Point } from "@zag-js/rect-utils" import { isEqual } from "@zag-js/utils" +import { ariaHidden } from "@zag-js/aria-hidden" +import { trapFocus } from "@zag-js/focus-trap" +import { preventBodyScroll } from "@zag-js/remove-scroll" import * as dom from "./menu.dom" import type { ChildMenuService, MenuSchema, ParentMenuService } from "./menu.types" @@ -28,6 +31,7 @@ export const machine = createMachine({ typeahead: true, composite: true, loopFocus: false, + modal: false, navigate(details) { clickIfLink(details.node) }, @@ -381,7 +385,14 @@ export const machine = createMachine({ open: { tags: ["open"], - effects: ["trackInteractOutside", "trackPositioning", "scrollToHighlightedItem"], + effects: [ + "trackInteractOutside", + "trackPositioning", + "scrollToHighlightedItem", + "trapFocus", + "preventScroll", + "hideContentBelow", + ], entry: ["focusMenu", "resumePointer"], on: { "CONTROLLED.CLOSE": [ @@ -565,6 +576,7 @@ export const machine = createMachine({ let restoreFocus = true return trackDismissableElement(getContentEl, { type: "menu", + pointerBlocking: prop("modal"), defer: true, exclude: [dom.getTriggerEl(scope)], onInteractOutside: prop("onInteractOutside"), @@ -640,6 +652,27 @@ export const machine = createMachine({ callback: exec, }) }, + hideContentBelow({ prop, scope }) { + if (!prop("modal")) return + const getElements = () => [dom.getContentEl(scope), dom.getTriggerEl(scope)] + return ariaHidden(getElements, { defer: true }) + }, + preventScroll({ prop, scope }) { + if (!prop("modal")) return + return preventBodyScroll(scope.getDoc()) + }, + trapFocus({ prop, scope }) { + if (!prop("modal")) return + const contentEl = () => dom.getContentEl(scope) + return trapFocus(contentEl, { + initialFocus: () => + getInitialFocus({ + root: dom.getContentEl(scope), + enabled: true, + }), + getShadowRoot: true, + }) + }, }, actions: { diff --git a/packages/machines/menu/src/menu.props.ts b/packages/machines/menu/src/menu.props.ts index 43aea1059b..73458cfff6 100644 --- a/packages/machines/menu/src/menu.props.ts +++ b/packages/machines/menu/src/menu.props.ts @@ -15,6 +15,7 @@ export const props = createProps()([ "id", "ids", "loopFocus", + "modal", "navigate", "onEscapeKeyDown", "onFocusOutside", diff --git a/packages/machines/menu/src/menu.types.ts b/packages/machines/menu/src/menu.types.ts index faac6f93af..e0a4ddcbc0 100644 --- a/packages/machines/menu/src/menu.types.ts +++ b/packages/machines/menu/src/menu.types.ts @@ -90,6 +90,16 @@ export interface MenuProps extends DirectionProperty, CommonProperties, Dismissa * @default true */ closeOnSelect?: boolean | undefined + /** + * Whether the menu should be modal. When set to `true`: + * - interaction with outside elements will be disabled + * - only menu content will be visible to screen readers + * - scrolling is blocked + * - focus is trapped within the menu + * + * @default false + */ + modal?: boolean | undefined /** * The accessibility label for the menu */ @@ -123,7 +133,7 @@ export interface MenuProps extends DirectionProperty, CommonProperties, Dismissa navigate?: ((details: NavigateDetails) => void) | null | undefined } -type PropsWithDefault = "closeOnSelect" | "typeahead" | "composite" | "positioning" | "loopFocus" +type PropsWithDefault = "closeOnSelect" | "typeahead" | "composite" | "positioning" | "loopFocus" | "modal" export interface MenuSchema { props: RequiredBy diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13c88af5cb..4bc9094f31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2555,6 +2555,9 @@ importers: '@zag-js/anatomy': specifier: workspace:* version: link:../../anatomy + '@zag-js/aria-hidden': + specifier: workspace:* + version: link:../../utilities/aria-hidden '@zag-js/core': specifier: workspace:* version: link:../../core @@ -2564,12 +2567,18 @@ importers: '@zag-js/dom-query': specifier: workspace:* version: link:../../utilities/dom-query + '@zag-js/focus-trap': + specifier: workspace:* + version: link:../../utilities/focus-trap '@zag-js/popper': specifier: workspace:* version: link:../../utilities/popper '@zag-js/rect-utils': specifier: workspace:* version: link:../../utilities/rect + '@zag-js/remove-scroll': + specifier: workspace:* + version: link:../../utilities/remove-scroll '@zag-js/types': specifier: workspace:* version: link:../../types diff --git a/shared/src/controls.ts b/shared/src/controls.ts index 0acd8efbcf..1da73fe30f 100644 --- a/shared/src/controls.ts +++ b/shared/src/controls.ts @@ -60,6 +60,7 @@ export const editableControls = defineControls({ export const menuControls = defineControls({ closeOnSelect: { type: "boolean", defaultValue: true }, loopFocus: { type: "boolean", defaultValue: false }, + modal: { type: "boolean", defaultValue: false }, }) export const hoverCardControls = defineControls({