Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/dialog-non-modal.md
Original file line number Diff line number Diff line change
@@ -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`
18 changes: 18 additions & 0 deletions .changeset/drawer-swipe-area.md
Original file line number Diff line number Diff line change
@@ -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
<div {...api.getSwipeAreaProps()} />
```

- 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
74 changes: 74 additions & 0 deletions e2e/drawer-swipe-area.e2e.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
65 changes: 52 additions & 13 deletions e2e/drawer.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { controls } from "./_utils"
import { DrawerModel } from "./models/drawer.model"

let I: DrawerModel
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
})
})
8 changes: 8 additions & 0 deletions e2e/models/drawer.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
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]")
}
Expand Down Expand Up @@ -64,8 +68,12 @@
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()

Check failure on line 76 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:12:7 › drawer [swipe-area] › should open when swiped up from swipe area

1) e2e/drawer-swipe-area.e2e.ts:12:7 › drawer [swipe-area] › should open when swiped up from swipe area Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:76 74 | 75 | seeContent() { > 76 | return expect(this.content).toBeVisible() | ^ 77 | } 78 | 79 | dontSeeContent() { at DrawerModel.seeContent (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:76:33) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:14:13

Check failure on line 76 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:12:7 › drawer [swipe-area] › should open when swiped up from swipe area

1) e2e/drawer-swipe-area.e2e.ts:12:7 › drawer [swipe-area] › should open when swiped up from swipe area Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:76 74 | 75 | seeContent() { > 76 | return expect(this.content).toBeVisible() | ^ 77 | } 78 | 79 | dontSeeContent() { at DrawerModel.seeContent (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:76:33) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:14:13

Check failure on line 76 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:12:7 › drawer [swipe-area] › should open when swiped up from swipe area

1) e2e/drawer-swipe-area.e2e.ts:12:7 › drawer [swipe-area] › should open when swiped up from swipe area Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:76 74 | 75 | seeContent() { > 76 | return expect(this.content).toBeVisible() | ^ 77 | } 78 | 79 | dontSeeContent() { at DrawerModel.seeContent (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:76:33) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:14:13

Check failure on line 76 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:12:7 › drawer [swipe-area] › should open when swiped up from swipe area

1) e2e/drawer-swipe-area.e2e.ts:12:7 › drawer [swipe-area] › should open when swiped up from swipe area Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:76 74 | 75 | seeContent() { > 76 | return expect(this.content).toBeVisible() | ^ 77 | } 78 | 79 | dontSeeContent() { at DrawerModel.seeContent (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:76:33) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:14:13

Check failure on line 76 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:12:7 › drawer [swipe-area] › should open when swiped up from swipe area

1) e2e/drawer-swipe-area.e2e.ts:12:7 › drawer [swipe-area] › should open when swiped up from swipe area Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:76 74 | 75 | seeContent() { > 76 | return expect(this.content).toBeVisible() | ^ 77 | } 78 | 79 | dontSeeContent() { at DrawerModel.seeContent (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:76:33) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:14:13

Check failure on line 76 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:12:7 › drawer [swipe-area] › should open when swiped up from swipe area

1) e2e/drawer-swipe-area.e2e.ts:12:7 › drawer [swipe-area] › should open when swiped up from swipe area Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:76 74 | 75 | seeContent() { > 76 | return expect(this.content).toBeVisible() | ^ 77 | } 78 | 79 | dontSeeContent() { at DrawerModel.seeContent (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:76:33) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:14:13
}

dontSeeContent() {
Expand Down Expand Up @@ -124,7 +132,7 @@

async waitForOpenState() {
// Wait for element to be visible and animations to complete
await expect(this.content).toBeVisible()

Check failure on line 135 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:55:7 › drawer [swipe-area] › should close when swiped down after swipe open

4) e2e/drawer-swipe-area.e2e.ts:55:7 › drawer [swipe-area] › should close when swiped down after swipe open Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:135 133 | async waitForOpenState() { 134 | // Wait for element to be visible and animations to complete > 135 | await expect(this.content).toBeVisible() | ^ 136 | await this.content.evaluate((el) => Promise.allSettled(el.getAnimations().map((animation) => animation.finished))) 137 | } 138 | at DrawerModel.waitForOpenState (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:135:32) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:57:13

Check failure on line 135 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:46:7 › drawer [swipe-area] › should close on escape after swipe open

3) e2e/drawer-swipe-area.e2e.ts:46:7 › drawer [swipe-area] › should close on escape after swipe open Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:135 133 | async waitForOpenState() { 134 | // Wait for element to be visible and animations to complete > 135 | await expect(this.content).toBeVisible() | ^ 136 | await this.content.evaluate((el) => Promise.allSettled(el.getAnimations().map((animation) => animation.finished))) 137 | } 138 | at DrawerModel.waitForOpenState (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:135:32) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:48:13

Check failure on line 135 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:46:7 › drawer [swipe-area] › should close on escape after swipe open

3) e2e/drawer-swipe-area.e2e.ts:46:7 › drawer [swipe-area] › should close on escape after swipe open Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:135 133 | async waitForOpenState() { 134 | // Wait for element to be visible and animations to complete > 135 | await expect(this.content).toBeVisible() | ^ 136 | await this.content.evaluate((el) => Promise.allSettled(el.getAnimations().map((animation) => animation.finished))) 137 | } 138 | at DrawerModel.waitForOpenState (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:135:32) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:48:13

Check failure on line 135 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:46:7 › drawer [swipe-area] › should close on escape after swipe open

3) e2e/drawer-swipe-area.e2e.ts:46:7 › drawer [swipe-area] › should close on escape after swipe open Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:135 133 | async waitForOpenState() { 134 | // Wait for element to be visible and animations to complete > 135 | await expect(this.content).toBeVisible() | ^ 136 | await this.content.evaluate((el) => Promise.allSettled(el.getAnimations().map((animation) => animation.finished))) 137 | } 138 | at DrawerModel.waitForOpenState (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:135:32) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:48:13

Check failure on line 135 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:36:7 › drawer [swipe-area] › should close when clicked outside after swipe open

2) e2e/drawer-swipe-area.e2e.ts:36:7 › drawer [swipe-area] › should close when clicked outside after swipe open Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:135 133 | async waitForOpenState() { 134 | // Wait for element to be visible and animations to complete > 135 | await expect(this.content).toBeVisible() | ^ 136 | await this.content.evaluate((el) => Promise.allSettled(el.getAnimations().map((animation) => animation.finished))) 137 | } 138 | at DrawerModel.waitForOpenState (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:135:32) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:38:13

Check failure on line 135 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:36:7 › drawer [swipe-area] › should close when clicked outside after swipe open

2) e2e/drawer-swipe-area.e2e.ts:36:7 › drawer [swipe-area] › should close when clicked outside after swipe open Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:135 133 | async waitForOpenState() { 134 | // Wait for element to be visible and animations to complete > 135 | await expect(this.content).toBeVisible() | ^ 136 | await this.content.evaluate((el) => Promise.allSettled(el.getAnimations().map((animation) => animation.finished))) 137 | } 138 | at DrawerModel.waitForOpenState (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:135:32) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:38:13

Check failure on line 135 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:36:7 › drawer [swipe-area] › should close when clicked outside after swipe open

2) e2e/drawer-swipe-area.e2e.ts:36:7 › drawer [swipe-area] › should close when clicked outside after swipe open Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:135 133 | async waitForOpenState() { 134 | // Wait for element to be visible and animations to complete > 135 | await expect(this.content).toBeVisible() | ^ 136 | await this.content.evaluate((el) => Promise.allSettled(el.getAnimations().map((animation) => animation.finished))) 137 | } 138 | at DrawerModel.waitForOpenState (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:135:32) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:38:13

Check failure on line 135 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:55:7 › drawer [swipe-area] › should close when swiped down after swipe open

4) e2e/drawer-swipe-area.e2e.ts:55:7 › drawer [swipe-area] › should close when swiped down after swipe open Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:135 133 | async waitForOpenState() { 134 | // Wait for element to be visible and animations to complete > 135 | await expect(this.content).toBeVisible() | ^ 136 | await this.content.evaluate((el) => Promise.allSettled(el.getAnimations().map((animation) => animation.finished))) 137 | } 138 | at DrawerModel.waitForOpenState (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:135:32) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:57:13

Check failure on line 135 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:46:7 › drawer [swipe-area] › should close on escape after swipe open

3) e2e/drawer-swipe-area.e2e.ts:46:7 › drawer [swipe-area] › should close on escape after swipe open Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:135 133 | async waitForOpenState() { 134 | // Wait for element to be visible and animations to complete > 135 | await expect(this.content).toBeVisible() | ^ 136 | await this.content.evaluate((el) => Promise.allSettled(el.getAnimations().map((animation) => animation.finished))) 137 | } 138 | at DrawerModel.waitForOpenState (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:135:32) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:48:13

Check failure on line 135 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:46:7 › drawer [swipe-area] › should close on escape after swipe open

3) e2e/drawer-swipe-area.e2e.ts:46:7 › drawer [swipe-area] › should close on escape after swipe open Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:135 133 | async waitForOpenState() { 134 | // Wait for element to be visible and animations to complete > 135 | await expect(this.content).toBeVisible() | ^ 136 | await this.content.evaluate((el) => Promise.allSettled(el.getAnimations().map((animation) => animation.finished))) 137 | } 138 | at DrawerModel.waitForOpenState (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:135:32) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:48:13

Check failure on line 135 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:46:7 › drawer [swipe-area] › should close on escape after swipe open

3) e2e/drawer-swipe-area.e2e.ts:46:7 › drawer [swipe-area] › should close on escape after swipe open Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:135 133 | async waitForOpenState() { 134 | // Wait for element to be visible and animations to complete > 135 | await expect(this.content).toBeVisible() | ^ 136 | await this.content.evaluate((el) => Promise.allSettled(el.getAnimations().map((animation) => animation.finished))) 137 | } 138 | at DrawerModel.waitForOpenState (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:135:32) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:48:13

Check failure on line 135 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:36:7 › drawer [swipe-area] › should close when clicked outside after swipe open

2) e2e/drawer-swipe-area.e2e.ts:36:7 › drawer [swipe-area] › should close when clicked outside after swipe open Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:135 133 | async waitForOpenState() { 134 | // Wait for element to be visible and animations to complete > 135 | await expect(this.content).toBeVisible() | ^ 136 | await this.content.evaluate((el) => Promise.allSettled(el.getAnimations().map((animation) => animation.finished))) 137 | } 138 | at DrawerModel.waitForOpenState (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:135:32) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:38:13

Check failure on line 135 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:36:7 › drawer [swipe-area] › should close when clicked outside after swipe open

2) e2e/drawer-swipe-area.e2e.ts:36:7 › drawer [swipe-area] › should close when clicked outside after swipe open Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:135 133 | async waitForOpenState() { 134 | // Wait for element to be visible and animations to complete > 135 | await expect(this.content).toBeVisible() | ^ 136 | await this.content.evaluate((el) => Promise.allSettled(el.getAnimations().map((animation) => animation.finished))) 137 | } 138 | at DrawerModel.waitForOpenState (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:135:32) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:38:13

Check failure on line 135 in e2e/models/drawer.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (5)

e2e/drawer-swipe-area.e2e.ts:36:7 › drawer [swipe-area] › should close when clicked outside after swipe open

2) e2e/drawer-swipe-area.e2e.ts:36:7 › drawer [swipe-area] › should close when clicked outside after swipe open Error: expect(locator).toBeVisible() failed Locator: locator('[data-part=content]') Expected: visible Received: hidden Timeout: 10000ms Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('[data-part=content]') 14 × locator resolved to <div tabindex="-1" role="dialog" aria-modal="true" data-scope="drawer" data-state="closed" data-part="content" id="drawer:_R_1m_:content" data-swipe-direction="down" aria-labelledby="drawer:_R_1m_:title" class="drawer-module__8-jXLq__content" aria-describedby="drawer:_R_1m_:description">…</div> - unexpected value "hidden" at models/drawer.model.ts:135 133 | async waitForOpenState() { 134 | // Wait for element to be visible and animations to complete > 135 | await expect(this.content).toBeVisible() | ^ 136 | await this.content.evaluate((el) => Promise.allSettled(el.getAnimations().map((animation) => animation.finished))) 137 | } 138 | at DrawerModel.waitForOpenState (/home/runner/work/lib-zag/lib-zag/e2e/models/drawer.model.ts:135:32) at /home/runner/work/lib-zag/lib-zag/e2e/drawer-swipe-area.e2e.ts:38:13
await this.content.evaluate((el) => Promise.allSettled(el.getAnimations().map((animation) => animation.finished)))
}

Expand Down
60 changes: 60 additions & 0 deletions examples/next-ts/pages/drawer/action-sheet.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
<button className={styles.trigger} {...api.getTriggerProps()}>
Manage Profile
</button>
<Presence className={styles.backdrop} {...api.getBackdropProps()} />
<div className={styles.positioner} {...api.getPositionerProps()}>
<Presence className={styles.popup} {...api.getContentProps({ draggable: false })}>
<div className={styles.surface}>
<div {...api.getTitleProps()} className={styles.title}>
Profile Actions
</div>
<ul className={styles.actions}>
<li className={styles.action}>
<button className={styles.actionButton} onClick={() => api.setOpen(false)}>
Edit Profile
</button>
</li>
<li className={styles.action}>
<button className={styles.actionButton} onClick={() => api.setOpen(false)}>
Change Avatar
</button>
</li>
<li className={styles.action}>
<button className={styles.actionButton} onClick={() => api.setOpen(false)}>
Privacy Settings
</button>
</li>
</ul>
</div>

<div className={styles.dangerSurface}>
<button className={styles.dangerButton} onClick={() => api.setOpen(false)}>
Delete Account
</button>
</div>

<div className={styles.surface}>
<button className={styles.actionButton} {...api.getCloseTriggerProps()}>
Cancel
</button>
</div>
</Presence>
</div>
</main>
)
}
104 changes: 104 additions & 0 deletions examples/next-ts/pages/drawer/controlled.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h3>Always Open (no onOpenChange)</h3>
<p style={{ fontSize: 14, color: "#6b7280" }}>
This drawer has <code>open: true</code> without <code>onOpenChange</code>. Swiping, escape, and outside click
should have no effect.
</p>
<Presence className={styles.backdrop} {...api.getBackdropProps()} />
<div className={styles.positioner} {...api.getPositionerProps()}>
<Presence className={styles.content} {...api.getContentProps()}>
<div className={styles.grabber} {...api.getGrabberProps()}>
<div className={styles.grabberIndicator} {...api.getGrabberIndicatorProps()} />
</div>
<div {...api.getTitleProps()}>Always Open</div>
<p {...api.getDescriptionProps()}>
Try swiping down, pressing Escape, or clicking outside. This drawer should never close.
</p>
</Presence>
</div>
</div>
)
}

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 (
<div>
<h3>Controlled (open + onOpenChange)</h3>
<p style={{ fontSize: 14, color: "#6b7280" }}>Standard controlled mode. Open state is managed by React.</p>
<button className={styles.trigger} {...api.getTriggerProps()}>
Open Controlled
</button>
<Presence className={styles.backdrop} {...api.getBackdropProps()} />
<div className={styles.positioner} {...api.getPositionerProps()}>
<Presence className={styles.content} {...api.getContentProps()}>
<div className={styles.grabber} {...api.getGrabberProps()}>
<div className={styles.grabberIndicator} {...api.getGrabberIndicatorProps()} />
</div>
<div {...api.getTitleProps()}>Controlled Drawer</div>
<p {...api.getDescriptionProps()}>
This drawer is fully controlled. Swipe, escape, and outside click all work.
</p>
<p style={{ fontSize: 14 }}>
Open: <strong>{String(open)}</strong>
</p>
<button {...api.getCloseTriggerProps()}>Close</button>
</Presence>
</div>
</div>
)
}

export default function Page() {
const [scenario, setScenario] = useState<"always-open" | "controlled">("controlled")

return (
<main style={{ padding: 20 }}>
<div style={{ display: "flex", gap: 8, marginBottom: 20 }}>
<button
className={styles.trigger}
onClick={() => setScenario("always-open")}
style={{ fontWeight: scenario === "always-open" ? 700 : 400 }}
>
Always Open
</button>
<button
className={styles.trigger}
onClick={() => setScenario("controlled")}
style={{ fontWeight: scenario === "controlled" ? 700 : 400 }}
>
Controlled
</button>
</div>

{scenario === "always-open" && <AlwaysOpenDrawer />}
{scenario === "controlled" && <ControlledDrawer />}
</main>
)
}
Loading
Loading