diff --git a/.changeset/dialog-non-modal.md b/.changeset/dialog-non-modal.md new file mode 100644 index 0000000000..0f83d867bb --- /dev/null +++ b/.changeset/dialog-non-modal.md @@ -0,0 +1,7 @@ +--- +"@zag-js/dialog": patch +--- + +- Set `pointer-events: none` on positioner in non-modal mode so the page stays interactive +- Add initial focus management for non-modal mode (mirrors popover behavior) +- Fix `aria-modal` to reflect actual `modal` prop value instead of hardcoded `true` diff --git a/.changeset/drawer-swipe-area.md b/.changeset/drawer-swipe-area.md new file mode 100644 index 0000000000..e2bc05062f --- /dev/null +++ b/.changeset/drawer-swipe-area.md @@ -0,0 +1,18 @@ +--- +"@zag-js/drawer": minor +--- + +- Add `description` anatomy part with `aria-describedby` support on the content element +- Add `SwipeArea` part for swipe-to-open gestures from screen edges + + ```tsx +
+ ``` + +- Add `getDescriptionProps()` and `getSwipeAreaProps()` to the connect API +- Require intentional swipe movement before showing the drawer (no flash on pointer down) +- Smooth settle animation from release point to fully open position +- Add cross-axis scroll preservation to prevent drawer drag when scrolling horizontally +- Fix swipe-to-dismiss in controlled mode (`open: true` without `onOpenChange` now blocks dismiss) +- Set `pointer-events: none` on positioner in non-modal mode so the page stays interactive +- Add initial focus management for non-modal mode diff --git a/e2e/drawer-swipe-area.e2e.ts b/e2e/drawer-swipe-area.e2e.ts new file mode 100644 index 0000000000..35e758ccb8 --- /dev/null +++ b/e2e/drawer-swipe-area.e2e.ts @@ -0,0 +1,74 @@ +import { test } from "@playwright/test" +import { DrawerModel } from "./models/drawer.model" + +let I: DrawerModel + +test.describe("drawer [swipe-area]", () => { + test.beforeEach(async ({ page }) => { + I = new DrawerModel(page) + await I.goto("/drawer/swipe-area") + }) + + test("should open when swiped up from swipe area", async () => { + await I.swipeArea("up", 300) + await I.seeContent() + await I.seeBackdrop() + }) + + test("should not open on pointer down without swipe", async ({ page }) => { + const swipeArea = page.locator("[data-part=swipe-area]") + const box = await swipeArea.boundingBox() + if (!box) throw new Error("Swipe area not found") + + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await page.mouse.down() + await page.waitForTimeout(200) + await page.mouse.up() + + await I.dontSeeContent() + }) + + test("should not open on small swipe", async () => { + await I.swipeArea("up", 3) + await I.dontSeeContent() + }) + + test("should close when clicked outside after swipe open", async () => { + await I.swipeArea("up", 300) + await I.waitForOpenState() + + await I.clickOutside() + + await I.dontSeeContent() + await I.dontSeeBackdrop() + }) + + test("should close on escape after swipe open", async () => { + await I.swipeArea("up", 300) + await I.waitForOpenState() + + await I.pressKey("Escape") + + await I.dontSeeContent() + }) + + test("should close when swiped down after swipe open", async () => { + await I.swipeArea("up", 300) + await I.waitForOpenState() + + await I.dragGrabber("down", 200, 100) + + await I.dontSeeContent() + await I.dontSeeBackdrop() + }) + + test("should close via close button after swipe open", async ({ page }) => { + await I.swipeArea("up", 300) + await I.waitForOpenState() + + await page.click("[data-part=close-trigger]") + + await I.dontSeeContent() + await I.dontSeeBackdrop() + }) +}) diff --git a/e2e/drawer.e2e.ts b/e2e/drawer.e2e.ts index 6434fa7177..a21bcc2ef9 100644 --- a/e2e/drawer.e2e.ts +++ b/e2e/drawer.e2e.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test" +import { controls } from "./_utils" import { DrawerModel } from "./models/drawer.model" let I: DrawerModel @@ -54,7 +55,7 @@ test.describe("drawer", () => { await I.waitForOpenState() const initialHeight = await I.getContentVisibleHeight() - const dragDistance = Math.floor(initialHeight * 0.3) + const dragDistance = Math.floor(initialHeight * 0.55) await I.dragGrabber("down", dragDistance) await I.dontSeeContent() @@ -153,27 +154,65 @@ test.describe("drawer [snapPoints]", () => { await I.seeContent() await I.waitForOpenState() + const rem20Px = await I.page.evaluate(() => { + const rootPx = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) + return 20 * rootPx + }) + const initialHeight = await I.getContentVisibleHeight() - const viewportHeight = await I.page.evaluate(() => window.innerHeight) - const lowerHeight = Math.min(initialHeight, viewportHeight * 0.25) - const dragTo250 = Math.max(0, initialHeight - 250) + 20 + expect(Math.abs(initialHeight - rem20Px)).toBeLessThanOrEqual(4) - // Drag down enough to target the 250px snap point without crossing below the lowest snap point. - await I.dragGrabber("down", dragTo250) + await I.dragGrabber("up", 250) await I.waitForSnapComplete() let currentHeight = await I.getContentVisibleHeight() + expect(currentHeight).toBeGreaterThan(initialHeight + 80) - const firstSnapIs250 = Math.abs(currentHeight - 250) <= 1 - const firstSnapIsLower = Math.abs(currentHeight - lowerHeight) <= 1 - expect(firstSnapIs250 || firstSnapIsLower).toBe(true) - - // Drag up to target the 250px snap point. - await I.dragGrabber("up", 120) + await I.dragGrabber("down", 250) await I.waitForSnapComplete() currentHeight = await I.getContentVisibleHeight() + expect(Math.abs(currentHeight - rem20Px)).toBeLessThanOrEqual(4) + }) +}) + +test.describe("drawer [snapToSequentialPoints]", () => { + test.beforeEach(async ({ page }) => { + I = new DrawerModel(page) + await I.goto("/drawer/snap-points") + await controls(page).bool("snapToSequentialPoints", true) + }) + + test("should advance one step on swipe up", async () => { + await I.clickTrigger() + await I.seeContent() + await I.waitForOpenState() + + const initialHeight = await I.getContentVisibleHeight() - expect(currentHeight).toBeCloseTo(250, 0) + // Swipe up to expand to next snap point + await I.dragGrabber("up", 100) + await I.waitForSnapComplete() + + const newHeight = await I.getContentVisibleHeight() + expect(newHeight).toBeGreaterThan(initialHeight + 50) + }) + + test("should not dismiss on fast swipe down from full height, should snap instead", async () => { + await I.clickTrigger() + await I.seeContent() + await I.waitForOpenState() + + // Sequential mode: 20rem → 1 (full) in one step + await I.dragGrabber("up", 200) + await I.waitForSnapComplete() + await I.waitForOpenState() + + // Swipe down — should snap to lower point, NOT dismiss + await I.dragGrabber("down", 150) + await I.waitForSnapComplete() + + // Drawer should still be visible (snapped, not dismissed) + await I.seeContent() }) }) diff --git a/e2e/models/drawer.model.ts b/e2e/models/drawer.model.ts index f6b0e4950e..6ae4ca2163 100644 --- a/e2e/models/drawer.model.ts +++ b/e2e/models/drawer.model.ts @@ -36,6 +36,10 @@ export class DrawerModel extends Model { return this.page.locator(grabber) } + private get swipeAreaEl() { + return this.page.locator(part("swipe-area")) + } + private get noDragArea() { return this.page.locator("[data-no-drag]") } @@ -64,6 +68,10 @@ export class DrawerModel extends Model { return swipe(this.page, this.noDragArea, direction, distance, duration, release) } + swipeArea(direction: "up" | "down", distance: number = 100, duration = 500, release = true) { + return swipe(this.page, this.swipeAreaEl, direction, distance, duration, release) + } + seeContent() { return expect(this.content).toBeVisible() } diff --git a/examples/next-ts/pages/drawer/action-sheet.tsx b/examples/next-ts/pages/drawer/action-sheet.tsx new file mode 100644 index 0000000000..8d83d04876 --- /dev/null +++ b/examples/next-ts/pages/drawer/action-sheet.tsx @@ -0,0 +1,60 @@ +import * as drawer from "@zag-js/drawer" +import { normalizeProps, useMachine } from "@zag-js/react" +import { useId } from "react" +import { Presence } from "../../components/presence" +import styles from "../../../shared/styles/drawer-action-sheet.module.css" + +export default function Page() { + const service = useMachine(drawer.machine, { + id: useId(), + }) + + const api = drawer.connect(service, normalizeProps) + + return ( +
+ + +
+ +
+
+ Profile Actions +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+ +
+ +
+ +
+ +
+
+
+
+ ) +} diff --git a/examples/next-ts/pages/drawer/controlled.tsx b/examples/next-ts/pages/drawer/controlled.tsx new file mode 100644 index 0000000000..b16b6b8bc9 --- /dev/null +++ b/examples/next-ts/pages/drawer/controlled.tsx @@ -0,0 +1,104 @@ +import * as drawer from "@zag-js/drawer" +import { normalizeProps, useMachine } from "@zag-js/react" +import { useId, useState } from "react" +import { Presence } from "../../components/presence" +import styles from "../../../shared/styles/drawer.module.css" + +function AlwaysOpenDrawer() { + const service = useMachine(drawer.machine, { + id: useId(), + open: true, + }) + + const api = drawer.connect(service, normalizeProps) + + return ( +
+

Always Open (no onOpenChange)

+

+ This drawer has open: true without onOpenChange. Swiping, escape, and outside click + should have no effect. +

+ +
+ +
+
+
+
Always Open
+

+ Try swiping down, pressing Escape, or clicking outside. This drawer should never close. +

+ +
+
+ ) +} + +function ControlledDrawer() { + const [open, setOpen] = useState(false) + + const service = useMachine(drawer.machine, { + id: useId(), + open, + onOpenChange({ open }) { + setOpen(open) + }, + }) + + const api = drawer.connect(service, normalizeProps) + + return ( +
+

Controlled (open + onOpenChange)

+

Standard controlled mode. Open state is managed by React.

+ + +
+ +
+
+
+
Controlled Drawer
+

+ This drawer is fully controlled. Swipe, escape, and outside click all work. +

+

+ Open: {String(open)} +

+ + +
+
+ ) +} + +export default function Page() { + const [scenario, setScenario] = useState<"always-open" | "controlled">("controlled") + + return ( +
+
+ + +
+ + {scenario === "always-open" && } + {scenario === "controlled" && } +
+ ) +} diff --git a/examples/next-ts/pages/drawer/cross-axis-scroll.tsx b/examples/next-ts/pages/drawer/cross-axis-scroll.tsx new file mode 100644 index 0000000000..19d3d9e84d --- /dev/null +++ b/examples/next-ts/pages/drawer/cross-axis-scroll.tsx @@ -0,0 +1,82 @@ +import * as drawer from "@zag-js/drawer" +import { normalizeProps, useMachine } from "@zag-js/react" +import { useId } from "react" +import { Presence } from "../../components/presence" +import { StateVisualizer } from "../../components/state-visualizer" +import { Toolbar } from "../../components/toolbar" +import styles from "../../../shared/styles/drawer.module.css" + +export default function Page() { + const service = useMachine(drawer.machine, { + id: useId(), + }) + + const api = drawer.connect(service, normalizeProps) + + return ( + <> +
+ + +
+ +
+
+
+
Cross-Axis Scroll
+

+ Try scrolling the image carousel horizontally. It should scroll without triggering the drawer drag. +

+ + {/* Horizontal scrollable content inside a vertical-swipe drawer */} +
+ {Array.from({ length: 10 }, (_, i) => ( +
+ {i + 1} +
+ ))} +
+ + {/* Vertical scrollable content */} +
+ {Array.from({ length: 50 }).map((_, index) => ( +
+ Item {index} +
+ ))} +
+ +
+
+ + + + + + ) +} diff --git a/examples/next-ts/pages/drawer/default-active-snap-point.tsx b/examples/next-ts/pages/drawer/default-active-snap-point.tsx index 57cf050c75..13bb29ef4e 100644 --- a/examples/next-ts/pages/drawer/default-active-snap-point.tsx +++ b/examples/next-ts/pages/drawer/default-active-snap-point.tsx @@ -13,8 +13,8 @@ export default function Page() { const service = useMachine(drawer.machine, { id: useId(), - snapPoints: [0.25, "250px", 1], - defaultSnapPoint: 0.25, + snapPoints: ["20rem", 1], + defaultSnapPoint: "20rem", ...controls.context, }) diff --git a/examples/next-ts/pages/drawer/mobile-nav.tsx b/examples/next-ts/pages/drawer/mobile-nav.tsx new file mode 100644 index 0000000000..13b3fc0dd9 --- /dev/null +++ b/examples/next-ts/pages/drawer/mobile-nav.tsx @@ -0,0 +1,89 @@ +import * as drawer from "@zag-js/drawer" +import { normalizeProps, useMachine } from "@zag-js/react" +import { useId } from "react" +import { Presence } from "../../components/presence" +import styles from "../../../shared/styles/drawer-mobile-nav.module.css" + +const NAV_ITEMS = [ + { href: "#", label: "Overview" }, + { href: "#", label: "Components" }, + { href: "#", label: "Utilities" }, + { href: "#", label: "Releases" }, +] + +const LONG_LIST = Array.from({ length: 50 }, (_, i) => ({ + href: "#", + label: `Item ${i + 1}`, +})) + +export default function Page() { + const service = useMachine(drawer.machine, { + id: useId(), + }) + + const api = drawer.connect(service, normalizeProps) + + return ( +
+
+ +
+ + +
+ + + +
+
+ ) +} diff --git a/examples/next-ts/pages/drawer/non-modal.tsx b/examples/next-ts/pages/drawer/non-modal.tsx new file mode 100644 index 0000000000..a710e84a32 --- /dev/null +++ b/examples/next-ts/pages/drawer/non-modal.tsx @@ -0,0 +1,57 @@ +import * as drawer from "@zag-js/drawer" +import { normalizeProps, useMachine } from "@zag-js/react" +import { useId } from "react" +import { Presence } from "../../components/presence" +import styles from "../../../shared/styles/drawer.module.css" + +export default function Page() { + const service = useMachine(drawer.machine, { + id: useId(), + modal: false, + closeOnInteractOutside: false, + swipeDirection: "right", + }) + + const api = drawer.connect(service, normalizeProps) + + return ( +
+ + +
+

+ This area stays interactive while the drawer is open. Try typing below: +

+ +
+ +
+ +
Non-modal Drawer
+

+ No backdrop, no focus trap, no scroll lock. The page behind stays fully interactive. Close with the button, + drag to dismiss, or Escape. +

+
+ +
+
+
+
+ ) +} diff --git a/examples/next-ts/pages/drawer/range-input.tsx b/examples/next-ts/pages/drawer/range-input.tsx new file mode 100644 index 0000000000..c165b0ccb7 --- /dev/null +++ b/examples/next-ts/pages/drawer/range-input.tsx @@ -0,0 +1,75 @@ +import * as drawer from "@zag-js/drawer" +import { normalizeProps, useMachine } from "@zag-js/react" +import { drawerControls } from "@zag-js/shared" +import { useId, useState } from "react" +import { Presence } from "../../components/presence" +import { StateVisualizer } from "../../components/state-visualizer" +import { Toolbar } from "../../components/toolbar" +import { useControls } from "../../hooks/use-controls" +import styles from "../../../shared/styles/drawer.module.css" + +export default function Page() { + const controls = useControls(drawerControls) + const [volume, setVolume] = useState(50) + + const service = useMachine(drawer.machine, { + id: useId(), + ...controls.context, + }) + + const api = drawer.connect(service, normalizeProps) + + return ( + <> +
+ + +
+ +
+
+
+
Drawer + native range
+

+ Drag the slider horizontally. The sheet should not move or steal the gesture while adjusting the range. +

+
+ + setVolume(Number(e.target.value))} + data-testid="drawer-native-range" + style={{ width: "100%", touchAction: "auto" }} + /> + + {volume} + +
+
+ {Array.from({ length: 40 }).map((_element, index) => ( +
+ Item {index} +
+ ))} +
+ +
+
+ + + + + + ) +} diff --git a/examples/next-ts/pages/drawer/snap-points.tsx b/examples/next-ts/pages/drawer/snap-points.tsx index 988883d0df..3e45f457f2 100644 --- a/examples/next-ts/pages/drawer/snap-points.tsx +++ b/examples/next-ts/pages/drawer/snap-points.tsx @@ -13,7 +13,7 @@ export default function Page() { const service = useMachine(drawer.machine, { id: useId(), - snapPoints: [0.25, "250px", 1], + snapPoints: ["20rem", 1], ...controls.context, }) diff --git a/examples/next-ts/pages/drawer/swipe-area.tsx b/examples/next-ts/pages/drawer/swipe-area.tsx new file mode 100644 index 0000000000..fa5d2382e8 --- /dev/null +++ b/examples/next-ts/pages/drawer/swipe-area.tsx @@ -0,0 +1,43 @@ +import * as drawer from "@zag-js/drawer" +import { normalizeProps, useMachine } from "@zag-js/react" +import { useId } from "react" +import { Presence } from "../../components/presence" +import { StateVisualizer } from "../../components/state-visualizer" +import { Toolbar } from "../../components/toolbar" +import styles from "../../../shared/styles/drawer.module.css" + +export default function Page() { + const service = useMachine(drawer.machine, { + id: useId(), + }) + + const api = drawer.connect(service, normalizeProps) + + return ( + <> +
+
+ +
+ +
+
+
+
Drawer
+

Swipe up from the bottom edge to open this drawer.

+ +
+ {Array.from({ length: 100 }).map((_element, index) => ( +
Item {index}
+ ))} +
+ +
+
+ + + + + + ) +} diff --git a/examples/nuxt-ts/app/components/DrawerControlledAlwaysOpen.vue b/examples/nuxt-ts/app/components/DrawerControlledAlwaysOpen.vue new file mode 100644 index 0000000000..69867df756 --- /dev/null +++ b/examples/nuxt-ts/app/components/DrawerControlledAlwaysOpen.vue @@ -0,0 +1,38 @@ + + + diff --git a/examples/nuxt-ts/app/components/DrawerControlledManaged.vue b/examples/nuxt-ts/app/components/DrawerControlledManaged.vue new file mode 100644 index 0000000000..a58569b322 --- /dev/null +++ b/examples/nuxt-ts/app/components/DrawerControlledManaged.vue @@ -0,0 +1,47 @@ + + + diff --git a/examples/nuxt-ts/app/composables/useControls.ts b/examples/nuxt-ts/app/composables/useControls.ts index 844e3edacf..b91954cda7 100644 --- a/examples/nuxt-ts/app/composables/useControls.ts +++ b/examples/nuxt-ts/app/composables/useControls.ts @@ -14,7 +14,7 @@ export interface UseControlsReturn { setState: (key: string, value: any) => void getState: (key: string) => any keys: (keyof T)[] - mergeProps:

(props: P) => ComputedRef & P> + mergeProps:

(props: Partial

) => ComputedRef & Partial

> } export const useControls = (config: T): UseControlsReturn => { @@ -36,7 +36,7 @@ export const useControls = (config: T): UseControlsRetu setState, getState, keys: Object.keys(config) as (keyof ControlValue)[], - mergeProps:

(props: P): ComputedRef & P> => { + mergeProps:

(props: Partial

): ComputedRef & P> => { return computed(() => ({ ...getTransformedControlValues(config, toRaw(toValue(state))), ...props, diff --git a/examples/nuxt-ts/app/pages/drawer/action-sheet.vue b/examples/nuxt-ts/app/pages/drawer/action-sheet.vue new file mode 100644 index 0000000000..2f520dc595 --- /dev/null +++ b/examples/nuxt-ts/app/pages/drawer/action-sheet.vue @@ -0,0 +1,43 @@ + + + diff --git a/examples/nuxt-ts/app/pages/drawer/basic.vue b/examples/nuxt-ts/app/pages/drawer/basic.vue index 9300dbc90d..aafdf99cd2 100644 --- a/examples/nuxt-ts/app/pages/drawer/basic.vue +++ b/examples/nuxt-ts/app/pages/drawer/basic.vue @@ -2,6 +2,7 @@ import * as drawer from "@zag-js/drawer" import { drawerControls } from "@zag-js/shared" import { normalizeProps, useMachine } from "@zag-js/vue" +import Presence from "~/components/Presence.vue" import styles from "../../../../shared/styles/drawer.module.css" const controls = useControls(drawerControls) @@ -19,9 +20,9 @@ const api = computed(() => drawer.connect(service, normalizeProps))