From c906c099997be95d15396fcc5bb5583e9431c2bf Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Fri, 20 Mar 2026 05:04:33 +0200 Subject: [PATCH 1/7] refactor: drawer machine --- .changeset/drawer-swipe-area.md | 15 + e2e/drawer-swipe-area.e2e.ts | 74 +++++ e2e/models/drawer.model.ts | 8 + .../next-ts/pages/drawer/action-sheet.tsx | 60 ++++ examples/next-ts/pages/drawer/controlled.tsx | 104 +++++++ .../pages/drawer/cross-axis-scroll.tsx | 82 +++++ examples/next-ts/pages/drawer/mobile-nav.tsx | 89 ++++++ examples/next-ts/pages/drawer/swipe-area.tsx | 43 +++ .../styles/drawer-action-sheet.module.css | 200 +++++++++++++ .../styles/drawer-mobile-nav.module.css | 279 ++++++++++++++++++ examples/shared/styles/drawer.module.css | 11 + .../machines/drawer/src/drawer.anatomy.ts | 2 + .../machines/drawer/src/drawer.connect.ts | 76 ++++- packages/machines/drawer/src/drawer.dom.ts | 4 + .../machines/drawer/src/drawer.machine.ts | 178 ++++++++++- packages/machines/drawer/src/drawer.types.ts | 20 +- packages/machines/drawer/src/index.ts | 1 + .../machines/drawer/src/utils/drag-manager.ts | 63 ++++ shared/src/routes.ts | 5 + 19 files changed, 1299 insertions(+), 15 deletions(-) create mode 100644 .changeset/drawer-swipe-area.md create mode 100644 e2e/drawer-swipe-area.e2e.ts create mode 100644 examples/next-ts/pages/drawer/action-sheet.tsx create mode 100644 examples/next-ts/pages/drawer/controlled.tsx create mode 100644 examples/next-ts/pages/drawer/cross-axis-scroll.tsx create mode 100644 examples/next-ts/pages/drawer/mobile-nav.tsx create mode 100644 examples/next-ts/pages/drawer/swipe-area.tsx create mode 100644 examples/shared/styles/drawer-action-sheet.module.css create mode 100644 examples/shared/styles/drawer-mobile-nav.module.css diff --git a/.changeset/drawer-swipe-area.md b/.changeset/drawer-swipe-area.md new file mode 100644 index 0000000000..cf94aa8bc0 --- /dev/null +++ b/.changeset/drawer-swipe-area.md @@ -0,0 +1,15 @@ +--- +"@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 + // Invisible zone at the bottom of the screen for swipe-to-open +
+ ``` + +- 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 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/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/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/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/shared/styles/drawer-action-sheet.module.css b/examples/shared/styles/drawer-action-sheet.module.css new file mode 100644 index 0000000000..e79b2ff924 --- /dev/null +++ b/examples/shared/styles/drawer-action-sheet.module.css @@ -0,0 +1,200 @@ +.trigger { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + height: 2.5rem; + padding: 0 0.875rem; + margin: 0; + outline: 0; + border: 1px solid #e5e7eb; + border-radius: 0.375rem; + background-color: #f9fafb; + font-family: inherit; + font-size: 1rem; + font-weight: 500; + line-height: 1.5rem; + color: #111827; + user-select: none; + cursor: pointer; + + @media (hover: hover) { + &:hover { + background-color: #f3f4f6; + } + } + + &:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: -1px; + } +} + +.backdrop { + --backdrop-opacity: 0.4; + position: fixed; + min-height: 100dvh; + inset: 0; + background-color: black; + opacity: calc(var(--backdrop-opacity) * (1 - var(--drawer-swipe-progress, 0))); + transition-duration: 450ms; + transition-property: opacity; + transition-timing-function: cubic-bezier(0.32, 0.72, 0, 1); + + &[data-state="open"] { + animation: fadeIn 450ms cubic-bezier(0.32, 0.72, 0, 1); + } + + &[data-state="closed"] { + animation: fadeOut 450ms cubic-bezier(0.32, 0.72, 0, 1); + opacity: 0; + } + + &[data-swiping] { + animation: none; + transition-duration: 0ms; + } +} + +.positioner { + position: fixed; + inset: 0; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.popup { + box-sizing: border-box; + width: 100%; + max-width: 28rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0 1rem calc(1rem + env(safe-area-inset-bottom, 0px)); + outline: 0; + transform: translate3d(var(--drawer-translate-x, 0px), var(--drawer-translate-y, 0px), 0); + transition: transform 450ms cubic-bezier(0.32, 0.72, 0, 1); + will-change: transform; + + &[data-state="open"] { + animation: slideUp 450ms cubic-bezier(0.32, 0.72, 0, 1); + } + + &[data-state="closed"] { + animation: slideDown 450ms cubic-bezier(0.32, 0.72, 0, 1); + } + + &[data-swiping] { + user-select: none; + transition-duration: 0ms; + } +} + +.surface { + pointer-events: auto; + border-radius: 1rem; + outline: 1px solid #e5e7eb; + background-color: #f9fafb; + color: #111827; + overflow: hidden; +} + +.actions { + list-style: none; + margin: 0; + padding: 0; +} + +.action:not(:first-child) { + border-top: 1px solid #e5e7eb; +} + +.actionButton { + box-sizing: border-box; + width: 100%; + margin: 0; + padding: 1rem 1.25rem; + border: 0; + background-color: transparent; + font-family: inherit; + font-size: 1rem; + line-height: 1.5rem; + text-align: center; + color: inherit; + user-select: none; + cursor: pointer; + + @media (hover: hover) { + &:hover { + background-color: #f3f4f6; + } + } + + &:focus-visible { + outline: 0; + background-color: #f3f4f6; + } +} + +.dangerSurface { + pointer-events: auto; + border-radius: 1rem; + outline: 1px solid #e5e7eb; + background-color: #f9fafb; + overflow: hidden; +} + +.dangerButton { + box-sizing: border-box; + width: 100%; + margin: 0; + padding: 1rem 1.25rem; + border: 0; + background-color: transparent; + font-family: inherit; + font-size: 1rem; + line-height: 1.5rem; + text-align: center; + color: #dc2626; + user-select: none; + cursor: pointer; + + @media (hover: hover) { + &:hover { + background-color: #fef2f2; + } + } + + &:focus-visible { + outline: 0; + background-color: #fef2f2; + } +} + +.title { + font-size: 0.8125rem; + line-height: 1.25rem; + text-align: center; + color: #6b7280; + padding: 0.75rem 1.25rem 0; +} + +@keyframes fadeIn { + from { opacity: 0; } +} + +@keyframes fadeOut { + from { opacity: calc(var(--backdrop-opacity) * (1 - var(--drawer-swipe-progress, 0))); } + to { opacity: 0; } +} + +@keyframes slideUp { + from { transform: translateY(calc(100% + 1rem + 2px)); } + to { transform: translate3d(var(--drawer-translate-x, 0px), var(--drawer-translate-y, 0px), 0); } +} + +@keyframes slideDown { + from { transform: translate3d(var(--drawer-translate-x, 0px), var(--drawer-translate-y, 0px), 0); } + to { transform: translateY(calc(100% + 1rem + 2px)); } +} diff --git a/examples/shared/styles/drawer-mobile-nav.module.css b/examples/shared/styles/drawer-mobile-nav.module.css new file mode 100644 index 0000000000..def0e64765 --- /dev/null +++ b/examples/shared/styles/drawer-mobile-nav.module.css @@ -0,0 +1,279 @@ +.page { + min-height: 100vh; +} + +.header { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; +} + +.menuButton { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + height: 2.5rem; + padding: 0 0.875rem; + margin: 0; + outline: 0; + border: 1px solid #e5e7eb; + border-radius: 0.375rem; + background-color: #f9fafb; + font-family: inherit; + font-size: 1rem; + font-weight: 500; + line-height: 1.5rem; + color: #111827; + user-select: none; + cursor: pointer; + + @media (hover: hover) { + &:hover { + background-color: #f3f4f6; + } + } + + &:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: -1px; + } +} + +.logo { + font-size: 1.125rem; + font-weight: 700; + color: #111827; +} + +.content { + padding: 24px 16px; + color: #6b7280; +} + +.backdrop { + --backdrop-opacity: 1; + position: fixed; + inset: 0; + min-height: 100dvh; + transition-duration: 600ms; + transition-property: backdrop-filter, opacity; + transition-timing-function: cubic-bezier(0.32, 0.72, 0, 1); + backdrop-filter: blur(1.5px); + background-image: linear-gradient(to bottom, rgb(0 0 0 / 5%) 0, rgb(0 0 0 / 10%) 50%); + opacity: calc(var(--backdrop-opacity) * (1 - var(--drawer-swipe-progress, 0))); + + &[data-state="open"] { + animation: backdropIn 600ms cubic-bezier(0.32, 0.72, 0, 1); + } + + &[data-state="closed"] { + animation: backdropOut 350ms cubic-bezier(0.375, 0.015, 0.545, 0.455); + backdrop-filter: blur(0); + opacity: 0; + } + + &[data-swiping] { + animation: none; + transition-duration: 0ms; + } +} + +.positioner { + position: fixed; + inset: 0; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.popup { + box-sizing: border-box; + width: 100%; + max-width: 42rem; + margin: 0 auto; + outline: 0; + transform: translateY(var(--drawer-translate-y, 0px)); + transition: transform 600ms cubic-bezier(0.45, 1.005, 0, 1.005); + will-change: transform; + + &[data-state="open"] { + animation: slideUp 600ms cubic-bezier(0.45, 1.005, 0, 1.005); + } + + &[data-state="closed"] { + animation: slideDown 350ms cubic-bezier(0.375, 0.015, 0.545, 0.455); + } + + &[data-swiping] { + user-select: none; + transition-duration: 0ms; + } +} + +.panel { + position: relative; + display: flex; + flex-direction: column; + padding: 1rem 1.5rem 1.5rem; + border-top-left-radius: 1rem; + border-top-right-radius: 1rem; + outline: 1px solid #e5e7eb; + background-color: #f9fafb; + box-shadow: + 0 10px 64px -10px rgb(36 40 52 / 20%), + 0 0.25px 0 1px #e5e7eb; + color: #111827; +} + +.panelHeader { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + margin-bottom: 0.75rem; +} + +.headerSpacer { + width: 2.25rem; + height: 2.25rem; +} + +.handle { + display: flex; + align-items: center; + justify-content: center; + justify-self: center; + padding: 12px 0; + cursor: grab; + touch-action: none; +} + +.handleIndicator { + width: 3rem; + height: 0.25rem; + border-radius: 9999px; + background-color: #d1d5db; +} + +.closeButton { + width: 2.25rem; + height: 2.25rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 9999px; + border: 1px solid #e5e7eb; + background-color: #f9fafb; + color: #111827; + cursor: pointer; + justify-self: end; + + @media (hover: hover) { + &:hover { + background-color: #f3f4f6; + } + } + + &:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: -1px; + } +} + +.title { + margin: 0 0 0.25rem; + font-size: 1.125rem; + line-height: 1.75rem; + letter-spacing: -0.0025em; + font-weight: 500; +} + +.description { + margin: 0 0 1.25rem; + font-size: 1rem; + line-height: 1.5rem; + color: #6b7280; +} + +.navList { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 0.25rem; +} + +.longList { + list-style: none; + padding: 0; + margin: 1.5rem 0 0; + display: grid; + gap: 0.25rem; +} + +.navItem { + display: flex; +} + +.navLink { + width: 100%; + padding: 0.75rem 1rem; + border-radius: 0.75rem; + color: inherit; + text-decoration: none; + background-color: #f3f4f6; + + &:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: -1px; + } + + @media (hover: hover) { + &:hover { + background-color: #e5e7eb; + } + } +} + +.scrollArea { + padding-bottom: 2rem; + max-height: 70vh; + overflow-y: auto; +} + +@keyframes backdropIn { + from { + backdrop-filter: blur(0); + opacity: 0; + } +} + +@keyframes backdropOut { + from { + backdrop-filter: blur(1.5px); + opacity: calc(var(--backdrop-opacity) * (1 - var(--drawer-swipe-progress, 0))); + } + to { + backdrop-filter: blur(0); + opacity: 0; + } +} + +@keyframes slideUp { + from { + transform: translateY(100dvh); + } + to { + transform: translateY(var(--drawer-translate-y, 0px)); + } +} + +@keyframes slideDown { + from { + transform: translateY(var(--drawer-translate-y, 0px)); + } + to { + transform: translateY(calc(max(100dvh, 100%) + 2px)); + } +} diff --git a/examples/shared/styles/drawer.module.css b/examples/shared/styles/drawer.module.css index e14f3572df..5b0e210a06 100644 --- a/examples/shared/styles/drawer.module.css +++ b/examples/shared/styles/drawer.module.css @@ -147,6 +147,17 @@ cursor: pointer; } +.swipeArea { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 48px; + z-index: 10; + background: rgba(0, 120, 255, 0.3); + border-top: 2px solid rgba(0, 120, 255, 0.6); +} + .scrollable { overflow-y: auto; } diff --git a/packages/machines/drawer/src/drawer.anatomy.ts b/packages/machines/drawer/src/drawer.anatomy.ts index cafe0a229b..5587013856 100644 --- a/packages/machines/drawer/src/drawer.anatomy.ts +++ b/packages/machines/drawer/src/drawer.anatomy.ts @@ -4,11 +4,13 @@ export const anatomy = createAnatomy("drawer").parts( "positioner", "content", "title", + "description", "trigger", "backdrop", "grabber", "grabberIndicator", "closeTrigger", + "swipeArea", ) export const parts = anatomy.build() diff --git a/packages/machines/drawer/src/drawer.connect.ts b/packages/machines/drawer/src/drawer.connect.ts index 4f9fa463ce..a77975e292 100644 --- a/packages/machines/drawer/src/drawer.connect.ts +++ b/packages/machines/drawer/src/drawer.connect.ts @@ -9,11 +9,19 @@ const isVerticalDirection = (direction: DrawerApi["swipeDirection"]) => directio const isNegativeDirection = (direction: DrawerApi["swipeDirection"]) => direction === "up" || direction === "left" +const oppositeDirection: Record = { + up: "down", + down: "up", + left: "right", + right: "left", +} + export function connect(service: DrawerService, normalize: NormalizeProps): DrawerApi { const { state, send, context, scope, prop } = service const open = state.hasTag("open") const closed = state.matches("closed") + const swipingOpen = state.matches("swiping-open") const dragOffset = context.get("dragOffset") const dragging = dragOffset !== null @@ -23,13 +31,19 @@ export function connect(service: DrawerService, normalize: const contentSize = context.get("contentSize") const swipeStrength = context.get("swipeStrength") const snapPointOffset = resolvedActiveSnapPoint?.offset ?? 0 - const currentOffset = dragOffset ?? snapPointOffset - const swipeMovement = dragging ? currentOffset - snapPointOffset : 0 + const swipeOpenFallbackOffset = swipingOpen && dragOffset === null ? (contentSize ?? 9999) : 0 + const currentOffset = dragOffset ?? (snapPointOffset || swipeOpenFallbackOffset) + const swipeMovement = dragging || swipingOpen ? currentOffset - snapPointOffset : 0 const signedCurrentOffset = isNegativeDirection(swipeDirection) ? -currentOffset : currentOffset const signedSnapPointOffset = isNegativeDirection(swipeDirection) ? -snapPointOffset : snapPointOffset const signedMovement = isNegativeDirection(swipeDirection) ? -swipeMovement : swipeMovement + const isActivelySwiping = dragging || swipingOpen const swipeProgress = - dragging && contentSize && contentSize > 0 ? Math.max(0, Math.min(1, Math.abs(signedMovement) / contentSize)) : 0 + isActivelySwiping && contentSize && contentSize > 0 + ? Math.max(0, Math.min(1, Math.abs(signedMovement) / contentSize)) + : swipingOpen + ? 1 // fully closed (transparent backdrop) until contentSize is measured + : 0 const translateX = isVerticalDirection(swipeDirection) ? 0 : signedCurrentOffset const translateY = isVerticalDirection(swipeDirection) ? signedCurrentOffset : 0 @@ -91,6 +105,7 @@ export function connect(service: DrawerService, normalize: getContentProps(props = { draggable: true }) { const movementX = isVerticalDirection(swipeDirection) ? 0 : signedMovement const movementY = isVerticalDirection(swipeDirection) ? signedMovement : 0 + const rendered = context.get("rendered") return normalize.element({ ...parts.content.attrs, @@ -99,16 +114,18 @@ export function connect(service: DrawerService, normalize: tabIndex: -1, role: prop("role"), "aria-modal": prop("modal"), - "aria-labelledby": dom.getTitleId(scope), + "aria-labelledby": rendered.title ? dom.getTitleId(scope) : undefined, + "aria-describedby": rendered.description ? dom.getDescriptionId(scope) : undefined, hidden: !open, "data-state": open ? "open" : "closed", "data-expanded": resolvedActiveSnapPoint?.offset === 0 ? "" : undefined, "data-swipe-direction": swipeDirection, - "data-swiping": dragging ? "" : undefined, + "data-swiping": dragging || swipingOpen ? "" : undefined, "data-dragging": dragging ? "" : undefined, style: { + visibility: swipingOpen && dragOffset === null ? "hidden" : undefined, transform: "translate3d(var(--drawer-translate-x, 0px), var(--drawer-translate-y, 0px), 0)", - transitionDuration: dragging ? "0s" : undefined, + transitionDuration: dragging || swipingOpen ? "0s" : undefined, "--drawer-translate": toPx(translateY), "--drawer-translate-x": toPx(translateX), "--drawer-translate-y": toPx(translateY), @@ -134,6 +151,14 @@ export function connect(service: DrawerService, normalize: }) }, + getDescriptionProps() { + return normalize.element({ + ...parts.description.attrs, + id: dom.getDescriptionId(scope), + dir: prop("dir"), + }) + }, + getTriggerProps() { return normalize.button({ ...parts.trigger.attrs, @@ -149,9 +174,9 @@ export function connect(service: DrawerService, normalize: return normalize.element({ ...parts.backdrop.attrs, id: dom.getBackdropId(scope), - hidden: !open, + hidden: !open || (swipingOpen && dragOffset === null), "data-state": open ? "open" : "closed", - "data-swiping": dragging ? "" : undefined, + "data-swiping": dragging || swipingOpen ? "" : undefined, style: { willChange: "opacity", "--drawer-swipe-progress": `${swipeProgress}`, @@ -189,5 +214,40 @@ export function connect(service: DrawerService, normalize: }, }) }, + + getSwipeAreaProps(props = {}) { + const disabled = props.disabled ?? false + const openDirection = props.swipeDirection ?? oppositeDirection[swipeDirection] + + return normalize.element({ + ...parts.swipeArea.attrs, + id: dom.getSwipeAreaId(scope), + role: "presentation", + "aria-hidden": true, + "data-state": open ? "open" : "closed", + "data-swiping": swipingOpen ? "" : undefined, + "data-swipe-direction": openDirection, + "data-disabled": disabled ? "" : undefined, + style: { + touchAction: isVerticalDirection(openDirection) ? "pan-x" : "pan-y", + pointerEvents: disabled || (open && !swipingOpen) ? "none" : undefined, + }, + onPointerDown(event) { + if (disabled) return + if (!isLeftClick(event)) return + if (event.pointerType === "touch") return + if (open && !swipingOpen) return + send({ type: "SWIPE_AREA.START", point: getEventPoint(event) }) + if (event.cancelable) event.preventDefault() + }, + onTouchStart(event) { + if (disabled) return + if (open && !swipingOpen) return + const touch = event.touches[0] + if (!touch) return + send({ type: "SWIPE_AREA.START", point: { x: touch.clientX, y: touch.clientY } }) + }, + }) + }, } } diff --git a/packages/machines/drawer/src/drawer.dom.ts b/packages/machines/drawer/src/drawer.dom.ts index 952c3055c8..857816013b 100644 --- a/packages/machines/drawer/src/drawer.dom.ts +++ b/packages/machines/drawer/src/drawer.dom.ts @@ -4,15 +4,19 @@ import { queryAll } from "@zag-js/dom-query" export const getContentId = (ctx: Scope) => ctx.ids?.content ?? `drawer:${ctx.id}:content` export const getPositionerId = (ctx: Scope) => ctx.ids?.positioner ?? `drawer:${ctx.id}:positioner` export const getTitleId = (ctx: Scope) => ctx.ids?.title ?? `drawer:${ctx.id}:title` +export const getDescriptionId = (ctx: Scope) => ctx.ids?.description ?? `drawer:${ctx.id}:description` export const getTriggerId = (ctx: Scope) => ctx.ids?.trigger ?? `drawer:${ctx.id}:trigger` export const getBackdropId = (ctx: Scope) => ctx.ids?.backdrop ?? `drawer:${ctx.id}:backdrop` export const getHeaderId = (ctx: Scope) => ctx.ids?.header ?? `drawer:${ctx.id}:header` export const getGrabberId = (ctx: Scope) => ctx.ids?.grabber ?? `drawer:${ctx.id}:grabber` export const getGrabberIndicatorId = (ctx: Scope) => ctx.ids?.grabberIndicator ?? `drawer:${ctx.id}:grabber-indicator` export const getCloseTriggerId = (ctx: Scope) => ctx.ids?.closeTrigger ?? `drawer:${ctx.id}:close-trigger` +export const getSwipeAreaId = (ctx: Scope) => ctx.ids?.swipeArea ?? `drawer:${ctx.id}:swipe-area` export const getContentEl = (ctx: Scope) => ctx.getById(getContentId(ctx)) export const getPositionerEl = (ctx: Scope) => ctx.getById(getPositionerId(ctx)) +export const getTitleEl = (ctx: Scope) => ctx.getById(getTitleId(ctx)) +export const getDescriptionEl = (ctx: Scope) => ctx.getById(getDescriptionId(ctx)) export const getTriggerEl = (ctx: Scope) => ctx.getById(getTriggerId(ctx)) export const getBackdropEl = (ctx: Scope) => ctx.getById(getBackdropId(ctx)) export const getHeaderEl = (ctx: Scope) => ctx.getById(getHeaderId(ctx)) diff --git a/packages/machines/drawer/src/drawer.machine.ts b/packages/machines/drawer/src/drawer.machine.ts index a1ef9c3c1a..ef21b21317 100644 --- a/packages/machines/drawer/src/drawer.machine.ts +++ b/packages/machines/drawer/src/drawer.machine.ts @@ -1,7 +1,7 @@ import { ariaHidden } from "@zag-js/aria-hidden" -import { createMachine } from "@zag-js/core" +import { createGuards, createMachine } from "@zag-js/core" import { trackDismissableElement } from "@zag-js/dismissable" -import { addDomEvent, getEventPoint, getEventTarget, resizeObserverBorderBox } from "@zag-js/dom-query" +import { addDomEvent, getEventPoint, getEventTarget, raf, resizeObserverBorderBox } from "@zag-js/dom-query" import { trapFocus } from "@zag-js/focus-trap" import { preventBodyScroll } from "@zag-js/remove-scroll" import * as dom from "./drawer.dom" @@ -10,6 +10,8 @@ import type { DrawerSchema, ResolvedSnapPoint, SwipeDirection } from "./drawer.t import { DragManager } from "./utils/drag-manager" import { resolveSnapPoint } from "./utils/resolve-snap-point" +const { and } = createGuards() + const isVerticalDirection = (direction: SwipeDirection) => direction === "down" || direction === "up" function dedupeSnapPoints(points: ResolvedSnapPoint[]) { @@ -99,6 +101,9 @@ export const machine = createMachine({ swipeStrength: bindable(() => ({ defaultValue: 1, })), + rendered: bindable<{ title: boolean; description: boolean }>(() => ({ + defaultValue: { title: true, description: true }, + })), } }, @@ -209,6 +214,7 @@ export const machine = createMachine({ states: { open: { tags: ["open"], + entry: ["checkRenderedElements", "deferClearDragOffset"], effects: [ "trackDismissableElement", "preventScroll", @@ -222,7 +228,7 @@ export const machine = createMachine({ on: { "CONTROLLED.CLOSE": { target: "closing", - actions: ["resetSwipeStrength"], + actions: ["clearSwipeOpenAnimation", "resetSwipeStrength"], }, POINTER_DOWN: { actions: ["setPointerStart"], @@ -238,10 +244,20 @@ export const machine = createMachine({ }, ], POINTER_UP: [ + { + guard: and("shouldCloseOnSwipe", "isOpenControlled"), + actions: [ + "clearSwipeOpenAnimation", + "clearRegistrySwiping", + "clearPointerStart", + "clearDragOffset", + "invokeOnClose", + ], + }, { guard: "shouldCloseOnSwipe", target: "closing", - actions: ["clearRegistrySwiping", "setDismissSwipeStrength"], + actions: ["clearSwipeOpenAnimation", "clearRegistrySwiping", "setDismissSwipeStrength"], }, { guard: "isDragging", @@ -260,11 +276,11 @@ export const machine = createMachine({ CLOSE: [ { guard: "isOpenControlled", - actions: ["invokeOnClose"], + actions: ["clearSwipeOpenAnimation", "invokeOnClose"], }, { target: "closing", - actions: ["resetSwipeStrength", "invokeOnClose"], + actions: ["clearSwipeOpenAnimation", "resetSwipeStrength", "invokeOnClose"], }, ], }, @@ -288,6 +304,53 @@ export const machine = createMachine({ }, }, + "swipe-area-dragging": { + tags: ["closed"], + effects: ["trackSwipeOpenPointerMove"], + on: { + POINTER_MOVE: { + guard: "hasSwipeIntent", + target: "swiping-open", + }, + POINTER_UP: { + target: "closed", + actions: ["clearPointerStart", "clearVelocityTracking"], + }, + }, + }, + + "swiping-open": { + tags: ["open"], + effects: ["trackSwipeOpenPointerMove", "trackSizeMeasurements"], + on: { + POINTER_MOVE: { + actions: ["setSwipeOpenOffset"], + }, + POINTER_UP: [ + { + guard: and("shouldOpenOnSwipe", "isOpenControlled"), + actions: ["clearPointerStart", "invokeOnOpen"], + }, + { + guard: "shouldOpenOnSwipe", + target: "open", + actions: ["clearPointerStart", "invokeOnOpen"], + }, + { + target: "closed", + actions: ["clearPointerStart", "clearDragOffset", "clearSizeMeasurements"], + }, + ], + "CONTROLLED.OPEN": { + target: "open", + }, + CLOSE: { + target: "closed", + actions: ["clearPointerStart", "clearDragOffset", "clearSizeMeasurements"], + }, + }, + }, + closed: { tags: ["closed"], on: { @@ -304,6 +367,10 @@ export const machine = createMachine({ actions: ["invokeOnOpen"], }, ], + "SWIPE_AREA.START": { + target: "swipe-area-dragging", + actions: ["setPointerStart"], + }, }, }, }, @@ -338,9 +405,65 @@ export const machine = createMachine({ prop("closeThreshold"), ) }, + + hasSwipeIntent({ refs, prop, event }) { + const dragManager = refs.get("dragManager") + const start = dragManager.getPointerStart() + if (!start || !event.point) return false + const direction = prop("swipeDirection") + const isVertical = isVerticalDirection(direction) + const sign = direction === "up" || direction === "left" ? -1 : 1 + const axis = isVertical ? "y" : "x" + // Opening direction is opposite to dismiss direction + const displacement = (start[axis] - event.point[axis]) * sign + return displacement > 5 + }, + + shouldOpenOnSwipe({ context, refs, prop }) { + const dragManager = refs.get("dragManager") + return dragManager.shouldOpen( + context.get("contentSize"), + prop("swipeVelocityThreshold"), + prop("closeThreshold"), + ) + }, }, actions: { + checkRenderedElements({ context, scope }) { + raf(() => { + context.set("rendered", { + title: !!dom.getTitleEl(scope), + description: !!dom.getDescriptionEl(scope), + }) + }) + }, + + deferClearDragOffset({ context, refs, scope }) { + const dragOffset = context.get("dragOffset") + if (dragOffset === null) return + + const contentEl = dom.getContentEl(scope) + const backdropEl = dom.getBackdropEl(scope) + + // Suppress CSS entry animations so they don't replay from initial state. + // The CSS transition will smoothly animate from release position to fully open. + if (contentEl) contentEl.style.setProperty("animation", "none", "important") + if (backdropEl) backdropEl.style.setProperty("animation", "none", "important") + + raf(() => { + refs.get("dragManager").clearDragOffset() + context.set("dragOffset", null) + }) + }, + + clearSwipeOpenAnimation({ scope }) { + const contentEl = dom.getContentEl(scope) + const backdropEl = dom.getBackdropEl(scope) + if (contentEl) contentEl.style.removeProperty("animation") + if (backdropEl) backdropEl.style.removeProperty("animation") + }, + invokeOnOpen({ prop }) { prop("onOpenChange")?.({ open: true }) }, @@ -367,6 +490,14 @@ export const machine = createMachine({ context.set("dragOffset", dragManager.getDragOffset()) }, + setSwipeOpenOffset({ context, event, refs, prop }) { + const dragManager = refs.get("dragManager") + const contentSize = context.get("contentSize") + if (!contentSize) return + dragManager.setSwipeOpenOffset(event.point, contentSize, prop("swipeDirection")) + context.set("dragOffset", dragManager.getDragOffset()) + }, + setClosestSnapPoint({ computed, context, refs, prop }) { const snapPoints = computed("resolvedSnapPoints") const contentSize = context.get("contentSize") @@ -715,6 +846,41 @@ export const machine = createMachine({ } }, + trackSwipeOpenPointerMove({ scope, send }) { + const doc = scope.getDoc() + + function onPointerMove(event: PointerEvent) { + if (event.pointerType === "touch") return + send({ type: "POINTER_MOVE", point: getEventPoint(event) }) + } + + function onPointerUp(event: PointerEvent) { + if (event.pointerType === "touch") return + send({ type: "POINTER_UP", point: getEventPoint(event) }) + } + + function onTouchMove(event: TouchEvent) { + if (!event.touches[0]) return + send({ type: "POINTER_MOVE", point: getEventPoint(event) }) + } + + function onTouchEnd(event: TouchEvent) { + if (event.touches.length !== 0) return + send({ type: "POINTER_UP", point: getEventPoint(event) }) + } + + const cleanups = [ + addDomEvent(doc, "pointermove", onPointerMove), + addDomEvent(doc, "pointerup", onPointerUp), + addDomEvent(doc, "touchmove", onTouchMove, { passive: false }), + addDomEvent(doc, "touchend", onTouchEnd), + ] + + return () => { + cleanups.forEach((cleanup) => cleanup()) + } + }, + trackExitAnimation({ send, scope }) { const contentEl = dom.getContentEl(scope) if (!contentEl) return diff --git a/packages/machines/drawer/src/drawer.types.ts b/packages/machines/drawer/src/drawer.types.ts index a8af509275..fbad65712d 100644 --- a/packages/machines/drawer/src/drawer.types.ts +++ b/packages/machines/drawer/src/drawer.types.ts @@ -47,11 +47,13 @@ export type ElementIds = Partial<{ positioner: string content: string title: string + description: string header: string trigger: string grabber: string grabberIndicator: string closeTrigger: string + swipeArea: string }> export interface DrawerProps extends DirectionProperty, CommonProperties, DismissableElementHandlers { @@ -184,7 +186,7 @@ type PropsWithDefault = export interface DrawerSchema { props: RequiredBy - state: "open" | "closed" | "closing" + state: "open" | "closed" | "closing" | "swipe-area-dragging" | "swiping-open" tag: "open" | "closed" context: { dragOffset: number | null @@ -194,6 +196,7 @@ export interface DrawerSchema { viewportSize: number rootFontSize: number swipeStrength: number + rendered: { title: boolean; description: boolean } } refs: { dragManager: DragManager @@ -220,6 +223,19 @@ export interface ContentProps { draggable?: boolean | undefined } +export interface SwipeAreaProps { + /** + * Whether the swipe area is disabled. + * @default false + */ + disabled?: boolean | undefined + /** + * The swipe direction that opens the drawer. + * Defaults to the opposite of the drawer's `swipeDirection`. + */ + swipeDirection?: SwipeDirection | undefined +} + export interface DrawerApi { /** * Whether the drawer is open. @@ -270,9 +286,11 @@ export interface DrawerApi { getPositionerProps: () => T["element"] getContentProps: (props?: ContentProps) => T["element"] getTitleProps: () => T["element"] + getDescriptionProps: () => T["element"] getTriggerProps: () => T["element"] getBackdropProps: () => T["element"] getGrabberProps: () => T["element"] getGrabberIndicatorProps: () => T["element"] getCloseTriggerProps: () => T["element"] + getSwipeAreaProps: (props?: SwipeAreaProps) => T["element"] } diff --git a/packages/machines/drawer/src/index.ts b/packages/machines/drawer/src/index.ts index 31d1133828..604bf6afcb 100644 --- a/packages/machines/drawer/src/index.ts +++ b/packages/machines/drawer/src/index.ts @@ -18,5 +18,6 @@ export type { DrawerService as Service, SnapPoint, SnapPointChangeDetails, + SwipeAreaProps, SwipeDirection, } from "./drawer.types" diff --git a/packages/machines/drawer/src/utils/drag-manager.ts b/packages/machines/drawer/src/utils/drag-manager.ts index 071cd567c2..d830392532 100644 --- a/packages/machines/drawer/src/utils/drag-manager.ts +++ b/packages/machines/drawer/src/utils/drag-manager.ts @@ -112,6 +112,18 @@ export class DragManager { if (Math.abs(delta) < DRAG_START_THRESHOLD) return false + // If movement is primarily cross-axis, preserve native scrolling + const isVertical = direction === "down" || direction === "up" + const crossDelta = isVertical ? Math.abs(point.x - this.pointerStart.x) : Math.abs(point.y - this.pointerStart.y) + + if (crossDelta > Math.abs(delta)) { + const crossDirection: SwipeDirection = isVertical ? "right" : "down" + const crossScroll = getScrollInfo(target, container, crossDirection) + if (crossScroll.availableForwardScroll > 1 || crossScroll.availableBackwardScroll > 0) { + return false + } + } + const { availableForwardScroll, availableBackwardScroll } = getScrollInfo(target, container, direction) if ((delta > 0 && Math.abs(availableForwardScroll) > 1) || (delta < 0 && Math.abs(availableBackwardScroll) > 0)) { @@ -173,6 +185,57 @@ export class DragManager { return closest.value } + setSwipeOpenOffset(point: Point, contentSize: number, direction: SwipeDirection) { + if (!this.pointerStart) return + + const currentTimestamp = Date.now() + const sign = this.getDirectionSign(direction) + const axisValue = this.getAxisValue(point, direction) + + if (this.lastPoint) { + const lastAxisValue = this.getAxisValue(this.lastPoint, direction) + const delta = (axisValue - lastAxisValue) * sign + + if (this.lastTimestamp) { + const dt = currentTimestamp - this.lastTimestamp + if (dt > 0) { + const calculatedVelocity = (delta / dt) * 1000 + this.velocity = Number.isFinite(calculatedVelocity) ? calculatedVelocity : 0 + } + } + } + + this.lastPoint = point + this.lastTimestamp = currentTimestamp + + // Opening displacement: how far user has swiped in the opening direction + const pointerStartAxis = this.getAxisValue(this.pointerStart, direction) + const openDisplacement = (pointerStartAxis - axisValue) * sign + + let dragOffset = contentSize - Math.max(0, openDisplacement) + + // Rubber-band: sqrt damping when dragged past fully open + if (dragOffset < 0) { + dragOffset = -Math.sqrt(Math.abs(dragOffset)) + } + + this.dragOffset = dragOffset + } + + shouldOpen(contentSize: number | null, swipeVelocityThreshold: number, openThreshold: number): boolean { + if (this.dragOffset === null || this.velocity === null || contentSize === null) return false + + const visibleSize = contentSize - this.dragOffset + + // Fast swipe in opening direction (negative velocity = opening) + const isFastSwipe = this.velocity < 0 && Math.abs(this.velocity) >= swipeVelocityThreshold + + // Dragged past threshold + const hasEnoughDisplacement = visibleSize >= contentSize * openThreshold + + return isFastSwipe || hasEnoughDisplacement + } + computeSwipeStrength(targetOffset: number): number { const MAX_DURATION_MS = 360 const MIN_SCALAR = 0.1 diff --git a/shared/src/routes.ts b/shared/src/routes.ts index 47f7006e6e..b97d147f68 100644 --- a/shared/src/routes.ts +++ b/shared/src/routes.ts @@ -44,6 +44,11 @@ export const componentRoutes: ComponentRoute[] = [ { slug: "snap-points", title: "Snap Points" }, { slug: "default-active-snap-point", title: "Active Snap Point" }, { slug: "indent-background", title: "Indent Background" }, + { slug: "swipe-area", title: "Swipe Area" }, + { slug: "cross-axis-scroll", title: "Cross-Axis Scroll" }, + { slug: "action-sheet", title: "Action Sheet" }, + { slug: "controlled", title: "Controlled" }, + { slug: "mobile-nav", title: "Mobile Nav" }, ], }, { From 0756817a6f0ca52a0619f640c8d984b614763b21 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Fri, 20 Mar 2026 12:41:44 +0200 Subject: [PATCH 2/7] feat(dialog, drawer): enhance non-modal functionality and focus management --- .changeset/dialog-non-modal.md | 7 +++ .changeset/drawer-swipe-area.md | 5 +- examples/next-ts/pages/drawer/non-modal.tsx | 57 +++++++++++++++++++ examples/shared/styles/drawer.module.css | 6 ++ .../machines/dialog/src/dialog.connect.ts | 7 ++- .../machines/dialog/src/dialog.machine.ts | 15 ++++- packages/machines/dialog/src/dialog.types.ts | 8 ++- packages/machines/drawer/GAPS.md | 49 ++++++++++++++++ .../machines/drawer/src/drawer.connect.ts | 4 ++ .../machines/drawer/src/drawer.machine.ts | 23 +++++++- shared/src/routes.ts | 1 + 11 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 .changeset/dialog-non-modal.md create mode 100644 examples/next-ts/pages/drawer/non-modal.tsx create mode 100644 packages/machines/drawer/GAPS.md 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 index cf94aa8bc0..e2bc05062f 100644 --- a/.changeset/drawer-swipe-area.md +++ b/.changeset/drawer-swipe-area.md @@ -6,10 +6,13 @@ - Add `SwipeArea` part for swipe-to-open gestures from screen edges ```tsx - // Invisible zone at the bottom of the screen for swipe-to-open
``` - 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/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/shared/styles/drawer.module.css b/examples/shared/styles/drawer.module.css index 5b0e210a06..ed6c3bb580 100644 --- a/examples/shared/styles/drawer.module.css +++ b/examples/shared/styles/drawer.module.css @@ -63,6 +63,7 @@ right: auto; top: auto; bottom: auto; + box-shadow: 0 -4px 32px rgb(0 0 0 / 10%); &[data-state="open"] { animation-name: slideInDown; @@ -77,6 +78,7 @@ border-top-right-radius: 0; border-bottom-left-radius: 16px; border-bottom-right-radius: 16px; + box-shadow: 0 4px 32px rgb(0 0 0 / 10%); &[data-state="open"] { animation-name: slideInUp; @@ -97,7 +99,9 @@ } &[data-swipe-direction="left"] { + border-top-right-radius: 16px; border-bottom-right-radius: 16px; + box-shadow: 4px 0 32px rgb(0 0 0 / 10%); &[data-state="open"] { animation-name: slideInLeft; @@ -109,7 +113,9 @@ } &[data-swipe-direction="right"] { + border-top-left-radius: 16px; border-bottom-left-radius: 16px; + box-shadow: -4px 0 32px rgb(0 0 0 / 10%); &[data-state="open"] { animation-name: slideInRight; diff --git a/packages/machines/dialog/src/dialog.connect.ts b/packages/machines/dialog/src/dialog.connect.ts index 8067224348..2f3e8209a6 100644 --- a/packages/machines/dialog/src/dialog.connect.ts +++ b/packages/machines/dialog/src/dialog.connect.ts @@ -53,7 +53,7 @@ export function connect( dir: prop("dir"), id: dom.getPositionerId(scope), style: { - pointerEvents: open ? undefined : "none", + pointerEvents: !open || !prop("modal") ? "none" : undefined, }, }) }, @@ -68,10 +68,13 @@ export function connect( id: dom.getContentId(scope), tabIndex: -1, "data-state": open ? "open" : "closed", - "aria-modal": true, + "aria-modal": prop("modal"), "aria-label": ariaLabel || undefined, "aria-labelledby": ariaLabel || !rendered.title ? undefined : dom.getTitleId(scope), "aria-describedby": rendered.description ? dom.getDescriptionId(scope) : undefined, + style: { + pointerEvents: prop("modal") ? undefined : "auto", + }, }) }, diff --git a/packages/machines/dialog/src/dialog.machine.ts b/packages/machines/dialog/src/dialog.machine.ts index 2938dfe21f..4bf16aac8d 100644 --- a/packages/machines/dialog/src/dialog.machine.ts +++ b/packages/machines/dialog/src/dialog.machine.ts @@ -1,7 +1,7 @@ import { ariaHidden } from "@zag-js/aria-hidden" import { createMachine } from "@zag-js/core" import { trackDismissableElement } from "@zag-js/dismissable" -import { getComputedStyle, raf } from "@zag-js/dom-query" +import { getComputedStyle, getInitialFocus, raf } from "@zag-js/dom-query" import { trapFocus } from "@zag-js/focus-trap" import { preventBodyScroll } from "@zag-js/remove-scroll" import * as dom from "./dialog.dom" @@ -46,7 +46,7 @@ export const machine = createMachine({ states: { open: { - entry: ["checkRenderedElements", "syncZIndex"], + entry: ["checkRenderedElements", "syncZIndex", "setInitialFocus"], effects: ["trackDismissableElement", "trapFocus", "preventScroll", "hideContentBelow"], on: { "CONTROLLED.CLOSE": { @@ -164,6 +164,17 @@ export const machine = createMachine({ }, actions: { + setInitialFocus({ prop, scope }) { + if (prop("trapFocus")) return + raf(() => { + const element = getInitialFocus({ + root: dom.getContentEl(scope), + getInitialEl: prop("initialFocusEl"), + }) + element?.focus({ preventScroll: true }) + }) + }, + checkRenderedElements({ context, scope }) { raf(() => { context.set("rendered", { diff --git a/packages/machines/dialog/src/dialog.types.ts b/packages/machines/dialog/src/dialog.types.ts index dbcc2a2b0b..20e149b779 100644 --- a/packages/machines/dialog/src/dialog.types.ts +++ b/packages/machines/dialog/src/dialog.types.ts @@ -110,7 +110,13 @@ export interface DialogSchema { } guard: "isOpenControlled" effect: "trackDismissableElement" | "preventScroll" | "trapFocus" | "hideContentBelow" - action: "checkRenderedElements" | "syncZIndex" | "invokeOnClose" | "invokeOnOpen" | "toggleVisibility" + action: + | "checkRenderedElements" + | "syncZIndex" + | "setInitialFocus" + | "invokeOnClose" + | "invokeOnOpen" + | "toggleVisibility" event: { type: "CONTROLLED.OPEN" | "CONTROLLED.CLOSE" | "OPEN" | "CLOSE" | "TOGGLE" } diff --git a/packages/machines/drawer/GAPS.md b/packages/machines/drawer/GAPS.md new file mode 100644 index 0000000000..29ec10d0b1 --- /dev/null +++ b/packages/machines/drawer/GAPS.md @@ -0,0 +1,49 @@ +# Drawer — Missing Features + +Gaps identified by comparing against Base UI's drawer implementation. + +## P0 — Must Have + +### ~~1. `description` anatomy part~~ ✅ Done + +Added `description` to anatomy, `getDescriptionProps()` to API, conditional `aria-describedby` +on content element, and `rendered` context tracking (mirrors dialog pattern). + +### 2. Change event `reason` field + +Provide a `reason` string on `onOpenChange` so consumers know _why_ the drawer opened/closed. +Values: `trigger-press`, `outside-press`, `escape-key`, `close-press`, `swipe`, `programmatic`. + +Without this, consumers can't distinguish a swipe dismiss from an escape key press, +making analytics and conditional close prevention difficult. + +## P1 — Should Have + +### ~~4. `SwipeArea` part (swipe-to-open)~~ ✅ Done + +Added `swipeArea` anatomy part, `getSwipeAreaProps()` to API with `disabled` and `swipeDirection` +options, new `swiping-open` machine state with pointer tracking and size measurement, +`setSwipeOpenOffset`/`shouldOpen` on DragManager, rubber-band damping, velocity-based opening, +and controlled mode support. + +### ~~5. Cross-axis scroll preservation~~ ✅ Done + +Added cross-axis intent detection in `shouldStartDragging`. When movement is primarily +cross-axis and the target has scrollable content in that direction, drawer drag is suppressed. +Added cross-axis-scroll example to demonstrate. + +### 6. `modal: "trap-focus"` hybrid mode + +A third modal mode that traps focus but allows scroll and pointer interaction outside. +Use case: persistent side panels, non-blocking drawers that still manage focus for a11y. + +## P2 — Nice to Have + +### 7. Missing examples + +Add examples for scenarios we support but don't demonstrate: +- ~~Non-modal drawer~~ ✅ Done +- ~~Side/position variants (left, right, top)~~ ✅ Done (non-modal uses right, mobile-nav uses bottom) +- ~~Action sheet pattern~~ ✅ Done +- ~~Mobile navigation~~ ✅ Done +- ~~Controlled open state~~ ✅ Done diff --git a/packages/machines/drawer/src/drawer.connect.ts b/packages/machines/drawer/src/drawer.connect.ts index a77975e292..cf68714e14 100644 --- a/packages/machines/drawer/src/drawer.connect.ts +++ b/packages/machines/drawer/src/drawer.connect.ts @@ -99,6 +99,9 @@ export function connect(service: DrawerService, normalize: hidden: closed, "data-state": open ? "open" : "closed", "data-swipe-direction": swipeDirection, + style: { + pointerEvents: prop("modal") ? undefined : "none", + }, }) }, @@ -123,6 +126,7 @@ export function connect(service: DrawerService, normalize: "data-swiping": dragging || swipingOpen ? "" : undefined, "data-dragging": dragging ? "" : undefined, style: { + pointerEvents: prop("modal") ? undefined : "auto", visibility: swipingOpen && dragOffset === null ? "hidden" : undefined, transform: "translate3d(var(--drawer-translate-x, 0px), var(--drawer-translate-y, 0px), 0)", transitionDuration: dragging || swipingOpen ? "0s" : undefined, diff --git a/packages/machines/drawer/src/drawer.machine.ts b/packages/machines/drawer/src/drawer.machine.ts index ef21b21317..96cf991440 100644 --- a/packages/machines/drawer/src/drawer.machine.ts +++ b/packages/machines/drawer/src/drawer.machine.ts @@ -1,7 +1,14 @@ import { ariaHidden } from "@zag-js/aria-hidden" import { createGuards, createMachine } from "@zag-js/core" import { trackDismissableElement } from "@zag-js/dismissable" -import { addDomEvent, getEventPoint, getEventTarget, raf, resizeObserverBorderBox } from "@zag-js/dom-query" +import { + addDomEvent, + getEventPoint, + getEventTarget, + getInitialFocus, + raf, + resizeObserverBorderBox, +} from "@zag-js/dom-query" import { trapFocus } from "@zag-js/focus-trap" import { preventBodyScroll } from "@zag-js/remove-scroll" import * as dom from "./drawer.dom" @@ -214,7 +221,7 @@ export const machine = createMachine({ states: { open: { tags: ["open"], - entry: ["checkRenderedElements", "deferClearDragOffset"], + entry: ["checkRenderedElements", "setInitialFocus", "deferClearDragOffset"], effects: [ "trackDismissableElement", "preventScroll", @@ -430,6 +437,18 @@ export const machine = createMachine({ }, actions: { + setInitialFocus({ prop, scope }) { + // In modal mode, trapFocus handles initial focus + if (prop("trapFocus")) return + raf(() => { + const element = getInitialFocus({ + root: dom.getContentEl(scope), + getInitialEl: prop("initialFocusEl"), + }) + element?.focus({ preventScroll: true }) + }) + }, + checkRenderedElements({ context, scope }) { raf(() => { context.set("rendered", { diff --git a/shared/src/routes.ts b/shared/src/routes.ts index b97d147f68..ddab8f6b5d 100644 --- a/shared/src/routes.ts +++ b/shared/src/routes.ts @@ -49,6 +49,7 @@ export const componentRoutes: ComponentRoute[] = [ { slug: "action-sheet", title: "Action Sheet" }, { slug: "controlled", title: "Controlled" }, { slug: "mobile-nav", title: "Mobile Nav" }, + { slug: "non-modal", title: "Non-Modal" }, ], }, { From 0be20aae7b41359a9306c2d65ecc429f2294c2ca Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Fri, 20 Mar 2026 16:59:26 +0200 Subject: [PATCH 3/7] refactor(drawer): update snap points and enhance drag functionality --- e2e/drawer.e2e.ts | 65 +++- examples/next-ts/pages/drawer/snap-points.tsx | 2 +- .../nuxt-ts/app/composables/useControls.ts | 4 +- .../nuxt-ts/app/pages/drawer/snap-points.vue | 4 +- .../src/routes/drawer/snap-points.tsx | 2 +- .../routes/drawer/snap-points/+page.svelte | 2 +- packages/machines/drawer/GAPS.md | 49 --- .../machines/drawer/src/drawer.machine.ts | 108 ++++--- .../machines/drawer/src/utils/drag-manager.ts | 279 ++++++++++++------ shared/src/controls.ts | 4 +- 10 files changed, 327 insertions(+), 192 deletions(-) delete mode 100644 packages/machines/drawer/GAPS.md 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/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/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/snap-points.vue b/examples/nuxt-ts/app/pages/drawer/snap-points.vue index 98e6c073cc..a5d0f34303 100644 --- a/examples/nuxt-ts/app/pages/drawer/snap-points.vue +++ b/examples/nuxt-ts/app/pages/drawer/snap-points.vue @@ -9,9 +9,9 @@ const controls = useControls(drawerControls) const service = useMachine( drawer.machine, - controls.mergeProps({ + controls.mergeProps({ id: useId(), - snapPoints: [0.25, "250px", 1], + snapPoints: ["20rem", 1], }), ) diff --git a/examples/solid-ts/src/routes/drawer/snap-points.tsx b/examples/solid-ts/src/routes/drawer/snap-points.tsx index 8f661f3ffb..98176c9c73 100644 --- a/examples/solid-ts/src/routes/drawer/snap-points.tsx +++ b/examples/solid-ts/src/routes/drawer/snap-points.tsx @@ -14,7 +14,7 @@ export default function Page() { drawer.machine, controls.mergeProps({ id: createUniqueId(), - snapPoints: [0.25, "250px", 1], + snapPoints: ["20rem", 1], }), ) diff --git a/examples/svelte-ts/src/routes/drawer/snap-points/+page.svelte b/examples/svelte-ts/src/routes/drawer/snap-points/+page.svelte index a1ea36e253..b20a24dd52 100644 --- a/examples/svelte-ts/src/routes/drawer/snap-points/+page.svelte +++ b/examples/svelte-ts/src/routes/drawer/snap-points/+page.svelte @@ -15,7 +15,7 @@ drawer.machine, controls.mergeProps({ id, - snapPoints: [0.25, "250px", 1], + snapPoints: ["20rem", 1], }), ) diff --git a/packages/machines/drawer/GAPS.md b/packages/machines/drawer/GAPS.md deleted file mode 100644 index 29ec10d0b1..0000000000 --- a/packages/machines/drawer/GAPS.md +++ /dev/null @@ -1,49 +0,0 @@ -# Drawer — Missing Features - -Gaps identified by comparing against Base UI's drawer implementation. - -## P0 — Must Have - -### ~~1. `description` anatomy part~~ ✅ Done - -Added `description` to anatomy, `getDescriptionProps()` to API, conditional `aria-describedby` -on content element, and `rendered` context tracking (mirrors dialog pattern). - -### 2. Change event `reason` field - -Provide a `reason` string on `onOpenChange` so consumers know _why_ the drawer opened/closed. -Values: `trigger-press`, `outside-press`, `escape-key`, `close-press`, `swipe`, `programmatic`. - -Without this, consumers can't distinguish a swipe dismiss from an escape key press, -making analytics and conditional close prevention difficult. - -## P1 — Should Have - -### ~~4. `SwipeArea` part (swipe-to-open)~~ ✅ Done - -Added `swipeArea` anatomy part, `getSwipeAreaProps()` to API with `disabled` and `swipeDirection` -options, new `swiping-open` machine state with pointer tracking and size measurement, -`setSwipeOpenOffset`/`shouldOpen` on DragManager, rubber-band damping, velocity-based opening, -and controlled mode support. - -### ~~5. Cross-axis scroll preservation~~ ✅ Done - -Added cross-axis intent detection in `shouldStartDragging`. When movement is primarily -cross-axis and the target has scrollable content in that direction, drawer drag is suppressed. -Added cross-axis-scroll example to demonstrate. - -### 6. `modal: "trap-focus"` hybrid mode - -A third modal mode that traps focus but allows scroll and pointer interaction outside. -Use case: persistent side panels, non-blocking drawers that still manage focus for a11y. - -## P2 — Nice to Have - -### 7. Missing examples - -Add examples for scenarios we support but don't demonstrate: -- ~~Non-modal drawer~~ ✅ Done -- ~~Side/position variants (left, right, top)~~ ✅ Done (non-modal uses right, mobile-nav uses bottom) -- ~~Action sheet pattern~~ ✅ Done -- ~~Mobile navigation~~ ✅ Done -- ~~Controlled open state~~ ✅ Done diff --git a/packages/machines/drawer/src/drawer.machine.ts b/packages/machines/drawer/src/drawer.machine.ts index 96cf991440..7f1aa2ce38 100644 --- a/packages/machines/drawer/src/drawer.machine.ts +++ b/packages/machines/drawer/src/drawer.machine.ts @@ -6,6 +6,7 @@ import { getEventPoint, getEventTarget, getInitialFocus, + isHTMLElement, raf, resizeObserverBorderBox, } from "@zag-js/dom-query" @@ -74,8 +75,8 @@ export const machine = createMachine({ defaultSnapPoint: props.defaultSnapPoint ?? snapPoints[0] ?? null, swipeDirection: "down", snapToSequentialPoints: false, - swipeVelocityThreshold: 700, - closeThreshold: 0.25, + swipeVelocityThreshold: 500, + closeThreshold: 0.5, preventDragOnScroll: true, ...props, } @@ -270,14 +271,16 @@ export const machine = createMachine({ guard: "isDragging", actions: [ "clearRegistrySwiping", + "suppressBackdropAnimation", "setSnapSwipeStrength", "setClosestSnapPoint", "clearPointerStart", "clearDragOffset", + "clearVelocityTracking", ], }, { - actions: ["clearRegistrySwiping", "clearPointerStart", "clearDragOffset"], + actions: ["clearRegistrySwiping", "clearPointerStart", "clearDragOffset", "clearVelocityTracking"], }, ], CLOSE: [ @@ -391,7 +394,7 @@ export const machine = createMachine({ }, shouldStartDragging({ prop, refs, event, scope }) { - if (!(event.target instanceof HTMLElement)) return false + if (!isHTMLElement(event.target)) return false const dragManager = refs.get("dragManager") return dragManager.shouldStartDragging( @@ -404,13 +407,10 @@ export const machine = createMachine({ }, shouldCloseOnSwipe({ prop, context, computed, refs }) { + // In sequential mode, let findClosestSnapPoint handle dismiss decisions + if (prop("snapToSequentialPoints")) return false const dragManager = refs.get("dragManager") - return dragManager.shouldDismiss( - context.get("contentSize"), - computed("resolvedSnapPoints"), - prop("swipeVelocityThreshold"), - prop("closeThreshold"), - ) + return dragManager.shouldDismiss(context.get("contentSize"), computed("resolvedSnapPoints")) }, hasSwipeIntent({ refs, prop, event }) { @@ -476,6 +476,13 @@ export const machine = createMachine({ }) }, + suppressBackdropAnimation({ scope }) { + const backdropEl = dom.getBackdropEl(scope) + if (!backdropEl) return + // Keep override until drawer closes (clearSwipeOpenAnimation handles cleanup) + backdropEl.style.setProperty("animation", "none", "important") + }, + clearSwipeOpenAnimation({ scope }) { const contentEl = dom.getContentEl(scope) const backdropEl = dom.getBackdropEl(scope) @@ -517,7 +524,7 @@ export const machine = createMachine({ context.set("dragOffset", dragManager.getDragOffset()) }, - setClosestSnapPoint({ computed, context, refs, prop }) { + setClosestSnapPoint({ computed, context, refs, prop, send }) { const snapPoints = computed("resolvedSnapPoints") const contentSize = context.get("contentSize") const viewportSize = context.get("viewportSize") @@ -530,8 +537,15 @@ export const machine = createMachine({ snapPoints, context.get("resolvedActiveSnapPoint"), prop("snapToSequentialPoints"), + contentSize, ) + // null means dismiss (sequential mode determined closing is closer) + if (closestSnapPoint === null) { + send({ type: "CLOSE" }) + return + } + context.set("snapPoint", closestSnapPoint) const resolved = resolveSnapPoint(closestSnapPoint, { @@ -572,12 +586,14 @@ export const machine = createMachine({ setSnapSwipeStrength({ context, refs, computed, prop }) { const dragManager = refs.get("dragManager") const snapPoints = computed("resolvedSnapPoints") + const contentSize = context.get("contentSize") const closestSnapPoint = dragManager.findClosestSnapPoint( snapPoints, context.get("resolvedActiveSnapPoint"), prop("snapToSequentialPoints"), + contentSize ?? 0, ) - const contentSize = context.get("contentSize") + if (closestSnapPoint === null) return const viewportSize = context.get("viewportSize") const rootFontSize = context.get("rootFontSize") const resolved = resolveSnapPoint(closestSnapPoint, { @@ -711,6 +727,9 @@ export const machine = createMachine({ const isVertical = isVerticalDirection(swipeDirection) function onPointerMove(event: PointerEvent) { + // Touch is handled by touchmove/touchend only. Feeding both pointer and touch + // events duplicates POINTER_MOVE on many browsers and skews velocity / snap. + if (event.pointerType === "touch") return const point = getEventPoint(event) const target = getEventTarget(event) send({ type: "POINTER_MOVE", point, target, swipeDirection }) @@ -722,6 +741,12 @@ export const machine = createMachine({ send({ type: "POINTER_UP", point }) } + function onPointerCancel(event: PointerEvent) { + if (event.pointerType === "touch") return + const point = getEventPoint(event) + send({ type: "POINTER_UP", point }) + } + function onTouchStart(event: TouchEvent) { if (!event.touches[0]) return lastAxis = isVertical ? event.touches[0].clientY : event.touches[0].clientX @@ -730,7 +755,8 @@ export const machine = createMachine({ function onTouchMove(event: TouchEvent) { if (!event.touches[0]) return const point = getEventPoint(event) - const target = event.target as HTMLElement + // Match pointer path: composedPath[0] behaves correctly with shadow DOM / retargeting. + const target = getEventTarget(event) ?? (event.target as HTMLElement) if (!prop("preventDragOnScroll")) { send({ type: "POINTER_MOVE", point, target, swipeDirection }) @@ -738,28 +764,30 @@ export const machine = createMachine({ } const contentEl = dom.getContentEl(scope) - if (!contentEl) return - - let el: HTMLElement | null = target - while ( - el && - el !== contentEl && - (isVertical ? el.scrollHeight <= el.clientHeight : el.scrollWidth <= el.clientWidth) - ) { - el = el.parentElement - } + // Never drop touch moves when content isn't resolved yet — pointermove has no such gate, + // and missing moves on mobile breaks drag + sequential snap release. + if (contentEl) { + let el: HTMLElement | null = target + while ( + el && + el !== contentEl && + (isVertical ? el.scrollHeight <= el.clientHeight : el.scrollWidth <= el.clientWidth) + ) { + el = el.parentElement + } - if (el && el !== contentEl) { - const scrollPos = isVertical ? el.scrollTop : el.scrollLeft - const axis = isVertical ? event.touches[0].clientY : event.touches[0].clientX + if (el && el !== contentEl) { + const scrollPos = isVertical ? el.scrollTop : el.scrollLeft + const axis = isVertical ? event.touches[0].clientY : event.touches[0].clientX - const atStart = scrollPos <= 0 - const movingTowardStart = axis > lastAxis - if (atStart && movingTowardStart) { - event.preventDefault() - } + const atStart = scrollPos <= 0 + const movingTowardStart = axis > lastAxis + if (atStart && movingTowardStart) { + event.preventDefault() + } - lastAxis = axis + lastAxis = axis + } } send({ type: "POINTER_MOVE", point, target, swipeDirection }) @@ -771,14 +799,24 @@ export const machine = createMachine({ send({ type: "POINTER_UP", point }) } + function onTouchCancel(event: TouchEvent) { + const point = getEventPoint(event) + send({ type: "POINTER_UP", point }) + } + const doc = scope.getDoc() + // Capture phase + non-passive touchmove so we run before nested scrollables / iOS + // default behaviors, consistent with robust drawer touch handling elsewhere. + const touchOpts = { capture: true, passive: false } as const const cleanups = [ addDomEvent(doc, "pointermove", onPointerMove), addDomEvent(doc, "pointerup", onPointerUp), - addDomEvent(doc, "touchstart", onTouchStart, { passive: false }), - addDomEvent(doc, "touchmove", onTouchMove, { passive: false }), - addDomEvent(doc, "touchend", onTouchEnd), + addDomEvent(doc, "pointercancel", onPointerCancel), + addDomEvent(doc, "touchstart", onTouchStart, touchOpts), + addDomEvent(doc, "touchmove", onTouchMove, touchOpts), + addDomEvent(doc, "touchend", onTouchEnd, { capture: true }), + addDomEvent(doc, "touchcancel", onTouchCancel, { capture: true }), ] return () => { diff --git a/packages/machines/drawer/src/utils/drag-manager.ts b/packages/machines/drawer/src/utils/drag-manager.ts index d830392532..237f6d963b 100644 --- a/packages/machines/drawer/src/utils/drag-manager.ts +++ b/packages/machines/drawer/src/utils/drag-manager.ts @@ -4,18 +4,35 @@ import { findClosestSnapPoint } from "./find-closest-snap-point" import { getScrollInfo } from "./get-scroll-info" const DRAG_START_THRESHOLD = 0.3 -const SEQUENTIAL_THRESHOLD = 14 -const SNAP_VELOCITY_THRESHOLD = 500 // px/s -const SNAP_VELOCITY_MULTIPLIER = 0.3 // seconds +const SEQUENTIAL_THRESHOLD = 24 +const SNAP_VELOCITY_THRESHOLD = 400 // px/s +const SNAP_VELOCITY_MULTIPLIER = 0.4 // seconds const MAX_SNAP_VELOCITY = 4000 // px/s +// Velocity tracking: sliding window for smooth, reliable velocity +const VELOCITY_WINDOW_MS = 100 // consider samples from the last 100ms +const MAX_RELEASE_VELOCITY_AGE_MS = 80 // max age for recent velocity to be valid +const MIN_GESTURE_DURATION_MS = 50 // min duration for overall gesture velocity +const MIN_VELOCITY_SAMPLES = 2 + +interface VelocitySample { + axis: number + time: number +} + export class DragManager { private pointerStart: Point | null = null private dragOffset: number | null = null - private lastPoint: Point | null = null - private lastTimestamp: number | null = null private velocity: number | null = null + // Sliding window for velocity calculation + private samples: VelocitySample[] = [] + + // Gesture-level tracking for fallback velocity + private gestureStartAxis: number | null = null + private gestureStartTime: number | null = null + private gestureSign: number = 1 + setPointerStart(point: Point) { this.pointerStart = point } @@ -36,29 +53,82 @@ export class DragManager { return direction === "up" || direction === "left" ? -1 : 1 } - setDragOffsetForDirection(point: Point, resolvedActiveSnapPointOffset: number, direction: SwipeDirection) { - if (!this.pointerStart) return + private trackVelocity(axisValue: number, sign: number) { + const now = performance.now() - const currentTimestamp = new Date().getTime() - const sign = this.getDirectionSign(direction) - const axisValue = this.getAxisValue(point, direction) + // Track gesture start for fallback velocity + if (this.gestureStartAxis === null) { + this.gestureStartAxis = axisValue + this.gestureStartTime = now + this.gestureSign = sign + } - if (this.lastPoint) { - const lastAxisValue = this.getAxisValue(this.lastPoint, direction) - const delta = (axisValue - lastAxisValue) * sign + this.samples.push({ axis: axisValue, time: now }) - if (this.lastTimestamp) { - const dt = currentTimestamp - this.lastTimestamp - if (dt > 0) { - const calculatedVelocity = (delta / dt) * 1000 - // Handle edge cases: NaN or Infinity should be treated as 0 - this.velocity = Number.isFinite(calculatedVelocity) ? calculatedVelocity : 0 - } + // Prune samples older than the window + const cutoff = now - VELOCITY_WINDOW_MS + while (this.samples.length > 0 && this.samples[0].time < cutoff) { + this.samples.shift() + } + + if (this.samples.length < MIN_VELOCITY_SAMPLES) { + this.velocity = 0 + return + } + + // Compute velocity from oldest to newest sample in the window + const oldest = this.samples[0] + const newest = this.samples[this.samples.length - 1] + const dt = newest.time - oldest.time + + if (dt <= 0) { + this.velocity = 0 + return + } + + const delta = (newest.axis - oldest.axis) * sign + const v = (delta / dt) * 1000 // px/s + this.velocity = Number.isFinite(v) ? v : 0 + } + + /** + * Get the best available velocity for release decisions. + * Prefers the sliding window velocity if the last sample is fresh. + * Falls back to the overall gesture velocity if samples are stale + * (e.g., user paused briefly before releasing). + */ + getReleaseVelocity(): number { + const now = performance.now() + + // Check if the sliding window velocity is fresh + if (this.samples.length >= MIN_VELOCITY_SAMPLES) { + const newest = this.samples[this.samples.length - 1] + if (now - newest.time <= MAX_RELEASE_VELOCITY_AGE_MS) { + return this.velocity ?? 0 + } + } + + // Fallback: overall gesture velocity + if (this.gestureStartAxis !== null && this.gestureStartTime !== null) { + const lastSample = this.samples[this.samples.length - 1] + if (lastSample) { + const dt = Math.max(lastSample.time - this.gestureStartTime, MIN_GESTURE_DURATION_MS) + const delta = (lastSample.axis - this.gestureStartAxis) * this.gestureSign + const v = (delta / dt) * 1000 + return Number.isFinite(v) ? v : 0 } } - this.lastPoint = point - this.lastTimestamp = currentTimestamp + return this.velocity ?? 0 + } + + setDragOffsetForDirection(point: Point, resolvedActiveSnapPointOffset: number, direction: SwipeDirection) { + if (!this.pointerStart) return + + const sign = this.getDirectionSign(direction) + const axisValue = this.getAxisValue(point, direction) + + this.trackVelocity(axisValue, sign) const pointerStartAxis = this.getAxisValue(this.pointerStart, direction) let delta = (pointerStartAxis - axisValue) * sign - resolvedActiveSnapPointOffset @@ -79,14 +149,11 @@ export class DragManager { this.dragOffset = null } - getVelocity(): number | null { - return this.velocity - } - clearVelocityTracking() { - this.lastPoint = null - this.lastTimestamp = null + this.samples = [] this.velocity = null + this.gestureStartAxis = null + this.gestureStartTime = null } clear() { @@ -138,45 +205,102 @@ export class DragManager { snapPoints: ResolvedSnapPoint[], snapPoint: ResolvedSnapPoint | null, snapToSequentialPoints: boolean, - ): number | string { + contentSize: number, + ): number | string | null { if (this.dragOffset === null) { return snapPoints[0]?.value ?? 1 } + const velocity = this.getReleaseVelocity() + if (snapToSequentialPoints && snapPoint) { - const currentIndex = snapPoints.findIndex((item) => Object.is(item.value, snapPoint.value)) - if (currentIndex >= 0) { - const delta = this.dragOffset - snapPoint.offset + // Sort by offset so index navigation matches drag direction + // (offset 0 = fully open at index 0, highest offset = most closed at end) + const ordered = [...snapPoints].sort((a, b) => a.offset - b.offset) + + // Find current index by closest offset (not value) since deduplication + // may have replaced the original snap point value + let currentIndex = 0 + let closestDist = Math.abs(snapPoint.offset - ordered[0].offset) + for (let i = 1; i < ordered.length; i++) { + const dist = Math.abs(snapPoint.offset - ordered[i].offset) + if (dist < closestDist) { + closestDist = dist + currentIndex = i + } + } + + { + const currentPoint = ordered[currentIndex] + const delta = this.dragOffset - currentPoint.offset const dragDirection = Math.sign(delta) - const velocityDirection = this.velocity !== null ? Math.sign(this.velocity) : 0 + let velocityDirection = Math.sign(velocity) + + // Touch releases often end with a short opposing velocity tick (rubber-band / OS), + // which would block velocity-based advance. If the drag offset clearly moved in one + // direction, ignore that reversal for advance only (Base UI aligns similarly). + let velocityForAdvance = velocity + if ( + dragDirection !== 0 && + Math.abs(delta) >= SEQUENTIAL_THRESHOLD && + velocityDirection !== 0 && + velocityDirection !== dragDirection + ) { + velocityForAdvance = 0 + velocityDirection = 0 + } - // Velocity-based skip: fast swipe in drag direction jumps to adjacent point + let targetSnapPoint = currentPoint + let effectiveTargetOffset = this.dragOffset + + // Velocity-based advancement to adjacent snap point const shouldAdvance = dragDirection !== 0 && velocityDirection === dragDirection && - Math.abs(this.velocity ?? 0) >= SNAP_VELOCITY_THRESHOLD + Math.abs(velocityForAdvance) >= SNAP_VELOCITY_THRESHOLD if (shouldAdvance) { - const nextIndex = Math.min(Math.max(currentIndex + dragDirection, 0), snapPoints.length - 1) - if (nextIndex !== currentIndex) { - return snapPoints[nextIndex].value + const adjacentIndex = Math.min(Math.max(currentIndex + dragDirection, 0), ordered.length - 1) + if (adjacentIndex !== currentIndex) { + const adjacentPoint = ordered[adjacentIndex] + // Always step to the adjacent snap when velocity commits to that direction. + // The old "only if not yet passed" check stranded flicks that overshot the + // intermediate offset (common on touch), snapping back to the previous stop. + targetSnapPoint = adjacentPoint + effectiveTargetOffset = adjacentPoint.offset + } else if (dragDirection > 0) { + // Past the last snap point with velocity → dismiss + return null + } + } else if (delta > SEQUENTIAL_THRESHOLD) { + const nextPoint = ordered[Math.min(currentIndex + 1, ordered.length - 1)] + if (nextPoint) { + targetSnapPoint = nextPoint + effectiveTargetOffset = nextPoint.offset + } + } else if (delta < -SEQUENTIAL_THRESHOLD) { + const prevPoint = ordered[Math.max(currentIndex - 1, 0)] + if (prevPoint) { + targetSnapPoint = prevPoint + effectiveTargetOffset = prevPoint.offset } } - if (delta > SEQUENTIAL_THRESHOLD) { - return snapPoints[Math.min(currentIndex + 1, snapPoints.length - 1)]?.value ?? snapPoint.value + // Compare close distance vs snap distance + const closeDistance = Math.abs(effectiveTargetOffset - contentSize) + const snapDistance = Math.abs(effectiveTargetOffset - targetSnapPoint.offset) + if (closeDistance < snapDistance) { + return null // dismiss } - if (delta < -SEQUENTIAL_THRESHOLD) { - return snapPoints[Math.max(currentIndex - 1, 0)]?.value ?? snapPoint.value - } - return snapPoint.value + + return targetSnapPoint.value } } // Non-sequential: apply velocity offset for momentum-based skipping let targetOffset = this.dragOffset - if (this.velocity !== null && Math.abs(this.velocity) >= SNAP_VELOCITY_THRESHOLD) { - const clamped = Math.min(MAX_SNAP_VELOCITY, Math.max(-MAX_SNAP_VELOCITY, this.velocity)) + if (Math.abs(velocity) >= SNAP_VELOCITY_THRESHOLD) { + const clamped = Math.min(MAX_SNAP_VELOCITY, Math.max(-MAX_SNAP_VELOCITY, velocity)) targetOffset += clamped * SNAP_VELOCITY_MULTIPLIER targetOffset = Math.max(0, targetOffset) } @@ -188,25 +312,10 @@ export class DragManager { setSwipeOpenOffset(point: Point, contentSize: number, direction: SwipeDirection) { if (!this.pointerStart) return - const currentTimestamp = Date.now() const sign = this.getDirectionSign(direction) const axisValue = this.getAxisValue(point, direction) - if (this.lastPoint) { - const lastAxisValue = this.getAxisValue(this.lastPoint, direction) - const delta = (axisValue - lastAxisValue) * sign - - if (this.lastTimestamp) { - const dt = currentTimestamp - this.lastTimestamp - if (dt > 0) { - const calculatedVelocity = (delta / dt) * 1000 - this.velocity = Number.isFinite(calculatedVelocity) ? calculatedVelocity : 0 - } - } - } - - this.lastPoint = point - this.lastTimestamp = currentTimestamp + this.trackVelocity(axisValue, sign) // Opening displacement: how far user has swiped in the opening direction const pointerStartAxis = this.getAxisValue(this.pointerStart, direction) @@ -223,12 +332,13 @@ export class DragManager { } shouldOpen(contentSize: number | null, swipeVelocityThreshold: number, openThreshold: number): boolean { - if (this.dragOffset === null || this.velocity === null || contentSize === null) return false + if (this.dragOffset === null || contentSize === null) return false + const velocity = this.getReleaseVelocity() const visibleSize = contentSize - this.dragOffset // Fast swipe in opening direction (negative velocity = opening) - const isFastSwipe = this.velocity < 0 && Math.abs(this.velocity) >= swipeVelocityThreshold + const isFastSwipe = velocity < 0 && Math.abs(velocity) >= swipeVelocityThreshold // Dragged past threshold const hasEnoughDisplacement = visibleSize >= contentSize * openThreshold @@ -241,10 +351,11 @@ export class DragManager { const MIN_SCALAR = 0.1 const MAX_SCALAR = 1 - if (this.dragOffset === null || this.velocity === null) return MAX_SCALAR + if (this.dragOffset === null) return MAX_SCALAR + const velocity = this.getReleaseVelocity() const distance = Math.abs(this.dragOffset - targetOffset) - const absVelocity = Math.abs(this.velocity) + const absVelocity = Math.abs(velocity) if (absVelocity <= 0 || distance <= 0) return MAX_SCALAR @@ -253,32 +364,28 @@ export class DragManager { return MIN_SCALAR + normalized * (MAX_SCALAR - MIN_SCALAR) } - shouldDismiss( - contentSize: number | null, - snapPoints: ResolvedSnapPoint[], - swipeVelocityThreshold: number, - closeThreshold: number, - ): boolean { - if (this.dragOffset === null || this.velocity === null || contentSize === null) return false + shouldDismiss(contentSize: number | null, snapPoints: ResolvedSnapPoint[]): boolean { + if (this.dragOffset === null || contentSize === null) return false + const velocity = this.getReleaseVelocity() const visibleSize = contentSize - this.dragOffset - const smallestSnapPoint = snapPoints.reduce((acc, curr) => (curr.offset > acc.offset ? curr : acc)) - const isFastSwipe = this.velocity > 0 && this.velocity >= swipeVelocityThreshold + if (visibleSize <= 0) return true - const closeThresholdInPixels = contentSize * (1 - closeThreshold) - const smallestSnapPointVisibleSize = contentSize - smallestSnapPoint.offset - const isBelowSmallestSnapPoint = visibleSize < smallestSnapPointVisibleSize - const isBelowCloseThreshold = visibleSize < closeThresholdInPixels - - // With multiple snap points, prefer snapping during regular drags, - // but still allow a fast swipe down to dismiss from below the lowest snap point. - if (snapPoints.length > 1) { - return visibleSize <= 0 || (isFastSwipe && isBelowSmallestSnapPoint) + // Apply velocity to target offset (same as findClosestSnapPoint) + let targetOffset = this.dragOffset + if (Math.abs(velocity) >= SNAP_VELOCITY_THRESHOLD) { + const clamped = Math.min(MAX_SNAP_VELOCITY, Math.max(-MAX_SNAP_VELOCITY, velocity)) + targetOffset += clamped * SNAP_VELOCITY_MULTIPLIER + targetOffset = Math.max(0, targetOffset) } - const hasEnoughDragToDismiss = (isBelowCloseThreshold && isBelowSmallestSnapPoint) || visibleSize === 0 + // Compare: is the velocity-adjusted target closer to "fully closed" + // than to any snap point? If so, dismiss. + const closeDistance = Math.abs(targetOffset - contentSize) + const closest = findClosestSnapPoint(targetOffset, snapPoints) + const snapDistance = Math.abs(targetOffset - closest.offset) - return (isFastSwipe && isBelowSmallestSnapPoint) || hasEnoughDragToDismiss + return closeDistance < snapDistance } } diff --git a/shared/src/controls.ts b/shared/src/controls.ts index b3e8463ee6..86cc7f68f2 100644 --- a/shared/src/controls.ts +++ b/shared/src/controls.ts @@ -358,8 +358,8 @@ export const passwordInputControls = defineControls({ }) export const drawerControls = defineControls({ - swipeVelocityThreshold: { type: "number", defaultValue: 700 }, - closeThreshold: { type: "number", defaultValue: 0.25 }, + swipeVelocityThreshold: { type: "number", defaultValue: 500 }, + closeThreshold: { type: "number", defaultValue: 0.5 }, preventDragOnScroll: { type: "boolean", defaultValue: true }, swipeDirection: { type: "select", options: ["down", "up", "left", "right"] as const, defaultValue: "down" }, snapToSequentialPoints: { type: "boolean", defaultValue: false }, From 996784ee56044b55a8056308e4ce620b94b969a9 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Fri, 20 Mar 2026 18:39:42 +0200 Subject: [PATCH 4/7] refactor: finetune drawer --- examples/next-ts/pages/drawer/range-input.tsx | 75 +++++++ .../machines/drawer/src/drawer.connect.ts | 102 +++++---- packages/machines/drawer/src/drawer.dom.ts | 13 +- .../machines/drawer/src/drawer.machine.ts | 204 +++++++++--------- .../drawer/src/utils/deferred-pointer.ts | 87 ++++++++ .../machines/drawer/src/utils/drag-manager.ts | 89 ++++---- .../src/utils/find-closest-snap-point.ts | 9 - .../drawer/src/utils/get-scroll-info.ts | 126 +++++++++-- .../drawer/src/utils/is-drag-exempt-target.ts | 76 +++++++ .../drawer/src/utils/release-velocity.ts | 52 +++++ .../drawer/src/utils/resolve-snap-point.ts | 51 ----- .../machines/drawer/src/utils/snap-point.ts | 80 +++++++ packages/machines/drawer/src/utils/swipe.ts | 51 +++++ packages/utilities/dom-query/src/node.ts | 11 +- shared/src/routes.ts | 1 + 15 files changed, 764 insertions(+), 263 deletions(-) create mode 100644 examples/next-ts/pages/drawer/range-input.tsx create mode 100644 packages/machines/drawer/src/utils/deferred-pointer.ts delete mode 100644 packages/machines/drawer/src/utils/find-closest-snap-point.ts create mode 100644 packages/machines/drawer/src/utils/is-drag-exempt-target.ts create mode 100644 packages/machines/drawer/src/utils/release-velocity.ts delete mode 100644 packages/machines/drawer/src/utils/resolve-snap-point.ts create mode 100644 packages/machines/drawer/src/utils/snap-point.ts create mode 100644 packages/machines/drawer/src/utils/swipe.ts 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/packages/machines/drawer/src/drawer.connect.ts b/packages/machines/drawer/src/drawer.connect.ts index cf68714e14..de892d7380 100644 --- a/packages/machines/drawer/src/drawer.connect.ts +++ b/packages/machines/drawer/src/drawer.connect.ts @@ -1,20 +1,12 @@ -import { getEventPoint, getEventTarget, isLeftClick } from "@zag-js/dom-query" +import { getEventPoint, isLeftClick } from "@zag-js/dom-query" import type { JSX, NormalizeProps, PropTypes } from "@zag-js/types" import { toPx } from "@zag-js/utils" import { parts } from "./drawer.anatomy" import * as dom from "./drawer.dom" import type { DrawerApi, DrawerService } from "./drawer.types" - -const isVerticalDirection = (direction: DrawerApi["swipeDirection"]) => direction === "down" || direction === "up" - -const isNegativeDirection = (direction: DrawerApi["swipeDirection"]) => direction === "up" || direction === "left" - -const oppositeDirection: Record = { - up: "down", - down: "up", - left: "right", - right: "left", -} +import { cancelDeferPointerDown, deferPointerDown } from "./utils/deferred-pointer" +import { isTextSelectionInDrawer, shouldIgnorePointerDownForDrag } from "./utils/is-drag-exempt-target" +import { isNegativeSwipeDirection, isVerticalSwipeDirection, oppositeSwipeDirection } from "./utils/swipe" export function connect(service: DrawerService, normalize: NormalizeProps): DrawerApi { const { state, send, context, scope, prop } = service @@ -22,35 +14,71 @@ export function connect(service: DrawerService, normalize: const open = state.hasTag("open") const closed = state.matches("closed") const swipingOpen = state.matches("swiping-open") + const dragOffset = context.get("dragOffset") const dragging = dragOffset !== null const snapPoint = context.get("snapPoint") - const resolvedActiveSnapPoint = context.get("resolvedActiveSnapPoint") const swipeDirection = prop("swipeDirection") const contentSize = context.get("contentSize") const swipeStrength = context.get("swipeStrength") + + const resolvedActiveSnapPoint = context.get("resolvedActiveSnapPoint") const snapPointOffset = resolvedActiveSnapPoint?.offset ?? 0 + const swipeOpenFallbackOffset = swipingOpen && dragOffset === null ? (contentSize ?? 9999) : 0 const currentOffset = dragOffset ?? (snapPointOffset || swipeOpenFallbackOffset) - const swipeMovement = dragging || swipingOpen ? currentOffset - snapPointOffset : 0 - const signedCurrentOffset = isNegativeDirection(swipeDirection) ? -currentOffset : currentOffset - const signedSnapPointOffset = isNegativeDirection(swipeDirection) ? -snapPointOffset : snapPointOffset - const signedMovement = isNegativeDirection(swipeDirection) ? -swipeMovement : swipeMovement + const signedSnapPointOffset = isNegativeSwipeDirection(swipeDirection) ? -snapPointOffset : snapPointOffset + const isActivelySwiping = dragging || swipingOpen + const swipeMovement = dragging || swipingOpen ? currentOffset - snapPointOffset : 0 + const signedMovement = isNegativeSwipeDirection(swipeDirection) ? -swipeMovement : swipeMovement const swipeProgress = isActivelySwiping && contentSize && contentSize > 0 ? Math.max(0, Math.min(1, Math.abs(signedMovement) / contentSize)) : swipingOpen ? 1 // fully closed (transparent backdrop) until contentSize is measured : 0 - const translateX = isVerticalDirection(swipeDirection) ? 0 : signedCurrentOffset - const translateY = isVerticalDirection(swipeDirection) ? signedCurrentOffset : 0 - function onPointerDown(event: JSX.PointerEvent) { - if (!isLeftClick(event)) return - const target = getEventTarget(event) - if (target?.hasAttribute("data-no-drag") || target?.closest("[data-no-drag]")) return + const signedCurrentOffset = isNegativeSwipeDirection(swipeDirection) ? -currentOffset : currentOffset + const translateX = isVerticalSwipeDirection(swipeDirection) ? 0 : signedCurrentOffset + const translateY = isVerticalSwipeDirection(swipeDirection) ? signedCurrentOffset : 0 + + /** + * Sheet body: mouse/pen defer POINTER_DOWN until movement reads as a sheet-axis drag so + * click–drag to select text does not arm the drawer on the first pixel of movement. + */ + function onContentPointerDown(event: JSX.PointerEvent) { + if (shouldIgnorePointerDownForDrag(event)) return + const content = dom.getContentEl(scope) + if (isTextSelectionInDrawer(scope.getDoc(), content)) return + if (state.matches("closing")) return + + const point = getEventPoint(event) + const defer = event.pointerType === "mouse" || event.pointerType === "pen" + if (defer) { + deferPointerDown({ + scope, + onCommit(point) { + send({ type: "POINTER_DOWN", point }) + }, + canCommitPointerDown() { + return state.hasTag("open") && !state.matches("closing") + }, + swipeDirection, + pointerId: event.pointerId, + startPoint: point, + }) + return + } + + send({ type: "POINTER_DOWN", point }) + } + + /** Grabber: immediate POINTER_DOWN; cancel any deferred content gesture; ignore text-selection gate. */ + function onGrabberPointerDown(event: JSX.PointerEvent) { + if (shouldIgnorePointerDownForDrag(event)) return + cancelDeferPointerDown(scope) if (state.matches("closing")) return send({ type: "POINTER_DOWN", point: getEventPoint(event) }) } @@ -74,17 +102,13 @@ export function connect(service: DrawerService, normalize: }, getOpenPercentage() { - if (!open) return 0 - - if (!contentSize) return 0 - + if (!open || !contentSize) return 0 return Math.max(0, Math.min(1, 1 - currentOffset / contentSize)) }, getSnapPointIndex() { - const snapPoints = prop("snapPoints") if (snapPoint === null) return -1 - return snapPoints.indexOf(snapPoint) + return prop("snapPoints").indexOf(snapPoint) }, getContentSize() { @@ -106,8 +130,8 @@ export function connect(service: DrawerService, normalize: }, getContentProps(props = { draggable: true }) { - const movementX = isVerticalDirection(swipeDirection) ? 0 : signedMovement - const movementY = isVerticalDirection(swipeDirection) ? signedMovement : 0 + const movementX = isVerticalSwipeDirection(swipeDirection) ? 0 : signedMovement + const movementY = isVerticalSwipeDirection(swipeDirection) ? signedMovement : 0 const rendered = context.get("rendered") return normalize.element({ @@ -133,8 +157,12 @@ export function connect(service: DrawerService, normalize: "--drawer-translate": toPx(translateY), "--drawer-translate-x": toPx(translateX), "--drawer-translate-y": toPx(translateY), - "--drawer-snap-point-offset-x": isVerticalDirection(swipeDirection) ? "0px" : toPx(signedSnapPointOffset), - "--drawer-snap-point-offset-y": isVerticalDirection(swipeDirection) ? toPx(signedSnapPointOffset) : "0px", + "--drawer-snap-point-offset-x": isVerticalSwipeDirection(swipeDirection) + ? "0px" + : toPx(signedSnapPointOffset), + "--drawer-snap-point-offset-y": isVerticalSwipeDirection(swipeDirection) + ? toPx(signedSnapPointOffset) + : "0px", "--drawer-swipe-movement-x": toPx(movementX), "--drawer-swipe-movement-y": toPx(movementY), "--drawer-swipe-strength": `${swipeStrength}`, @@ -142,7 +170,7 @@ export function connect(service: DrawerService, normalize: }, onPointerDown(event) { if (!props.draggable) return - onPointerDown(event) + onContentPointerDown(event) }, }) }, @@ -194,7 +222,7 @@ export function connect(service: DrawerService, normalize: ...parts.grabber.attrs, id: dom.getGrabberId(scope), onPointerDown(event) { - onPointerDown(event) + onGrabberPointerDown(event) }, style: { touchAction: "none", @@ -221,7 +249,7 @@ export function connect(service: DrawerService, normalize: getSwipeAreaProps(props = {}) { const disabled = props.disabled ?? false - const openDirection = props.swipeDirection ?? oppositeDirection[swipeDirection] + const openDirection = props.swipeDirection ?? oppositeSwipeDirection[swipeDirection] return normalize.element({ ...parts.swipeArea.attrs, @@ -233,7 +261,7 @@ export function connect(service: DrawerService, normalize: "data-swipe-direction": openDirection, "data-disabled": disabled ? "" : undefined, style: { - touchAction: isVerticalDirection(openDirection) ? "pan-x" : "pan-y", + touchAction: isVerticalSwipeDirection(openDirection) ? "pan-x" : "pan-y", pointerEvents: disabled || (open && !swipingOpen) ? "none" : undefined, }, onPointerDown(event) { diff --git a/packages/machines/drawer/src/drawer.dom.ts b/packages/machines/drawer/src/drawer.dom.ts index 857816013b..b0ce78b911 100644 --- a/packages/machines/drawer/src/drawer.dom.ts +++ b/packages/machines/drawer/src/drawer.dom.ts @@ -1,5 +1,5 @@ import type { Scope } from "@zag-js/core" -import { queryAll } from "@zag-js/dom-query" +import { isHTMLElement, queryAll } from "@zag-js/dom-query" export const getContentId = (ctx: Scope) => ctx.ids?.content ?? `drawer:${ctx.id}:content` export const getPositionerId = (ctx: Scope) => ctx.ids?.positioner ?? `drawer:${ctx.id}:positioner` @@ -23,6 +23,17 @@ export const getHeaderEl = (ctx: Scope) => ctx.getById(getHeaderId(ctx)) export const getGrabberEl = (ctx: Scope) => ctx.getById(getGrabberId(ctx)) export const getGrabberIndicatorEl = (ctx: Scope) => ctx.getById(getGrabberIndicatorId(ctx)) export const getCloseTriggerEl = (ctx: Scope) => ctx.getById(getCloseTriggerId(ctx)) +export const getSwipeAreaEl = (ctx: Scope) => ctx.getById(getSwipeAreaId(ctx)) + +/** Whether the event target lies inside the drawer content or swipe-area subtree. */ +export function isPointerWithinContentOrSwipeArea( + target: EventTarget | null, + content: HTMLElement | null, + swipeArea: HTMLElement | null, +): boolean { + if (!isHTMLElement(target)) return false + return Boolean((content && content.contains(target)) || (swipeArea && swipeArea.contains(target))) +} export const getScrollEls = (scope: Scope) => { const els: Record<"x" | "y", HTMLElement[]> = { x: [], y: [] } diff --git a/packages/machines/drawer/src/drawer.machine.ts b/packages/machines/drawer/src/drawer.machine.ts index 7f1aa2ce38..72f26bcd94 100644 --- a/packages/machines/drawer/src/drawer.machine.ts +++ b/packages/machines/drawer/src/drawer.machine.ts @@ -3,6 +3,7 @@ import { createGuards, createMachine } from "@zag-js/core" import { trackDismissableElement } from "@zag-js/dismissable" import { addDomEvent, + getComputedStyle, getEventPoint, getEventTarget, getInitialFocus, @@ -14,47 +15,23 @@ import { trapFocus } from "@zag-js/focus-trap" import { preventBodyScroll } from "@zag-js/remove-scroll" import * as dom from "./drawer.dom" import { drawerRegistry } from "./drawer.registry" -import type { DrawerSchema, ResolvedSnapPoint, SwipeDirection } from "./drawer.types" +import type { DrawerSchema, ResolvedSnapPoint } from "./drawer.types" import { DragManager } from "./utils/drag-manager" -import { resolveSnapPoint } from "./utils/resolve-snap-point" +import { + findClosestScrollableAncestorOnSwipeAxis, + shouldPreventTouchDefaultForDrawerPull, +} from "./utils/get-scroll-info" +import { isDragExemptElement } from "./utils/is-drag-exempt-target" +import { dedupeSnapPoints, resolveSnapPoint } from "./utils/snap-point" +import { + getSwipeDirectionSize, + hasOpeningSwipeIntent, + isVerticalSwipeDirection, + resolveSwipeProgress, +} from "./utils/swipe" const { and } = createGuards() -const isVerticalDirection = (direction: SwipeDirection) => direction === "down" || direction === "up" - -function dedupeSnapPoints(points: ResolvedSnapPoint[]) { - if (points.length <= 1) return points - - const deduped: ResolvedSnapPoint[] = [] - const seenHeights: number[] = [] - - for (let index = points.length - 1; index >= 0; index -= 1) { - const point = points[index] - const isDuplicate = seenHeights.some((height) => Math.abs(height - point.height) <= 1) - if (isDuplicate) continue - - seenHeights.push(point.height) - deduped.push(point) - } - - deduped.reverse() - return deduped -} - -function getDirectionSize(rect: DOMRect, direction: SwipeDirection) { - return isVerticalDirection(direction) ? rect.height : rect.width -} - -function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)) -} - -function resolveSwipeProgress(contentSize: number | null, dragOffset: number | null, snapPointOffset: number) { - if (!contentSize || contentSize <= 0) return 0 - const currentOffset = dragOffset ?? snapPointOffset - return clamp(1 - currentOffset / contentSize, 0, 1) -} - export const machine = createMachine({ props({ props, scope }) { const alertDialog = props.role === "alertdialog" @@ -130,13 +107,7 @@ export const machine = createMachine({ if (contentSize === null) return [] const points = prop("snapPoints") - .map((snapPoint) => - resolveSnapPoint(snapPoint, { - popupSize: contentSize, - viewportSize, - rootFontSize, - }), - ) + .map((snapPoint) => resolveSnapPoint(snapPoint, { contentSize, viewportSize, rootFontSize })) .filter((point): point is ResolvedSnapPoint => point !== null) return dedupeSnapPoints(points) @@ -170,11 +141,7 @@ export const machine = createMachine({ return } - const resolvedActiveSnapPoint = resolveSnapPoint(snapPoint, { - popupSize: contentSize, - viewportSize, - rootFontSize, - }) + const resolvedActiveSnapPoint = resolveSnapPoint(snapPoint, { contentSize, viewportSize, rootFontSize }) if (resolvedActiveSnapPoint) { context.set("resolvedActiveSnapPoint", resolvedActiveSnapPoint) @@ -229,6 +196,7 @@ export const machine = createMachine({ "trapFocus", "hideContentBelow", "trackPointerMove", + "trackGestureInterruption", "trackSizeMeasurements", "trackNestedDrawerMetrics", "trackDrawerStack", @@ -283,6 +251,15 @@ export const machine = createMachine({ actions: ["clearRegistrySwiping", "clearPointerStart", "clearDragOffset", "clearVelocityTracking"], }, ], + POINTER_CANCEL: [ + { + guard: "isDragging", + actions: ["clearRegistrySwiping", "clearPointerStart", "clearDragOffset", "clearVelocityTracking"], + }, + { + actions: ["clearRegistrySwiping", "clearPointerStart", "clearVelocityTracking"], + }, + ], CLOSE: [ { guard: "isOpenControlled", @@ -316,7 +293,7 @@ export const machine = createMachine({ "swipe-area-dragging": { tags: ["closed"], - effects: ["trackSwipeOpenPointerMove"], + effects: ["trackSwipeOpenPointerMove", "trackGestureInterruption"], on: { POINTER_MOVE: { guard: "hasSwipeIntent", @@ -326,12 +303,16 @@ export const machine = createMachine({ target: "closed", actions: ["clearPointerStart", "clearVelocityTracking"], }, + POINTER_CANCEL: { + target: "closed", + actions: ["clearPointerStart", "clearVelocityTracking"], + }, }, }, "swiping-open": { tags: ["open"], - effects: ["trackSwipeOpenPointerMove", "trackSizeMeasurements"], + effects: ["trackSwipeOpenPointerMove", "trackSizeMeasurements", "trackGestureInterruption"], on: { POINTER_MOVE: { actions: ["setSwipeOpenOffset"], @@ -351,6 +332,10 @@ export const machine = createMachine({ actions: ["clearPointerStart", "clearDragOffset", "clearSizeMeasurements"], }, ], + POINTER_CANCEL: { + target: "closed", + actions: ["clearPointerStart", "clearDragOffset", "clearSizeMeasurements", "clearVelocityTracking"], + }, "CONTROLLED.OPEN": { target: "open", }, @@ -395,6 +380,7 @@ export const machine = createMachine({ shouldStartDragging({ prop, refs, event, scope }) { if (!isHTMLElement(event.target)) return false + if (isDragExemptElement(event.target)) return false const dragManager = refs.get("dragManager") return dragManager.shouldStartDragging( @@ -410,20 +396,18 @@ export const machine = createMachine({ // In sequential mode, let findClosestSnapPoint handle dismiss decisions if (prop("snapToSequentialPoints")) return false const dragManager = refs.get("dragManager") - return dragManager.shouldDismiss(context.get("contentSize"), computed("resolvedSnapPoints")) + return dragManager.shouldDismiss( + context.get("contentSize"), + computed("resolvedSnapPoints"), + context.get("resolvedActiveSnapPoint")?.offset ?? 0, + ) }, hasSwipeIntent({ refs, prop, event }) { const dragManager = refs.get("dragManager") const start = dragManager.getPointerStart() if (!start || !event.point) return false - const direction = prop("swipeDirection") - const isVertical = isVerticalDirection(direction) - const sign = direction === "up" || direction === "left" ? -1 : 1 - const axis = isVertical ? "y" : "x" - // Opening direction is opposite to dismiss direction - const displacement = (start[axis] - event.point[axis]) * sign - return displacement > 5 + return hasOpeningSwipeIntent(start, event.point, prop("swipeDirection")) }, shouldOpenOnSwipe({ context, refs, prop }) { @@ -548,11 +532,7 @@ export const machine = createMachine({ context.set("snapPoint", closestSnapPoint) - const resolved = resolveSnapPoint(closestSnapPoint, { - popupSize: contentSize, - viewportSize, - rootFontSize, - }) + const resolved = resolveSnapPoint(closestSnapPoint, { contentSize, viewportSize, rootFontSize }) context.set("resolvedActiveSnapPoint", resolved) }, @@ -597,17 +577,19 @@ export const machine = createMachine({ const viewportSize = context.get("viewportSize") const rootFontSize = context.get("rootFontSize") const resolved = resolveSnapPoint(closestSnapPoint, { - popupSize: contentSize ?? 0, + contentSize: contentSize ?? 0, viewportSize, rootFontSize, }) - context.set("swipeStrength", dragManager.computeSwipeStrength(resolved?.offset ?? 0)) + const restOffset = context.get("resolvedActiveSnapPoint")?.offset ?? 0 + context.set("swipeStrength", dragManager.computeSwipeStrength(resolved?.offset ?? 0, restOffset)) }, setDismissSwipeStrength({ context, refs }) { const dragManager = refs.get("dragManager") const contentSize = context.get("contentSize") - context.set("swipeStrength", dragManager.computeSwipeStrength(contentSize ?? 0)) + const restOffset = context.get("resolvedActiveSnapPoint")?.offset ?? 0 + context.set("swipeStrength", dragManager.computeSwipeStrength(contentSize ?? 0, restOffset)) }, resetSwipeStrength({ context }) { @@ -721,10 +703,35 @@ export const machine = createMachine({ return ariaHidden(getElements, { defer: true }) }, + trackGestureInterruption({ scope, send }) { + const doc = scope.getDoc() + + function onVisibilityChange() { + if (doc.visibilityState === "hidden") { + send({ type: "POINTER_CANCEL" }) + } + } + + function onLostPointerCapture(event: PointerEvent) { + const target = getEventTarget(event) + if (!dom.isPointerWithinContentOrSwipeArea(target, dom.getContentEl(scope), dom.getSwipeAreaEl(scope))) return + send({ type: "POINTER_CANCEL" }) + } + + const cleanups = [ + addDomEvent(doc, "visibilitychange", onVisibilityChange), + addDomEvent(doc, "lostpointercapture", onLostPointerCapture, true), + ] + + return () => { + cleanups.forEach((cleanup) => cleanup()) + } + }, + trackPointerMove({ scope, send, prop }) { let lastAxis = 0 const swipeDirection = prop("swipeDirection") - const isVertical = isVerticalDirection(swipeDirection) + const isVertical = isVerticalSwipeDirection(swipeDirection) function onPointerMove(event: PointerEvent) { // Touch is handled by touchmove/touchend only. Feeding both pointer and touch @@ -743,8 +750,7 @@ export const machine = createMachine({ function onPointerCancel(event: PointerEvent) { if (event.pointerType === "touch") return - const point = getEventPoint(event) - send({ type: "POINTER_UP", point }) + send({ type: "POINTER_CANCEL" }) } function onTouchStart(event: TouchEvent) { @@ -766,27 +772,24 @@ export const machine = createMachine({ const contentEl = dom.getContentEl(scope) // Never drop touch moves when content isn't resolved yet — pointermove has no such gate, // and missing moves on mobile breaks drag + sequential snap release. - if (contentEl) { - let el: HTMLElement | null = target - while ( - el && - el !== contentEl && - (isVertical ? el.scrollHeight <= el.clientHeight : el.scrollWidth <= el.clientWidth) - ) { - el = el.parentElement - } - - if (el && el !== contentEl) { - const scrollPos = isVertical ? el.scrollTop : el.scrollLeft + if (contentEl && !isDragExemptElement(target)) { + const scrollParent = findClosestScrollableAncestorOnSwipeAxis(target, contentEl, swipeDirection) + if (scrollParent) { const axis = isVertical ? event.touches[0].clientY : event.touches[0].clientX - - const atStart = scrollPos <= 0 - const movingTowardStart = axis > lastAxis - if (atStart && movingTowardStart) { + if ( + shouldPreventTouchDefaultForDrawerPull({ + scrollParent, + swipeDirection, + lastMainAxis: lastAxis, + currentMainAxis: axis, + }) && + event.cancelable + ) { event.preventDefault() } - lastAxis = axis + } else { + lastAxis = isVertical ? event.touches[0].clientY : event.touches[0].clientX } } @@ -799,22 +802,18 @@ export const machine = createMachine({ send({ type: "POINTER_UP", point }) } - function onTouchCancel(event: TouchEvent) { - const point = getEventPoint(event) - send({ type: "POINTER_UP", point }) + function onTouchCancel() { + send({ type: "POINTER_CANCEL" }) } const doc = scope.getDoc() - // Capture phase + non-passive touchmove so we run before nested scrollables / iOS - // default behaviors, consistent with robust drawer touch handling elsewhere. - const touchOpts = { capture: true, passive: false } as const const cleanups = [ addDomEvent(doc, "pointermove", onPointerMove), addDomEvent(doc, "pointerup", onPointerUp), addDomEvent(doc, "pointercancel", onPointerCancel), - addDomEvent(doc, "touchstart", onTouchStart, touchOpts), - addDomEvent(doc, "touchmove", onTouchMove, touchOpts), + addDomEvent(doc, "touchstart", onTouchStart, { capture: true, passive: false }), + addDomEvent(doc, "touchmove", onTouchMove, { capture: true, passive: false }), addDomEvent(doc, "touchend", onTouchEnd, { capture: true }), addDomEvent(doc, "touchcancel", onTouchCancel, { capture: true }), ] @@ -834,10 +833,10 @@ export const machine = createMachine({ const updateSize = () => { const direction = prop("swipeDirection") const rect = contentEl.getBoundingClientRect() - const viewportSize = isVerticalDirection(direction) ? html.clientHeight : html.clientWidth + const viewportSize = isVerticalSwipeDirection(direction) ? html.clientHeight : html.clientWidth const rootFontSize = Number.parseFloat(getComputedStyle(html).fontSize) - context.set("contentSize", getDirectionSize(rect, direction)) + context.set("contentSize", getSwipeDirectionSize(rect, direction)) context.set("viewportSize", viewportSize) if (Number.isFinite(rootFontSize)) { context.set("rootFontSize", rootFontSize) @@ -916,6 +915,11 @@ export const machine = createMachine({ send({ type: "POINTER_UP", point: getEventPoint(event) }) } + function onPointerCancelSwipeOpen(event: PointerEvent) { + if (event.pointerType === "touch") return + send({ type: "POINTER_CANCEL" }) + } + function onTouchMove(event: TouchEvent) { if (!event.touches[0]) return send({ type: "POINTER_MOVE", point: getEventPoint(event) }) @@ -926,11 +930,17 @@ export const machine = createMachine({ send({ type: "POINTER_UP", point: getEventPoint(event) }) } + function onTouchCancelSwipeOpen() { + send({ type: "POINTER_CANCEL" }) + } + const cleanups = [ addDomEvent(doc, "pointermove", onPointerMove), addDomEvent(doc, "pointerup", onPointerUp), + addDomEvent(doc, "pointercancel", onPointerCancelSwipeOpen), addDomEvent(doc, "touchmove", onTouchMove, { passive: false }), addDomEvent(doc, "touchend", onTouchEnd), + addDomEvent(doc, "touchcancel", onTouchCancelSwipeOpen, { capture: true }), ] return () => { diff --git a/packages/machines/drawer/src/utils/deferred-pointer.ts b/packages/machines/drawer/src/utils/deferred-pointer.ts new file mode 100644 index 0000000000..6199e7f2ac --- /dev/null +++ b/packages/machines/drawer/src/utils/deferred-pointer.ts @@ -0,0 +1,87 @@ +import type { Scope } from "@zag-js/core" +import { addDomEvent } from "@zag-js/dom-query" +import type { Point } from "@zag-js/types" +import type { SwipeDirection } from "../drawer.types" +import { isVerticalSwipeDirection } from "./swipe" + +const DEFERRED_DRAG_MIN_MAIN_AXIS_PX = 6 +/** Require main-axis movement to clearly dominate cross-axis (horizontal text drag stays off sheet drag). */ +const DEFERRED_DRAG_MAIN_OVER_CROSS_RATIO = 1.35 + +interface PendingPointer { + pointerId: number + startPoint: Point + cleanups: Array<() => void> +} + +const pendingByScope = new WeakMap() + +function clearPending(scope: Scope) { + const p = pendingByScope.get(scope) + if (!p) return + p.cleanups.forEach((fn) => fn()) + pendingByScope.delete(scope) +} + +/** + * Cancel any in-progress deferred content pointer-down (e.g. new gesture starting). + */ +export function cancelDeferPointerDown(scope: Scope) { + clearPending(scope) +} + +export interface DeferPointerDownOptions { + scope: Scope + /** Called when movement reads as a sheet-axis drag; wire to `send({ type: "POINTER_DOWN", point })` at the call site. */ + onCommit: (point: Point) => void + /** Drawer must still be open and not in closing animation before arming drag. */ + canCommitPointerDown: () => boolean + swipeDirection: SwipeDirection + pointerId: number + startPoint: Point +} + +/** + * For mouse/pen on sheet content: wait until movement is clearly a sheet-axis drag before calling `onCommit`, + * so click-drag to select text does not arm the drawer on the first pixel of movement. + */ +export function deferPointerDown(options: DeferPointerDownOptions): void { + const { scope, onCommit, canCommitPointerDown, swipeDirection, pointerId, startPoint } = options + + clearPending(scope) + + const win = scope.getWin() + const vertical = isVerticalSwipeDirection(swipeDirection) + + function onMove(event: PointerEvent) { + if (event.pointerId !== pointerId) return + + const dx = event.clientX - startPoint.x + const dy = event.clientY - startPoint.y + const mainDelta = vertical ? dy : dx + const crossDelta = vertical ? dx : dy + const absMain = Math.abs(mainDelta) + const absCross = Math.abs(crossDelta) + + if (absMain >= DEFERRED_DRAG_MIN_MAIN_AXIS_PX && absMain >= absCross * DEFERRED_DRAG_MAIN_OVER_CROSS_RATIO) { + if (canCommitPointerDown()) { + onCommit(startPoint) + } + clearPending(scope) + } + } + + function onEnd(event: PointerEvent) { + if (event.pointerId !== pointerId) return + clearPending(scope) + } + + const cleanups = [ + addDomEvent(win, "pointermove", onMove, { capture: true }), + addDomEvent(win, "pointerup", onEnd, { capture: true }), + addDomEvent(win, "pointercancel", onEnd, { capture: true }), + addDomEvent(win, "lostpointercapture", onEnd, { capture: true }), + ] + + pendingByScope.set(scope, { pointerId, startPoint, cleanups }) +} diff --git a/packages/machines/drawer/src/utils/drag-manager.ts b/packages/machines/drawer/src/utils/drag-manager.ts index 237f6d963b..9df760ea57 100644 --- a/packages/machines/drawer/src/utils/drag-manager.ts +++ b/packages/machines/drawer/src/utils/drag-manager.ts @@ -1,20 +1,28 @@ import type { Point } from "@zag-js/types" import type { ResolvedSnapPoint, SwipeDirection } from "../drawer.types" -import { findClosestSnapPoint } from "./find-closest-snap-point" import { getScrollInfo } from "./get-scroll-info" +import { adjustReleaseVelocityAgainstDisplacement, adjustReleaseVelocityForOpenSwipe } from "./release-velocity" +import { findClosestSnapPoint } from "./snap-point" +import { isVerticalSwipeDirection } from "./swipe" const DRAG_START_THRESHOLD = 0.3 +/** Treat slightly diagonal moves as cross-axis–biased so nested horizontal scroll wins more often. */ +const CROSS_AXIS_BIAS = 0.58 +const SCROLL_SLACK_GATE = 0.5 const SEQUENTIAL_THRESHOLD = 24 const SNAP_VELOCITY_THRESHOLD = 400 // px/s const SNAP_VELOCITY_MULTIPLIER = 0.4 // seconds const MAX_SNAP_VELOCITY = 4000 // px/s -// Velocity tracking: sliding window for smooth, reliable velocity -const VELOCITY_WINDOW_MS = 100 // consider samples from the last 100ms -const MAX_RELEASE_VELOCITY_AGE_MS = 80 // max age for recent velocity to be valid -const MIN_GESTURE_DURATION_MS = 50 // min duration for overall gesture velocity +const VELOCITY_WINDOW_MS = 100 +const MAX_RELEASE_VELOCITY_AGE_MS = 80 +const MIN_GESTURE_DURATION_MS = 50 const MIN_VELOCITY_SAMPLES = 2 +const SWIPE_STRENGTH_MAX_DURATION_MS = 360 +const SWIPE_STRENGTH_MIN_SCALAR = 0.1 +const SWIPE_STRENGTH_MAX_SCALAR = 1 + interface VelocitySample { axis: number time: number @@ -179,21 +187,27 @@ export class DragManager { if (Math.abs(delta) < DRAG_START_THRESHOLD) return false - // If movement is primarily cross-axis, preserve native scrolling - const isVertical = direction === "down" || direction === "up" + // If movement leans cross-axis, preserve native scrolling on that axis + const isVertical = isVerticalSwipeDirection(direction) const crossDelta = isVertical ? Math.abs(point.x - this.pointerStart.x) : Math.abs(point.y - this.pointerStart.y) - if (crossDelta > Math.abs(delta)) { + if (crossDelta > Math.abs(delta) * CROSS_AXIS_BIAS) { const crossDirection: SwipeDirection = isVertical ? "right" : "down" const crossScroll = getScrollInfo(target, container, crossDirection) - if (crossScroll.availableForwardScroll > 1 || crossScroll.availableBackwardScroll > 0) { + if ( + crossScroll.availableForwardScroll > SCROLL_SLACK_GATE || + crossScroll.availableBackwardScroll > SCROLL_SLACK_GATE + ) { return false } } const { availableForwardScroll, availableBackwardScroll } = getScrollInfo(target, container, direction) - if ((delta > 0 && Math.abs(availableForwardScroll) > 1) || (delta < 0 && Math.abs(availableBackwardScroll) > 0)) { + if ( + (delta > 0 && availableForwardScroll > SCROLL_SLACK_GATE) || + (delta < 0 && availableBackwardScroll > SCROLL_SLACK_GATE) + ) { return false } } @@ -211,8 +225,6 @@ export class DragManager { return snapPoints[0]?.value ?? 1 } - const velocity = this.getReleaseVelocity() - if (snapToSequentialPoints && snapPoint) { // Sort by offset so index navigation matches drag direction // (offset 0 = fully open at index 0, highest offset = most closed at end) @@ -234,21 +246,8 @@ export class DragManager { const currentPoint = ordered[currentIndex] const delta = this.dragOffset - currentPoint.offset const dragDirection = Math.sign(delta) - let velocityDirection = Math.sign(velocity) - - // Touch releases often end with a short opposing velocity tick (rubber-band / OS), - // which would block velocity-based advance. If the drag offset clearly moved in one - // direction, ignore that reversal for advance only (Base UI aligns similarly). - let velocityForAdvance = velocity - if ( - dragDirection !== 0 && - Math.abs(delta) >= SEQUENTIAL_THRESHOLD && - velocityDirection !== 0 && - velocityDirection !== dragDirection - ) { - velocityForAdvance = 0 - velocityDirection = 0 - } + const velocityAdjusted = adjustReleaseVelocityAgainstDisplacement(this.getReleaseVelocity(), delta) + const velocityDirection = Math.sign(velocityAdjusted) let targetSnapPoint = currentPoint let effectiveTargetOffset = this.dragOffset @@ -257,7 +256,7 @@ export class DragManager { const shouldAdvance = dragDirection !== 0 && velocityDirection === dragDirection && - Math.abs(velocityForAdvance) >= SNAP_VELOCITY_THRESHOLD + Math.abs(velocityAdjusted) >= SNAP_VELOCITY_THRESHOLD if (shouldAdvance) { const adjacentIndex = Math.min(Math.max(currentIndex + dragDirection, 0), ordered.length - 1) @@ -298,6 +297,11 @@ export class DragManager { } // Non-sequential: apply velocity offset for momentum-based skipping + const snapRestOffset = snapPoint?.offset ?? 0 + const velocity = adjustReleaseVelocityAgainstDisplacement( + this.getReleaseVelocity(), + this.dragOffset - snapRestOffset, + ) let targetOffset = this.dragOffset if (Math.abs(velocity) >= SNAP_VELOCITY_THRESHOLD) { const clamped = Math.min(MAX_SNAP_VELOCITY, Math.max(-MAX_SNAP_VELOCITY, velocity)) @@ -334,8 +338,9 @@ export class DragManager { shouldOpen(contentSize: number | null, swipeVelocityThreshold: number, openThreshold: number): boolean { if (this.dragOffset === null || contentSize === null) return false - const velocity = this.getReleaseVelocity() const visibleSize = contentSize - this.dragOffset + const visibleRatio = visibleSize / contentSize + const velocity = adjustReleaseVelocityForOpenSwipe(this.getReleaseVelocity(), visibleRatio, swipeVelocityThreshold) // Fast swipe in opening direction (negative velocity = opening) const isFastSwipe = velocity < 0 && Math.abs(velocity) >= swipeVelocityThreshold @@ -346,28 +351,30 @@ export class DragManager { return isFastSwipe || hasEnoughDisplacement } - computeSwipeStrength(targetOffset: number): number { - const MAX_DURATION_MS = 360 - const MIN_SCALAR = 0.1 - const MAX_SCALAR = 1 + computeSwipeStrength(targetOffset: number, resolvedSnapOffset: number | null = null): number { + if (this.dragOffset === null) return SWIPE_STRENGTH_MAX_SCALAR - if (this.dragOffset === null) return MAX_SCALAR - - const velocity = this.getReleaseVelocity() + let velocity = this.getReleaseVelocity() + if (resolvedSnapOffset != null) { + velocity = adjustReleaseVelocityAgainstDisplacement(velocity, this.dragOffset - resolvedSnapOffset) + } const distance = Math.abs(this.dragOffset - targetOffset) const absVelocity = Math.abs(velocity) - if (absVelocity <= 0 || distance <= 0) return MAX_SCALAR + if (absVelocity <= 0 || distance <= 0) return SWIPE_STRENGTH_MAX_SCALAR const estimatedTimeMs = (distance / absVelocity) * 1000 - const normalized = Math.min(1, Math.max(0, estimatedTimeMs / MAX_DURATION_MS)) - return MIN_SCALAR + normalized * (MAX_SCALAR - MIN_SCALAR) + const normalized = Math.min(1, Math.max(0, estimatedTimeMs / SWIPE_STRENGTH_MAX_DURATION_MS)) + return SWIPE_STRENGTH_MIN_SCALAR + normalized * (SWIPE_STRENGTH_MAX_SCALAR - SWIPE_STRENGTH_MIN_SCALAR) } - shouldDismiss(contentSize: number | null, snapPoints: ResolvedSnapPoint[]): boolean { + shouldDismiss(contentSize: number | null, snapPoints: ResolvedSnapPoint[], resolvedSnapOffset: number): boolean { if (this.dragOffset === null || contentSize === null) return false - const velocity = this.getReleaseVelocity() + const velocity = adjustReleaseVelocityAgainstDisplacement( + this.getReleaseVelocity(), + this.dragOffset - resolvedSnapOffset, + ) const visibleSize = contentSize - this.dragOffset if (visibleSize <= 0) return true diff --git a/packages/machines/drawer/src/utils/find-closest-snap-point.ts b/packages/machines/drawer/src/utils/find-closest-snap-point.ts deleted file mode 100644 index 6a0f1fe6eb..0000000000 --- a/packages/machines/drawer/src/utils/find-closest-snap-point.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ResolvedSnapPoint } from "../drawer.types" - -export function findClosestSnapPoint(offset: number, snapPoints: ResolvedSnapPoint[]): ResolvedSnapPoint { - return snapPoints.reduce((acc, curr) => { - const closestDiff = Math.abs(offset - acc.offset) - const currentDiff = Math.abs(offset - curr.offset) - return currentDiff < closestDiff ? curr : acc - }) -} diff --git a/packages/machines/drawer/src/utils/get-scroll-info.ts b/packages/machines/drawer/src/utils/get-scroll-info.ts index 803fa5d19b..0d58b1c5b8 100644 --- a/packages/machines/drawer/src/utils/get-scroll-info.ts +++ b/packages/machines/drawer/src/utils/get-scroll-info.ts @@ -1,41 +1,78 @@ +import { getComputedStyle } from "@zag-js/dom-query" import type { SwipeDirection } from "../drawer.types" +import { isVerticalSwipeDirection } from "./swipe" -function isVerticalScrollContainer(element: HTMLElement) { - const styles = getComputedStyle(element) - const overflow = styles.overflowY +const SCROLL_SLACK_EPSILON = 1 - return overflow === "auto" || overflow === "scroll" +function overflowAllowsScroll(overflow: string): boolean { + return overflow === "auto" || overflow === "scroll" || overflow === "overlay" } -function isHorizontalScrollContainer(element: HTMLElement) { - const styles = getComputedStyle(element) - const overflow = styles.overflowX - return overflow === "auto" || overflow === "scroll" +/** + * Whether the element can be scrolled by the user on the Y axis (overflow + overflow size). + */ +export function canScrollAlongY(el: HTMLElement): boolean { + const style = getComputedStyle(el) + if (!overflowAllowsScroll(style.overflowY)) return false + return el.scrollHeight > el.clientHeight + SCROLL_SLACK_EPSILON } -const isVerticalDirection = (direction: SwipeDirection) => direction === "up" || direction === "down" +/** + * Whether the element can be scrolled by the user on the X axis. + */ +export function canScrollAlongX(el: HTMLElement): boolean { + const style = getComputedStyle(el) + if (!overflowAllowsScroll(style.overflowX)) return false + return el.scrollWidth > el.clientWidth + SCROLL_SLACK_EPSILON +} + +export function canScrollOnSwipeAxis(el: HTMLElement, direction: SwipeDirection): boolean { + return isVerticalSwipeDirection(direction) ? canScrollAlongY(el) : canScrollAlongX(el) +} + +/** + * Closest scrollable ancestor on the swipe axis when walking up from `target` toward `container` + * (does not include `container` itself). + */ +export function findClosestScrollableAncestorOnSwipeAxis( + target: HTMLElement, + container: HTMLElement | null, + direction: SwipeDirection, +): HTMLElement | null { + if (!container) return null + let el: HTMLElement | null = target + while (el && el !== container) { + if (canScrollOnSwipeAxis(el, direction)) return el + el = el.parentElement + } + return null +} -export function getScrollInfo(target: HTMLElement, container: HTMLElement, direction: SwipeDirection) { - let element = target +/** + * Accumulated forward/backward scroll slack from `target` up to `container` on the given axis. + */ +export function getScrollInfo(target: HTMLElement, container: HTMLElement | null, direction: SwipeDirection) { let availableForwardScroll = 0 let availableBackwardScroll = 0 - const vertical = isVerticalDirection(direction) - - while (element) { - const clientSize = vertical ? element.clientHeight : element.clientWidth - const scrollPos = vertical ? element.scrollTop : element.scrollLeft - const scrollSize = vertical ? element.scrollHeight : element.scrollWidth + if (!container) { + return { availableForwardScroll, availableBackwardScroll } + } - const scrolled = scrollSize - scrollPos - clientSize - const isScrollable = vertical ? isVerticalScrollContainer(element) : isHorizontalScrollContainer(element) + const vertical = isVerticalSwipeDirection(direction) + let element: HTMLElement | null = target - if ((scrollPos !== 0 || scrolled !== 0) && isScrollable) { + while (element) { + if (vertical ? canScrollAlongY(element) : canScrollAlongX(element)) { + const clientSize = vertical ? element.clientHeight : element.clientWidth + const scrollPos = vertical ? element.scrollTop : element.scrollLeft + const scrollSize = vertical ? element.scrollHeight : element.scrollWidth + const scrolled = scrollSize - scrollPos - clientSize availableForwardScroll += scrolled availableBackwardScroll += scrollPos } - if (element === container || element === document.documentElement) break - element = element.parentNode as HTMLElement + if (element === container || element === element.ownerDocument.documentElement) break + element = element.parentElement } return { @@ -43,3 +80,48 @@ export function getScrollInfo(target: HTMLElement, container: HTMLElement, direc availableBackwardScroll, } } + +/** + * Whether a capturing `touchmove` should call `preventDefault` so the drawer can take over + * when a nested scroller is exhausted on the edge that aligns with dismiss direction. + */ +export function shouldPreventTouchDefaultForDrawerPull(options: { + scrollParent: HTMLElement + swipeDirection: SwipeDirection + lastMainAxis: number + currentMainAxis: number +}): boolean { + const { scrollParent, swipeDirection, lastMainAxis, currentMainAxis } = options + const vertical = isVerticalSwipeDirection(swipeDirection) + const movingPositive = currentMainAxis > lastMainAxis + + if (vertical) { + const scrollPos = scrollParent.scrollTop + const maxScroll = Math.max(0, scrollParent.scrollHeight - scrollParent.clientHeight) + + if (swipeDirection === "down") { + const atStart = scrollPos <= SCROLL_SLACK_EPSILON + return atStart && movingPositive + } + + if (swipeDirection === "up") { + const atEnd = scrollPos >= maxScroll - SCROLL_SLACK_EPSILON + return atEnd && !movingPositive + } + } else { + const scrollPos = scrollParent.scrollLeft + const maxScroll = Math.max(0, scrollParent.scrollWidth - scrollParent.clientWidth) + + if (swipeDirection === "right") { + const atStart = scrollPos <= SCROLL_SLACK_EPSILON + return atStart && movingPositive + } + + if (swipeDirection === "left") { + const atEnd = scrollPos >= maxScroll - SCROLL_SLACK_EPSILON + return atEnd && !movingPositive + } + } + + return false +} diff --git a/packages/machines/drawer/src/utils/is-drag-exempt-target.ts b/packages/machines/drawer/src/utils/is-drag-exempt-target.ts new file mode 100644 index 0000000000..2dd6789546 --- /dev/null +++ b/packages/machines/drawer/src/utils/is-drag-exempt-target.ts @@ -0,0 +1,76 @@ +import { + contains, + getEventTarget, + isEditableElement, + isHTMLElement, + isInputElement, + isLeftClick, +} from "@zag-js/dom-query" +import type { JSX } from "@zag-js/types" + +const NO_DRAG_DATA_ATTR = "data-no-drag" +const NO_DRAG_SELECTOR = `[${NO_DRAG_DATA_ATTR}]` + +/** + * Elements that need to keep browser-native pointer/touch behavior and must not + * participate in drawer dragging (e.g. native range sliders, text fields, selection). + */ +export function isDragExemptElement(el: EventTarget | null | undefined): boolean { + if (!isHTMLElement(el)) return false + if (el.closest(NO_DRAG_SELECTOR)) return true + + let node: HTMLElement | null = el + while (node) { + if (isEditableElement(node)) return true + node = node.parentElement + } + + const input = el.closest("input") + if (isInputElement(input)) { + const type = input.type + if (type === "range" || type === "file") return true + } + return false +} + +/** + * Non-collapsed selection whose range or caret endpoints live inside the drawer content + * (including shadow roots hosted under the content node). + */ +export function isTextSelectionInDrawer(doc: Document, contentEl: HTMLElement | null): boolean { + if (!contentEl) return false + const sel = doc.getSelection() + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return false + try { + const range = sel.getRangeAt(0) + if (contains(contentEl, range.commonAncestorContainer)) return true + if (contains(contentEl, sel.anchorNode)) return true + if (contains(contentEl, sel.focusNode)) return true + if (typeof range.intersectsNode === "function" && range.intersectsNode(contentEl)) { + return true + } + } catch { + return false + } + return false +} + +export function isDragExemptFromComposedPath(event: { + target: EventTarget | null + composedPath?: () => EventTarget[] +}): boolean { + const path = typeof event.composedPath === "function" ? event.composedPath() : [] + for (const node of path) { + if (isDragExemptElement(node)) return true + } + return isDragExemptElement(event.target) +} + +/** True when this pointer down should not arm drawer drag (wrong button, no-drag, or exempt target). */ +export function shouldIgnorePointerDownForDrag(event: JSX.PointerEvent): boolean { + if (!isLeftClick(event)) return true + const target = getEventTarget(event) + if (target?.hasAttribute(NO_DRAG_DATA_ATTR) || target?.closest(NO_DRAG_SELECTOR)) return true + if (isDragExemptFromComposedPath(event)) return true + return false +} diff --git a/packages/machines/drawer/src/utils/release-velocity.ts b/packages/machines/drawer/src/utils/release-velocity.ts new file mode 100644 index 0000000000..4339ff13bb --- /dev/null +++ b/packages/machines/drawer/src/utils/release-velocity.ts @@ -0,0 +1,52 @@ +/** Min displacement (px) from rest snap before we trust it over a brief opposing velocity tick. */ +const RELEASE_DISPLACEMENT_TRUST_PX = 24 +const OPEN_SWIPE_HIDDEN_VISIBLE_RATIO = 0.22 +const OPEN_SWIPE_HIDDEN_VELOCITY_MULTIPLIER = 1.25 +const OPEN_SWIPE_REVEALED_VISIBLE_RATIO = 0.5 +const OPEN_SWIPE_REVEALED_OPPOSING_MAX_ABS_VELOCITY = 650 + +/** + * When the user clearly moved the sheet one way, ignore a short opposing velocity sample + * (coalesced / rubber-band touch end, etc.). + */ +export function adjustReleaseVelocityAgainstDisplacement(velocity: number, displacementFromSnap: number): number { + const dSign = Math.sign(displacementFromSnap) + const vSign = Math.sign(velocity) + if ( + dSign !== 0 && + Math.abs(displacementFromSnap) >= RELEASE_DISPLACEMENT_TRUST_PX && + vSign !== 0 && + vSign !== dSign + ) { + return 0 + } + return velocity +} + +/** + * Swipe-open gesture: damp spurious velocity when the sheet is still almost fully off-screen + * or nearly committed open. + */ +export function adjustReleaseVelocityForOpenSwipe( + velocity: number, + visibleRatio: number, + swipeVelocityThreshold: number, +): number { + // Still mostly hidden: ignore moderate “toward open” velocity spikes + if ( + visibleRatio < OPEN_SWIPE_HIDDEN_VISIBLE_RATIO && + velocity < 0 && + Math.abs(velocity) < swipeVelocityThreshold * OPEN_SWIPE_HIDDEN_VELOCITY_MULTIPLIER + ) { + return 0 + } + // Largely revealed: small opposing velocity is usually noise + if ( + visibleRatio > OPEN_SWIPE_REVEALED_VISIBLE_RATIO && + velocity > 0 && + Math.abs(velocity) < OPEN_SWIPE_REVEALED_OPPOSING_MAX_ABS_VELOCITY + ) { + return 0 + } + return velocity +} diff --git a/packages/machines/drawer/src/utils/resolve-snap-point.ts b/packages/machines/drawer/src/utils/resolve-snap-point.ts deleted file mode 100644 index 1272531ca1..0000000000 --- a/packages/machines/drawer/src/utils/resolve-snap-point.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { ResolvedSnapPoint, SnapPoint } from "../drawer.types" - -function clamp(value: number, min: number, max: number) { - return Math.min(Math.max(value, min), max) -} - -export interface ResolveSnapPointOptions { - popupSize: number - viewportSize: number - rootFontSize: number -} - -function resolveSnapPointValue(snapPoint: SnapPoint, viewportSize: number, rootFontSize: number): number | null { - if (!Number.isFinite(viewportSize) || viewportSize <= 0) return null - - if (typeof snapPoint === "number") { - if (!Number.isFinite(snapPoint)) return null - if (snapPoint <= 1) return clamp(snapPoint, 0, 1) * viewportSize - return snapPoint - } - - const trimmed = snapPoint.trim() - - if (trimmed.endsWith("px")) { - const value = Number.parseFloat(trimmed) - return Number.isFinite(value) ? value : null - } - - if (trimmed.endsWith("rem")) { - const value = Number.parseFloat(trimmed) - return Number.isFinite(value) ? value * rootFontSize : null - } - - return null -} - -export function resolveSnapPoint(snapPoint: SnapPoint, options: ResolveSnapPointOptions): ResolvedSnapPoint | null { - const { popupSize, viewportSize, rootFontSize } = options - const maxSize = Math.min(popupSize, viewportSize) - if (!Number.isFinite(maxSize) || maxSize <= 0) return null - - const resolvedSize = resolveSnapPointValue(snapPoint, viewportSize, rootFontSize) - if (resolvedSize === null || !Number.isFinite(resolvedSize)) return null - - const height = clamp(resolvedSize, 0, maxSize) - return { - value: snapPoint, - height, - offset: Math.max(0, popupSize - height), - } -} diff --git a/packages/machines/drawer/src/utils/snap-point.ts b/packages/machines/drawer/src/utils/snap-point.ts new file mode 100644 index 0000000000..599761f8c1 --- /dev/null +++ b/packages/machines/drawer/src/utils/snap-point.ts @@ -0,0 +1,80 @@ +import { clampValue } from "@zag-js/utils" +import type { ResolvedSnapPoint, SnapPoint } from "../drawer.types" + +export interface ResolveSnapPointOptions { + contentSize: number + viewportSize: number + rootFontSize: number +} + +function resolveSnapPointValue(snapPoint: SnapPoint, viewportSize: number, rootFontSize: number): number | null { + if (!Number.isFinite(viewportSize) || viewportSize <= 0) return null + + if (typeof snapPoint === "number") { + if (!Number.isFinite(snapPoint)) return null + if (snapPoint <= 1) return clampValue(snapPoint, 0, 1) * viewportSize + return snapPoint + } + + const trimmed = snapPoint.trim() + + if (trimmed.endsWith("px")) { + const value = Number.parseFloat(trimmed) + return Number.isFinite(value) ? value : null + } + + if (trimmed.endsWith("rem")) { + const value = Number.parseFloat(trimmed) + return Number.isFinite(value) ? value * rootFontSize : null + } + + return null +} + +export function resolveSnapPoint(snapPoint: SnapPoint, options: ResolveSnapPointOptions): ResolvedSnapPoint | null { + const { contentSize, viewportSize, rootFontSize } = options + const maxSize = Math.min(contentSize, viewportSize) + if (!Number.isFinite(maxSize) || maxSize <= 0) return null + + const resolvedSize = resolveSnapPointValue(snapPoint, viewportSize, rootFontSize) + if (resolvedSize === null || !Number.isFinite(resolvedSize)) return null + + const height = clampValue(resolvedSize, 0, maxSize) + return { + value: snapPoint, + height, + offset: Math.max(0, contentSize - height), + } +} + +const HEIGHT_DEDUP_EPSILON_PX = 1 + +/** + * Collapse snap points that resolve to the same height (within 1px), keeping the first occurrence in list order. + */ +export function dedupeSnapPoints(points: ResolvedSnapPoint[]): ResolvedSnapPoint[] { + if (points.length <= 1) return points + + const deduped: ResolvedSnapPoint[] = [] + const seenHeights: number[] = [] + + for (let index = points.length - 1; index >= 0; index -= 1) { + const point = points[index] + const isDuplicate = seenHeights.some((height) => Math.abs(height - point.height) <= HEIGHT_DEDUP_EPSILON_PX) + if (isDuplicate) continue + + seenHeights.push(point.height) + deduped.push(point) + } + + deduped.reverse() + return deduped +} + +export function findClosestSnapPoint(offset: number, snapPoints: ResolvedSnapPoint[]): ResolvedSnapPoint { + return snapPoints.reduce((acc, curr) => { + const closestDiff = Math.abs(offset - acc.offset) + const currentDiff = Math.abs(offset - curr.offset) + return currentDiff < closestDiff ? curr : acc + }) +} diff --git a/packages/machines/drawer/src/utils/swipe.ts b/packages/machines/drawer/src/utils/swipe.ts new file mode 100644 index 0000000000..12b1dd59ed --- /dev/null +++ b/packages/machines/drawer/src/utils/swipe.ts @@ -0,0 +1,51 @@ +import { clampValue } from "@zag-js/utils" +import type { Point } from "@zag-js/types" +import type { SwipeDirection } from "../drawer.types" + +/** Swipe-area → swiping-open pointer intent (px). */ +const SWIPE_AREA_OPEN_INTENT_MIN_PX = 5 + +/** Swipe axis is vertical (up/down) vs horizontal (left/right). */ +export function isVerticalSwipeDirection(direction: SwipeDirection): boolean { + return direction === "down" || direction === "up" +} + +/** Dismiss direction uses negative offset along the swipe axis (up / left). */ +export function isNegativeSwipeDirection(direction: SwipeDirection): boolean { + return direction === "up" || direction === "left" +} + +export const oppositeSwipeDirection: Record = { + up: "down", + down: "up", + left: "right", + right: "left", +} + +/** Content size along the active swipe axis from a border box. */ +export function getSwipeDirectionSize(rect: DOMRect, direction: SwipeDirection): number { + return isVerticalSwipeDirection(direction) ? rect.height : rect.width +} + +/** Normalized swipe progress (0–1) for stack UI from content size and offsets. */ +export function resolveSwipeProgress( + contentSize: number | null, + dragOffset: number | null, + snapPointOffset: number, +): number { + if (!contentSize || contentSize <= 0) return 0 + const currentOffset = dragOffset ?? snapPointOffset + return clampValue(1 - currentOffset / contentSize, 0, 1) +} + +/** + * True when pointer moved far enough in the swipe-open direction from `start` to `current` + * (used for swipe-area → swiping-open transition). + */ +export function hasOpeningSwipeIntent(start: Point, current: Point, direction: SwipeDirection): boolean { + const isVertical = isVerticalSwipeDirection(direction) + const sign = isNegativeSwipeDirection(direction) ? -1 : 1 + const axis = isVertical ? "y" : "x" + const displacement = (start[axis] - current[axis]) * sign + return displacement > SWIPE_AREA_OPEN_INTENT_MIN_PX +} diff --git a/packages/utilities/dom-query/src/node.ts b/packages/utilities/dom-query/src/node.ts index ac7ce17a9f..832dd2537d 100644 --- a/packages/utilities/dom-query/src/node.ts +++ b/packages/utilities/dom-query/src/node.ts @@ -63,17 +63,18 @@ export function isEditableElement(el: HTMLElement | EventTarget | null) { type Target = HTMLElement | EventTarget | null | undefined +/** Whether `parent` contains `child` in the light tree, or `child` is inside a shadow tree hosted under `parent`. */ export function contains(parent: Target, child: Target) { if (!parent || !child) return false - if (!isHTMLElement(parent) || !isHTMLElement(child)) return false - const rootNode = child.getRootNode?.() - if (parent === child) return true + if (!isHTMLElement(parent) || !isNode(child)) return false + if (isHTMLElement(child) && parent === child) return true if (parent.contains(child)) return true + const rootNode = child.getRootNode?.() if (rootNode && isShadowRoot(rootNode)) { - let next = child + let next: Node | null = child while (next) { if (parent === next) return true - // @ts-ignore + // @ts-ignore ShadowRoot has host next = next.parentNode || next.host } } diff --git a/shared/src/routes.ts b/shared/src/routes.ts index ddab8f6b5d..95eb043644 100644 --- a/shared/src/routes.ts +++ b/shared/src/routes.ts @@ -46,6 +46,7 @@ export const componentRoutes: ComponentRoute[] = [ { slug: "indent-background", title: "Indent Background" }, { slug: "swipe-area", title: "Swipe Area" }, { slug: "cross-axis-scroll", title: "Cross-Axis Scroll" }, + { slug: "range-input", title: "Range Input" }, { slug: "action-sheet", title: "Action Sheet" }, { slug: "controlled", title: "Controlled" }, { slug: "mobile-nav", title: "Mobile Nav" }, From bc7f6643c29aa79cb193625807ac48d41af90806 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Fri, 20 Mar 2026 18:45:11 +0200 Subject: [PATCH 5/7] chore: add drawer readme --- packages/machines/drawer/src/README.md | 36 ++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 packages/machines/drawer/src/README.md diff --git a/packages/machines/drawer/src/README.md b/packages/machines/drawer/src/README.md new file mode 100644 index 0000000000..37c75b8286 --- /dev/null +++ b/packages/machines/drawer/src/README.md @@ -0,0 +1,36 @@ +# Drawer machine — how to read this package + +## Start here + +1. **`drawer.types.ts`** — `SwipeDirection`, snap point shapes, service/API types. +2. **`drawer.machine.ts`** — States (`open`, `closing`, `closed`, `swiping-open`, `swipe-area-dragging`), transitions, + guards/actions, and **effects** (DOM subscriptions). +3. **`drawer.connect.ts`** — Props passed to the DOM (CSS vars, `data-*`, pointer handlers). Presentation math for + translate/movement lives next to `getContentProps` / swipe area. + +## Gesture & input (where bugs often land) + +| Concern | Where to look | +| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| Pointer → machine events (`POINTER_MOVE`, etc.) | `drawer.machine.ts` → effect **`trackPointerMove`** (document-level pointer + **touch**; touch `preventDefault` vs nested scroll). | +| Swipe-open from closed (swipe area) | Same file → **`trackSwipeOpenPointerMove`**; guard **`hasOpeningSwipeIntent`** uses **`utils/swipe.ts`**. | +| Drag thresholds, velocity, snap resolution | **`utils/drag-manager.ts`**. | +| Deferred mouse/pen “don’t arm drag on first pixel” | **`utils/deferred-pointer.ts`**; wired from **`drawer.connect.ts`** (`deferPointerDown` / `cancelDeferPointerDown`). | +| Scroll competition / nested scroller | **`utils/get-scroll-info.ts`** (`findClosestScrollableAncestorOnSwipeAxis`, `shouldPreventTouchDefaultForDrawerPull`, `getScrollInfo`). | +| Ignore drag on inputs, selection, `data-no-drag` | **`utils/is-drag-exempt-target.ts`** (`isDragExemptElement`, `shouldIgnorePointerDownForDrag`, `isTextSelectionInDrawer`). | + +## DOM & layout + +- **`drawer.dom.ts`** — Part IDs, element getters, **`isPointerWithinContentOrSwipeArea`**. +- **`drawer.anatomy.ts`** — Part names/attributes for adapters. + +## Stack / nested drawers + +- **`drawer.stack.ts`**, **`drawer.registry.ts`** — Height and swipe progress for stacked sheets. + +## Public entry + +- **`index.ts`** re-exports machine, connect, props, types, stack helpers. + +When fixing a regression, confirm whether it’s **event routing** (machine effects), **headless props** (connect), or +**pure gesture math** (utils) before editing. From d66d0bdd986e8f36846fb95712249a63ebf38c4d Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Fri, 20 Mar 2026 19:00:52 +0200 Subject: [PATCH 6/7] chore: example parity --- .../drawer/default-active-snap-point.tsx | 4 +- .../components/DrawerControlledAlwaysOpen.vue | 38 ++++ .../components/DrawerControlledManaged.vue | 47 +++++ .../nuxt-ts/app/pages/drawer/action-sheet.vue | 43 +++++ examples/nuxt-ts/app/pages/drawer/basic.vue | 9 +- .../nuxt-ts/app/pages/drawer/controlled.vue | 32 ++++ .../app/pages/drawer/cross-axis-scroll.vue | 63 +++++++ .../drawer/default-active-snap-point.vue | 4 +- .../app/pages/drawer/draggable-false.vue | 44 +++++ .../app/pages/drawer/indent-background.vue | 56 ++++++ .../nuxt-ts/app/pages/drawer/mobile-nav.vue | 74 ++++++++ examples/nuxt-ts/app/pages/drawer/nested.vue | 146 ++++++++++++++++ .../nuxt-ts/app/pages/drawer/non-modal.vue | 54 ++++++ .../nuxt-ts/app/pages/drawer/range-input.vue | 67 ++++++++ .../nuxt-ts/app/pages/drawer/swipe-area.vue | 34 ++++ .../src/routes/drawer/action-sheet.tsx | 60 +++++++ examples/solid-ts/src/routes/drawer/basic.tsx | 9 +- .../solid-ts/src/routes/drawer/controlled.tsx | 109 ++++++++++++ .../src/routes/drawer/cross-axis-scroll.tsx | 79 +++++++++ .../drawer/default-active-snap-point.tsx | 11 +- .../src/routes/drawer/draggable-false.tsx | 51 ++++++ .../src/routes/drawer/indent-background.tsx | 57 ++++++ .../solid-ts/src/routes/drawer/mobile-nav.tsx | 98 +++++++++++ .../solid-ts/src/routes/drawer/nested.tsx | 162 ++++++++++++++++++ .../solid-ts/src/routes/drawer/non-modal.tsx | 57 ++++++ .../src/routes/drawer/range-input.tsx | 82 +++++++++ .../src/routes/drawer/snap-points.tsx | 7 +- .../solid-ts/src/routes/drawer/swipe-area.tsx | 41 +++++ .../routes/drawer/action-sheet/+page.svelte | 45 +++++ .../src/routes/drawer/controlled/+page.svelte | 36 ++++ .../DrawerControlledAlwaysOpen.svelte | 41 +++++ .../controlled/DrawerControlledManaged.svelte | 50 ++++++ .../drawer/cross-axis-scroll/+page.svelte | 53 ++++++ .../default-active-snap-point/+page.svelte | 4 +- .../drawer/indent-background/+page.svelte | 58 +++++++ .../src/routes/drawer/mobile-nav/+page.svelte | 78 +++++++++ .../src/routes/drawer/nested/+page.svelte | 137 +++++++++++++++ .../src/routes/drawer/non-modal/+page.svelte | 45 +++++ .../routes/drawer/range-input/+page.svelte | 67 ++++++++ .../src/routes/drawer/swipe-area/+page.svelte | 38 ++++ 40 files changed, 2168 insertions(+), 22 deletions(-) create mode 100644 examples/nuxt-ts/app/components/DrawerControlledAlwaysOpen.vue create mode 100644 examples/nuxt-ts/app/components/DrawerControlledManaged.vue create mode 100644 examples/nuxt-ts/app/pages/drawer/action-sheet.vue create mode 100644 examples/nuxt-ts/app/pages/drawer/controlled.vue create mode 100644 examples/nuxt-ts/app/pages/drawer/cross-axis-scroll.vue create mode 100644 examples/nuxt-ts/app/pages/drawer/draggable-false.vue create mode 100644 examples/nuxt-ts/app/pages/drawer/indent-background.vue create mode 100644 examples/nuxt-ts/app/pages/drawer/mobile-nav.vue create mode 100644 examples/nuxt-ts/app/pages/drawer/nested.vue create mode 100644 examples/nuxt-ts/app/pages/drawer/non-modal.vue create mode 100644 examples/nuxt-ts/app/pages/drawer/range-input.vue create mode 100644 examples/nuxt-ts/app/pages/drawer/swipe-area.vue create mode 100644 examples/solid-ts/src/routes/drawer/action-sheet.tsx create mode 100644 examples/solid-ts/src/routes/drawer/controlled.tsx create mode 100644 examples/solid-ts/src/routes/drawer/cross-axis-scroll.tsx create mode 100644 examples/solid-ts/src/routes/drawer/draggable-false.tsx create mode 100644 examples/solid-ts/src/routes/drawer/indent-background.tsx create mode 100644 examples/solid-ts/src/routes/drawer/mobile-nav.tsx create mode 100644 examples/solid-ts/src/routes/drawer/nested.tsx create mode 100644 examples/solid-ts/src/routes/drawer/non-modal.tsx create mode 100644 examples/solid-ts/src/routes/drawer/range-input.tsx create mode 100644 examples/solid-ts/src/routes/drawer/swipe-area.tsx create mode 100644 examples/svelte-ts/src/routes/drawer/action-sheet/+page.svelte create mode 100644 examples/svelte-ts/src/routes/drawer/controlled/+page.svelte create mode 100644 examples/svelte-ts/src/routes/drawer/controlled/DrawerControlledAlwaysOpen.svelte create mode 100644 examples/svelte-ts/src/routes/drawer/controlled/DrawerControlledManaged.svelte create mode 100644 examples/svelte-ts/src/routes/drawer/cross-axis-scroll/+page.svelte create mode 100644 examples/svelte-ts/src/routes/drawer/indent-background/+page.svelte create mode 100644 examples/svelte-ts/src/routes/drawer/mobile-nav/+page.svelte create mode 100644 examples/svelte-ts/src/routes/drawer/nested/+page.svelte create mode 100644 examples/svelte-ts/src/routes/drawer/non-modal/+page.svelte create mode 100644 examples/svelte-ts/src/routes/drawer/range-input/+page.svelte create mode 100644 examples/svelte-ts/src/routes/drawer/swipe-area/+page.svelte 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/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/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))