import type { JSX, ValidComponent } from "@solidjs/web"; /** * `` — positioning container for the drawer popup that * can be made scrollable, and the primary consumer of `useSwipeDismiss`. * * Full port of Base UI React's `DrawerViewport` * (`packages/react/src/drawer/viewport/DrawerViewport.tsx`). * * Responsibilities: * * - Install `useSwipeDismiss` against the popup element; route pointer * and touch events through the state machine. * - Attach a capture-phase `touchmove` listener on the document to * arbitrate between native scroll and swipe-dismiss at scroll edges. * - On release, resolve the swipe direction into a snap-point change or a * dismiss; on dismiss, call `store.setOpen(false, ...)` and handle the * controlled-mode rejection path with a `requestAnimationFrame` recovery. * - Expose `swiping` / `getDragStyles` / `swipeStrength` on * `` so the popup can freeze its transform while * a drag is in flight. * - Emit drawer-specific `data-nested` attr on the rendered element. * * Solid 2 adaptations: * * - `useEffect` → `createEffect(source, apply)` with cleanup returns. * - `setPointerCapture` lifecycle and the native `touchmove` * capture-phase listener are installed in `createEffect`. * - `ReactDOM.flushSync` is replaced with `flush()` from Solid — Solid * has no direct equivalent, but `flush()` forces any pending signal * writes to apply synchronously, matching the intent (transition-style * attributes land before the next paint). * - `React.startTransition` on the controlled-dismiss rejection path is * replaced by a `requestAnimationFrame` — on next frame we check * whether the parent accepted the close. */ import { dynamic } from "@solidjs/web"; import { createEffect, createMemo, createSignal, flush, omit, onCleanup, Show, untrack, } from "solid-js"; import { useDialogRootContext } from "../../dialog/dialog-root-context"; import { useDialogPortalContext } from "../../dialog/portal/dialog-portal-context"; import { createChangeEventDetails, REASONS, } from "../../internals/change-event-details"; import { composeRefs, userRef } from "../../internals/compose-refs"; import { contains } from "../../internals/dom/contains"; import { getTarget } from "../../internals/dom/get-target"; import { ownerDocument } from "../../internals/dom/owner-document"; import { ownerWindow } from "../../internals/dom/owner-window"; import { getElementAtPoint } from "../../internals/get-element-at-point"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import { findScrollableTouchTarget, type ScrollAxis, } from "../../internals/scrollable"; import { clamp } from "../../internals/slider-utils/clamp"; import { QUOIN_SWIPE_IGNORE_SELECTOR } from "../../internals/swipe-ignore-selector"; import type { BaseUIComponentProps } from "../../internals/types"; import { useAnimationFrame } from "../../internals/use-animation-frame"; import { type SwipeDirection, type UseSwipeDismissProgressDetails, useSwipeDismiss, } from "../../internals/use-swipe-dismiss"; import type { TransitionStatus } from "../../internals/use-transition-status/use-transition-status"; import { DrawerBackdropCssVars } from "../backdrop/drawer-backdrop-css-vars"; import { DRAWER_CONTENT_ATTRIBUTE } from "../content/drawer-content-data-attributes"; import { DrawerPopupCssVars } from "../popup/drawer-popup-css-vars"; import { DrawerPopupDataAttributes } from "../popup/drawer-popup-data-attributes"; import { useDrawerRootContext } from "../root/drawer-root-context"; import { useDrawerSnapPoints } from "../root/use-drawer-snap-points"; import { DrawerViewportContext, type DrawerViewportContextValue, } from "./drawer-viewport-context"; import { DrawerViewportDataAttributes } from "./drawer-viewport-data-attributes"; const MIN_SWIPE_THRESHOLD = 10; const FAST_SWIPE_VELOCITY = 0.5; const SNAP_VELOCITY_THRESHOLD = 0.5; const SNAP_VELOCITY_MULTIPLIER = 300; const MAX_SNAP_VELOCITY = 4; const MIN_SWIPE_RELEASE_VELOCITY = 0.2; const MAX_SWIPE_RELEASE_VELOCITY = 4; const MIN_SWIPE_RELEASE_DURATION_MS = 80; const MAX_SWIPE_RELEASE_DURATION_MS = 360; const MIN_SWIPE_RELEASE_SCALAR = 0.1; const MAX_SWIPE_RELEASE_SCALAR = 1; const DRAWER_CONTENT_SELECTOR = `[${DRAWER_CONTENT_ATTRIBUTE}]`; interface TouchScrollState { startX: number; startY: number; lastX: number; lastY: number; scrollTarget: HTMLElement | null; hasCrossAxisScrollableContent: boolean; allowSwipe: boolean | null; preserveNativeCrossAxisScroll: boolean; } export interface DrawerViewportState { open: boolean; transitionStatus: TransitionStatus; nested: boolean; nestedDialogOpen: boolean; } export interface DrawerViewportProps extends BaseUIComponentProps<"div", DrawerViewportState> { /** * Tag/component to render. Defaults to `"div"`. Replaces the * pre-spike `render` prop (axis G). */ as?: ValidComponent; } function setSwipeDismissedElements( popupElement: HTMLElement | null, backdropElement: HTMLElement | null, dismissed: boolean, ): void { if (dismissed) { popupElement?.setAttribute(DrawerPopupDataAttributes.swipeDismiss, ""); backdropElement?.setAttribute(DrawerPopupDataAttributes.swipeDismiss, ""); return; } popupElement?.removeAttribute(DrawerPopupDataAttributes.swipeDismiss); backdropElement?.removeAttribute(DrawerPopupDataAttributes.swipeDismiss); } function setBackdropSwipingAttribute( backdropElement: HTMLElement | null, swiping: boolean, ): void { if (!backdropElement) return; if (swiping) { backdropElement.setAttribute(DrawerPopupDataAttributes.swiping, ""); return; } backdropElement.removeAttribute(DrawerPopupDataAttributes.swiping); } function isSwipeIgnoredTarget(target: Element | null): boolean { return Boolean(target?.closest(QUOIN_SWIPE_IGNORE_SELECTOR)); } function isDrawerContentTarget(target: Element | null): boolean { return Boolean(target?.closest(DRAWER_CONTENT_SELECTOR)); } function getBaseSwipeThreshold( element: HTMLElement, direction: SwipeDirection, ): number { const size = direction === "left" || direction === "right" ? element.offsetWidth : element.offsetHeight; return Math.max(size * 0.5, MIN_SWIPE_THRESHOLD); } function isRangeInput( target: EventTarget | null, _win: Window, ): target is HTMLInputElement { return ( target !== null && Reflect.get(target, "tagName") === "INPUT" && Reflect.get(target, "type") === "range" ); } function isTextSelectionControl( target: EventTarget | null, ): target is HTMLInputElement | HTMLTextAreaElement { if (!target || !(target as Node).nodeType) return false; const el = target as Element; return el.tagName === "INPUT" || el.tagName === "TEXTAREA"; } function hasExpandedSelectionWithinTarget( selection: Selection, target: Element, ): boolean { const anchorElement = selection.anchorNode instanceof Element ? selection.anchorNode : (selection.anchorNode?.parentElement ?? null); const focusElement = selection.focusNode instanceof Element ? selection.focusNode : (selection.focusNode?.parentElement ?? null); return ( selection.containsNode(target, true) || contains(target, anchorElement) || contains(target, focusElement) ); } function shouldIgnoreSwipeForTextSelection( doc: Document, rootElement: HTMLElement, ): boolean { const activeEl = doc.activeElement as HTMLElement | null; const activeElementWithinRoot = Boolean( activeEl && contains(rootElement, activeEl), ); if (activeElementWithinRoot && isTextSelectionControl(activeEl)) { const { selectionStart, selectionEnd } = activeEl as HTMLInputElement; if ( selectionStart != null && selectionEnd != null && selectionStart < selectionEnd ) { return true; } } const selection = doc.getSelection?.(); if (!selection || selection.isCollapsed) return false; return hasExpandedSelectionWithinTarget(selection, rootElement); } function isEventOnRangeInput(event: TouchEvent, win: Window): boolean { const composedPath = event.composedPath(); if (composedPath) { return composedPath.some((pathTarget) => isRangeInput(pathTarget ?? null, win), ); } return isRangeInput(getTarget(event), win); } function isReactTouchEventOnRangeInput(event: TouchEvent): boolean { return isEventOnRangeInput(event, ownerWindow(event.currentTarget as Node)); } function updateTouchScrollPosition( touchState: TouchScrollState, touch: Touch, ): void { touchState.lastX = touch.clientX; touchState.lastY = touch.clientY; } function preserveNativeCrossAxisScrollOnMove( touchState: TouchScrollState, touch: Touch, isVerticalScrollAxis: boolean, ): boolean { if (touchState.preserveNativeCrossAxisScroll) return true; if ( touchState.allowSwipe === true || !touchState.hasCrossAxisScrollableContent ) return false; const drawerAxisGestureDelta = isVerticalScrollAxis ? touch.clientY - touchState.startY : touch.clientX - touchState.startX; const crossAxisGestureDelta = isVerticalScrollAxis ? touch.clientX - touchState.startX : touch.clientY - touchState.startY; const absDrawerAxisGestureDelta = Math.abs(drawerAxisGestureDelta); const absCrossAxisGestureDelta = Math.abs(crossAxisGestureDelta); if ( absCrossAxisGestureDelta < 6 || absCrossAxisGestureDelta <= absDrawerAxisGestureDelta + 2 ) { return false; } touchState.preserveNativeCrossAxisScroll = true; return true; } function hasScrollableContentOnAxis( scrollTarget: HTMLElement, axis: ScrollAxis, ): boolean { return axis === "vertical" ? scrollTarget.scrollHeight > scrollTarget.clientHeight : scrollTarget.scrollWidth > scrollTarget.clientWidth; } function getScrollMetrics( scrollTarget: HTMLElement, axis: ScrollAxis, ): { offset: number; max: number } { if (axis === "vertical") { const max = Math.max( 0, scrollTarget.scrollHeight - scrollTarget.clientHeight, ); return { offset: scrollTarget.scrollTop, max }; } const max = Math.max(0, scrollTarget.scrollWidth - scrollTarget.clientWidth); return { offset: scrollTarget.scrollLeft, max }; } function shouldDismissFromStartEdge( direction: SwipeDirection, axis: ScrollAxis, ): boolean | null { if (axis === "vertical") { if (direction === "down") return true; if (direction === "up") return false; return null; } if (direction === "right") return true; if (direction === "left") return false; return null; } function isAtSwipeStartEdge( scrollTarget: HTMLElement, axis: ScrollAxis, direction: SwipeDirection, ): boolean { const { offset, max } = getScrollMetrics(scrollTarget, axis); const dismissFromStartEdge = shouldDismissFromStartEdge(direction, axis); if (dismissFromStartEdge === null) return false; return dismissFromStartEdge ? offset <= 0 : offset >= max; } function canSwipeFromScrollEdgeOnMove( scrollTarget: HTMLElement, axis: ScrollAxis, direction: SwipeDirection, delta: number, ): boolean { const { offset, max } = getScrollMetrics(scrollTarget, axis); const dismissFromStartEdge = shouldDismissFromStartEdge(direction, axis); if (dismissFromStartEdge === null) return false; const movingTowardDismiss = dismissFromStartEdge ? delta > 0 : delta < 0; if (!movingTowardDismiss) return false; return dismissFromStartEdge ? offset <= 0 : offset >= max; } /** * A positioning container for the drawer popup. Renders a `
` with * `role="presentation"`. */ export function DrawerViewport(props: DrawerViewportProps): JSX.Element { const root = useDialogRootContext(); const drawer = useDrawerRootContext(); useDialogPortalContext(); const { snapPoints, resolvedSnapPoints, activeSnapPoint, activeSnapPointOffset, setActiveSnapPoint, popupHeight, } = useDrawerSnapPoints(); const swipeDirection = drawer.swipeDirection; const snapToSequentialPoints = drawer.snapToSequentialPoints; const notifyParentSwipingChange = drawer.notifyParentSwipingChange; const notifyParentSwipeProgressChange = drawer.notifyParentSwipeProgressChange; const frontmostHeight = drawer.frontmostHeight; const scrollAxis = (): ScrollAxis => swipeDirection() === "left" || swipeDirection() === "right" ? "horizontal" : "vertical"; const isVerticalScrollAxis = (): boolean => scrollAxis() === "vertical"; const crossScrollAxis = (): ScrollAxis => isVerticalScrollAxis() ? "horizontal" : "vertical"; const [swipeRelease, setSwipeRelease] = createSignal(null, { ownedWrite: true, }); let pendingSwipeCloseSnapPoint: | ReturnType | undefined; let resetSwipeFn: (() => void) | null = null; const controlledDismissFrame = useAnimationFrame(); let nestedSwipeActive = false; let lastPointerType: PointerEvent["pointerType"] | "" = ""; let ignoreNextTouchStartFromPen = false; let ignoreTouchSwipe = false; let touchScrollState: TouchScrollState | null = null; const snapPointRange = createMemo<{ minOffset: number; range: number; } | null>(() => { const pts = snapPoints(); if (!pts || pts.length < 2) return null; const dir = swipeDirection(); if (dir !== "down" && dir !== "up") return null; const rsp = resolvedSnapPoints(); if (rsp.length < 2) return null; const offsets = rsp .map((p) => p.offset) .filter((offset) => Number.isFinite(offset)) .sort((a, b) => a - b); if (offsets.length < 2) return null; const minOffset = offsets[0] ?? 0; const nextOffset = offsets[1] ?? 0; const maxOffset = offsets[offsets.length - 1] ?? 0; let range = nextOffset - minOffset; if (!Number.isFinite(range) || range <= 0) { const fallbackRange = maxOffset - minOffset; if (!Number.isFinite(fallbackRange) || fallbackRange <= 0) return null; range = fallbackRange; } return { minOffset, range }; }); const snapPointProgress = createMemo(() => { const range = snapPointRange(); const offset = activeSnapPointOffset(); if (!range || offset === null) return null; return clamp((offset - range.minOffset) / range.range, 0, 1); }); const swipeDirections = createMemo(() => { const pts = snapPoints(); const dir = swipeDirection(); if (pts && pts.length > 0 && (dir === "down" || dir === "up")) { return dir === "down" ? ["down", "up"] : ["up", "down"]; } return [dir]; }); const setSwipeDismissed = (dismissed: boolean): void => { setSwipeDismissedElements( root.popupRef.current, root.backdropRef.current, dismissed, ); }; const clearSwipeRelease = (): void => { setSwipeDismissed(false); root.popupRef.current?.removeAttribute("data-ending-style"); setSwipeRelease(null); }; const finishNestedSwipe = (): void => { if (!nestedSwipeActive) return; nestedSwipeActive = false; notifyParentSwipingChange?.(false); }; const applySwipeProgress = ({ resolvedProgress, shouldTrackProgress, notifyParent, }: { resolvedProgress: number; shouldTrackProgress: boolean; notifyParent: boolean; }): void => { const isActive = root.open() && !root.nested() && shouldTrackProgress; const swipeProgress = isActive ? resolvedProgress : 0; const nestedSwipeProgress = root.open() && shouldTrackProgress ? resolvedProgress : 0; if (notifyParent && notifyParentSwipeProgressChange) { notifyParentSwipeProgressChange(nestedSwipeProgress); if (nestedSwipeProgress <= 0) finishNestedSwipe(); } const backdropElement = root.backdropRef.current; if (!backdropElement) return; if (!isActive || swipeProgress <= 0) { backdropElement.style.setProperty( DrawerBackdropCssVars.swipeProgress, "0", ); backdropElement.style.removeProperty(DrawerPopupCssVars.height); return; } backdropElement.style.setProperty( DrawerBackdropCssVars.swipeProgress, `${swipeProgress}`, ); const fh = frontmostHeight(); if (fh > 0) { backdropElement.style.setProperty(DrawerPopupCssVars.height, `${fh}px`); } else { backdropElement.style.removeProperty(DrawerPopupCssVars.height); } }; function resolveSwipeRelease(params: { direction: SwipeDirection | undefined; deltaX: number; deltaY: number; velocityX: number; velocityY: number; releaseVelocityX: number; releaseVelocityY: number; }): number | null { const { direction, deltaX, deltaY, velocityX, velocityY, releaseVelocityX, releaseVelocityY, } = params; if (!direction) return null; const popupElement = root.popupRef.current; if (!popupElement) return null; const size = direction === "left" || direction === "right" ? popupElement.offsetWidth : popupElement.offsetHeight; if (!Number.isFinite(size) || size <= 0) return null; const axisDelta = direction === "left" || direction === "right" ? deltaX : deltaY; const pts = snapPoints(); const snapPointBaseOffset = pts && pts.length > 0 ? (activeSnapPointOffset() ?? 0) : 0; let baseOffset = 0; if (direction === "down") baseOffset = snapPointBaseOffset; else if (direction === "up") baseOffset = -snapPointBaseOffset; const translation = baseOffset + axisDelta; const translationAlongDirection = direction === "left" || direction === "up" ? -translation : translation; const remainingDistance = Math.max(0, size - translationAlongDirection); if (!Number.isFinite(remainingDistance) || remainingDistance <= 0) return null; const axisVelocity = direction === "left" || direction === "right" ? releaseVelocityX : releaseVelocityY; const fallbackVelocity = direction === "left" || direction === "right" ? velocityX : velocityY; const resolvedVelocity = Math.abs(axisVelocity) > 0 && Number.isFinite(axisVelocity) ? axisVelocity : fallbackVelocity; const directionalVelocity = direction === "left" || direction === "up" ? -resolvedVelocity : resolvedVelocity; if ( !Number.isFinite(directionalVelocity) || directionalVelocity <= MIN_SWIPE_RELEASE_VELOCITY ) { return null; } const clampedVelocity = clamp( directionalVelocity, MIN_SWIPE_RELEASE_VELOCITY, MAX_SWIPE_RELEASE_VELOCITY, ); const durationMs = clamp( remainingDistance / clampedVelocity, MIN_SWIPE_RELEASE_DURATION_MS, MAX_SWIPE_RELEASE_DURATION_MS, ); if (!Number.isFinite(durationMs)) return null; const normalizedDuration = (durationMs - MIN_SWIPE_RELEASE_DURATION_MS) / (MAX_SWIPE_RELEASE_DURATION_MS - MIN_SWIPE_RELEASE_DURATION_MS); const durationScalar = clamp( MIN_SWIPE_RELEASE_SCALAR + normalizedDuration * (MAX_SWIPE_RELEASE_SCALAR - MIN_SWIPE_RELEASE_SCALAR), MIN_SWIPE_RELEASE_SCALAR, MAX_SWIPE_RELEASE_SCALAR, ); if (!Number.isFinite(durationScalar) || durationScalar <= 0) return null; return durationScalar; } function updateNestedSwipeActive( details?: UseSwipeDismissProgressDetails, ): void { if (nestedSwipeActive || !details) return; const direction = details.direction ?? swipeDirection(); const delta = direction === "left" || direction === "right" ? details.deltaX : details.deltaY; if (!Number.isFinite(delta) || Math.abs(delta) < MIN_SWIPE_THRESHOLD) return; nestedSwipeActive = true; notifyParentSwipingChange?.(true); } const nestedDrawerOpen = (): boolean => root.nestedOpenDrawerCount() > 0; const swipe = untrack(() => useSwipeDismiss({ enabled: () => root.mounted() && !nestedDrawerOpen(), directions: swipeDirections(), elementRef: () => root.popupRef.current, ignoreSelectorWhenTouch: false, ignoreScrollableAncestors: true, movementCssVars: { x: DrawerPopupCssVars.swipeMovementX, y: DrawerPopupCssVars.swipeMovementY, }, onSwipeStart(event) { if ( "touches" in event || ("pointerType" in event && (event as PointerEvent).pointerType === "touch") ) { return; } const popupElement = root.popupRef.current; if (!popupElement) return; const doc = ownerDocument(popupElement); const selection = doc.getSelection?.(); if (!selection || selection.isCollapsed) return; const anchorElement = selection.anchorNode instanceof Element ? selection.anchorNode : (selection.anchorNode?.parentElement ?? null); const focusElement = selection.focusNode instanceof Element ? selection.focusNode : (selection.focusNode?.parentElement ?? null); if ( !contains(popupElement, anchorElement) && !contains(popupElement, focusElement) ) { return; } selection.removeAllRanges(); }, onSwipingChange(swiping) { setBackdropSwipingAttribute(root.backdropRef.current, swiping); if (!swiping && !notifyParentSwipeProgressChange) finishNestedSwipe(); }, swipeThreshold({ element, direction }) { return getBaseSwipeThreshold(element, direction); }, canStart(position, details) { const popupElement = root.popupRef.current; if (!popupElement) return false; const doc = popupElement.ownerDocument; const elementAtPoint = getElementAtPoint(doc, position.x, position.y); if (!elementAtPoint || !contains(popupElement, elementAtPoint)) { return false; } const nativeEvent = details.nativeEvent; const touchLike = "touches" in nativeEvent || ("pointerType" in nativeEvent && (nativeEvent as PointerEvent).pointerType === "touch"); if (touchLike && shouldIgnoreSwipeForTextSelection(doc, popupElement)) { return false; } if ( nativeEvent.type === "touchstart" && isSwipeIgnoredTarget(elementAtPoint) ) { return false; } return true; }, onProgress(progress, details) { updateNestedSwipeActive(details); const currentDirection = details?.direction ?? swipe.swipeDirection(); const isDismissSwipe = currentDirection === undefined || currentDirection === swipeDirection(); const pts = snapPoints(); const hasSnapPoints = Boolean(pts && pts.length > 0); const isVerticalSwipe = swipeDirection() === "down" || swipeDirection() === "up"; const shouldTrackProgress = (hasSnapPoints && isVerticalSwipe) || !hasSnapPoints || swipeDirection() === "left" || swipeDirection() === "right" || isDismissSwipe; let resolvedProgress = progress; const range = snapPointRange(); if (range && popupHeight() > 0) { if (details && Number.isFinite(details.deltaY)) { const baseOffset = activeSnapPointOffset() ?? range.minOffset; const nextOffset = clamp( baseOffset + details.deltaY, 0, popupHeight(), ); resolvedProgress = clamp( (nextOffset - range.minOffset) / range.range, 0, 1, ); } else if (snapPointProgress() !== null) { resolvedProgress = snapPointProgress() ?? 0; } else if (currentDirection === "down" || currentDirection === "up") { const displacement = progress * popupHeight(); const baseOffset = activeSnapPointOffset() ?? range.minOffset; const nextOffset = currentDirection === "down" ? baseOffset + displacement : baseOffset - displacement; resolvedProgress = clamp( (nextOffset - range.minOffset) / range.range, 0, 1, ); } } applySwipeProgress({ resolvedProgress, shouldTrackProgress, notifyParent: true, }); }, onRelease({ event, deltaX, deltaY, direction, velocityX, velocityY, releaseVelocityX, releaseVelocityY, }) { const swipeReleasePayload = { deltaX, deltaY, velocityX, velocityY, releaseVelocityX, releaseVelocityY, }; function startSwipeRelease(resolvedDirection: SwipeDirection): void { const popupElement = root.popupRef.current; if (!popupElement) return; finishNestedSwipe(); setSwipeDismissed(true); popupElement.style.removeProperty("transition"); popupElement.setAttribute("data-ending-style", ""); setSwipeRelease( resolveSwipeRelease({ direction: resolvedDirection, ...swipeReleasePayload, }), ); // Solid analog of `ReactDOM.flushSync` — force the signal write // above to apply synchronously so the transition style + ending // attribute land before the dismiss animation begins. flush(); } // No snap points: dismiss on a forward swipe past the base // threshold, or immediately on a fast forward flick. function resolveReleaseWithoutSnapPoints(): boolean | undefined { if (!direction) { clearSwipeRelease(); return undefined; } const element = root.popupRef.current; if (!element) { clearSwipeRelease(); return undefined; } const baseThreshold = getBaseSwipeThreshold(element, direction); const delta = direction === "left" || direction === "right" ? deltaX : deltaY; if (!Number.isFinite(delta)) { clearSwipeRelease(); return undefined; } const directionalDelta = direction === "left" || direction === "up" ? -delta : delta; if (directionalDelta <= 0) { clearSwipeRelease(); return false; } const velocity = direction === "left" || direction === "right" ? velocityX : velocityY; const directionalVelocity = direction === "left" || direction === "up" ? -velocity : velocity; if ( directionalVelocity >= FAST_SWIPE_VELOCITY && directionalDelta > 0 ) { startSwipeRelease(direction); return true; } const shouldClose = directionalDelta > baseThreshold; if (shouldClose) startSwipeRelease(direction); else clearSwipeRelease(); return shouldClose; } const pts = snapPoints(); if (!pts || pts.length === 0) { return resolveReleaseWithoutSnapPoints(); } if (swipeDirection() !== "down" && swipeDirection() !== "up") { clearSwipeRelease(); return undefined; } if (!popupHeight() || resolvedSnapPoints().length === 0) { clearSwipeRelease(); return undefined; } const dragDelta = swipeDirection() === "down" ? deltaY : -deltaY; if (!Number.isFinite(dragDelta)) { clearSwipeRelease(); return undefined; } const dragDirection = Math.sign(dragDelta); const releaseDirectionalVelocity = swipeDirection() === "down" ? releaseVelocityY : -releaseVelocityY; const fallbackDirectionalVelocity = swipeDirection() === "down" ? velocityY : -velocityY; let resolvedDirectionalVelocity = Number.isFinite( releaseDirectionalVelocity, ) ? releaseDirectionalVelocity : fallbackDirectionalVelocity; if ( dragDirection !== 0 && Math.abs(dragDelta) >= MIN_SWIPE_THRESHOLD && Number.isFinite(resolvedDirectionalVelocity) ) { const velocityDirection = Math.sign(resolvedDirectionalVelocity); if (velocityDirection !== 0 && velocityDirection !== dragDirection) { resolvedDirectionalVelocity = fallbackDirectionalVelocity; } } const currentOffset = activeSnapPointOffset() ?? 0; const dragTargetOffset = clamp( currentOffset + dragDelta, 0, popupHeight(), ); const velocityOffset = Number.isFinite(resolvedDirectionalVelocity) && Math.abs(resolvedDirectionalVelocity) >= SNAP_VELOCITY_THRESHOLD ? clamp( resolvedDirectionalVelocity, -MAX_SNAP_VELOCITY, MAX_SNAP_VELOCITY, ) * SNAP_VELOCITY_MULTIPLIER : 0; const targetOffset = snapToSequentialPoints() ? dragTargetOffset : clamp(dragTargetOffset + velocityOffset, 0, popupHeight()); const snapPointEventDetails = createChangeEventDetails( REASONS.swipe, event, ); const closeFromSnapPoints = (): true => { pendingSwipeCloseSnapPoint = activeSnapPoint(); setActiveSnapPoint?.(null, snapPointEventDetails); startSwipeRelease(swipeDirection()); return true; }; // Sequential snap points: pick the nearest snap to the projected // target, optionally advancing one step on a fast directional // flick, and close if the close edge is nearer than any snap. function resolveSequentialSnapRelease(): boolean { const orderedSnapPoints = [...resolvedSnapPoints()].sort( (first, second) => first.offset - second.offset, ); const firstOrdered = orderedSnapPoints[0]; if (!firstOrdered) { clearSwipeRelease(); return false; } let currentIndex = 0; let closestDistance = Math.abs(currentOffset - firstOrdered.offset); for (let index = 1; index < orderedSnapPoints.length; index += 1) { const p = orderedSnapPoints[index]; if (!p) continue; const distance = Math.abs(currentOffset - p.offset); if (distance < closestDistance) { closestDistance = distance; currentIndex = index; } } let targetSnapPoint = firstOrdered; closestDistance = Math.abs(targetOffset - targetSnapPoint.offset); for (const snapPoint of orderedSnapPoints) { const distance = Math.abs(targetOffset - snapPoint.offset); if (distance < closestDistance) { closestDistance = distance; targetSnapPoint = snapPoint; } } const velocityDirection = Math.sign(resolvedDirectionalVelocity); const shouldAdvance = dragDirection !== 0 && velocityDirection !== 0 && velocityDirection === dragDirection && Math.abs(resolvedDirectionalVelocity) >= SNAP_VELOCITY_THRESHOLD; let effectiveTargetOffset = targetOffset; if (shouldAdvance) { const adjacentIndex = clamp( currentIndex + dragDirection, 0, orderedSnapPoints.length - 1, ); if (adjacentIndex !== currentIndex) { const adjacentPoint = orderedSnapPoints[adjacentIndex]; if (adjacentPoint) { const shouldForceAdjacent = dragDirection > 0 ? targetOffset < adjacentPoint.offset : targetOffset > adjacentPoint.offset; if (shouldForceAdjacent) { targetSnapPoint = adjacentPoint; effectiveTargetOffset = adjacentPoint.offset; } } } else if (dragDirection > 0) { return closeFromSnapPoints(); } } const closeOffset = popupHeight(); const closeDistance = Math.abs(effectiveTargetOffset - closeOffset); const snapDistance = Math.abs( effectiveTargetOffset - targetSnapPoint.offset, ); if (closeDistance < snapDistance) { return closeFromSnapPoints(); } setActiveSnapPoint?.(targetSnapPoint.value, snapPointEventDetails); clearSwipeRelease(); return false; } if (snapToSequentialPoints()) { return resolveSequentialSnapRelease(); } // Free snap points: close on a fast forward flick, otherwise snap // to the nearest point unless the close edge is nearer. function resolveFreeSnapRelease(): boolean | undefined { if ( resolvedDirectionalVelocity >= FAST_SWIPE_VELOCITY && dragDelta > 0 ) { return closeFromSnapPoints(); } const rspList = resolvedSnapPoints(); const firstSp = rspList[0]; if (!firstSp) { clearSwipeRelease(); return undefined; } let closestSnapPoint = firstSp; let closestDistance = Math.abs( targetOffset - closestSnapPoint.offset, ); for (const snapPoint of rspList) { const distance = Math.abs(targetOffset - snapPoint.offset); if (distance < closestDistance) { closestDistance = distance; closestSnapPoint = snapPoint; } } const closeOffset = popupHeight(); const closeDistance = Math.abs(targetOffset - closeOffset); if (closeDistance < closestDistance) { return closeFromSnapPoints(); } setActiveSnapPoint?.(closestSnapPoint.value, snapPointEventDetails); clearSwipeRelease(); return false; } return resolveFreeSnapRelease(); }, onDismiss(event) { const backdropElement = root.backdropRef.current; if (backdropElement) { backdropElement.style.setProperty( DrawerBackdropCssVars.swipeProgress, "0", ); backdropElement.style.removeProperty(DrawerPopupCssVars.height); } const dismissEventDetails = createChangeEventDetails( REASONS.swipe, event, undefined, { preventUnmountOnClose() {} }, ); drawer.setOpen(false, dismissEventDetails); if (dismissEventDetails.isCanceled) { const pendingSnapPoint = pendingSwipeCloseSnapPoint; if (pendingSnapPoint !== undefined) { setActiveSnapPoint?.( pendingSnapPoint ?? null, createChangeEventDetails(REASONS.swipe, event), ); } pendingSwipeCloseSnapPoint = undefined; resetSwipeFn?.(); clearSwipeRelease(); return; } // Controlled mode — the effective open state may not have changed // yet. Rely on rAF to let the parent's state flush, then check if // the close was accepted. if (root.open()) { const savedEvent = event; controlledDismissFrame.request(() => { if (root.open()) { const pendingSnapPoint = pendingSwipeCloseSnapPoint; if (pendingSnapPoint !== undefined) { setActiveSnapPoint?.( pendingSnapPoint ?? null, createChangeEventDetails(REASONS.swipe, savedEvent), ); } pendingSwipeCloseSnapPoint = undefined; clearSwipeRelease(); resetSwipeFn?.(); } else { pendingSwipeCloseSnapPoint = undefined; } }); return; } pendingSwipeCloseSnapPoint = undefined; setSwipeDismissed(true); }, }), ); const swipePointerProps = swipe.getPointerProps; const swipeTouchProps = swipe.getTouchProps; resetSwipeFn = swipe.reset; // Capture-phase touchmove listener on the document: arbitrates between // native scrolling inside drawer content and swipe-dismiss at scroll // edges. `{ passive: false, capture: true }` — we may `preventDefault`. createEffect( () => ({ viewportEl: root.viewportElement(), popupEl: root.popupElement(), open: root.open(), mounted: root.mounted(), nestedOpen: nestedDrawerOpen(), axis: scrollAxis(), isVertical: isVerticalScrollAxis(), dir: swipeDirection(), }), ({ viewportEl, popupEl, open, mounted, nestedOpen, axis, isVertical, dir, }) => { const rootElement = viewportEl ?? popupEl; if (!rootElement) return undefined; const resolvedRootElement = rootElement; const doc = ownerDocument(resolvedRootElement); const win = ownerWindow(doc); const handleNativeTouchMove = (event: TouchEvent): void => { if (ignoreTouchSwipe) return; const touchState = touchScrollState; const touch = event.touches[0]; if (!touch || !touchState) return; const drawerAxisDelta = isVertical ? touch.clientY - touchState.lastY : touch.clientX - touchState.lastX; if (isEventOnRangeInput(event, win)) { touchState.allowSwipe = false; updateTouchScrollPosition(touchState, touch); return; } if (event.touches.length === 2) { updateTouchScrollPosition(touchState, touch); return; } const allowTouchMove = shouldIgnoreSwipeForTextSelection( doc, resolvedRootElement, ); if (allowTouchMove || !open || !mounted || nestedOpen) { updateTouchScrollPosition(touchState, touch); return; } if ( preserveNativeCrossAxisScrollOnMove(touchState, touch, isVertical) ) { updateTouchScrollPosition(touchState, touch); return; } const scrollTarget = touchState.scrollTarget; if ( !scrollTarget || scrollTarget === doc.documentElement || scrollTarget === doc.body ) { if (event.cancelable) event.preventDefault(); updateTouchScrollPosition(touchState, touch); return; } const hasScrollableContent = hasScrollableContentOnAxis( scrollTarget, axis, ); if (!hasScrollableContent) { if (event.cancelable) event.preventDefault(); updateTouchScrollPosition(touchState, touch); return; } const delta = drawerAxisDelta; if (delta !== 0) { const canSwipeFromScrollEdge = canSwipeFromScrollEdgeOnMove( scrollTarget, axis, dir, delta, ); if (!touchState.allowSwipe) { if (!event.cancelable) { touchState.allowSwipe = false; } else if (canSwipeFromScrollEdge) { touchState.allowSwipe = true; event.preventDefault(); } else { touchState.allowSwipe = false; } } else if (event.cancelable) { event.preventDefault(); } } updateTouchScrollPosition(touchState, touch); }; doc.addEventListener("touchmove", handleNativeTouchMove, { passive: false, capture: true, }); return () => { doc.removeEventListener("touchmove", handleNativeTouchMove, { capture: true, }); }; }, ); // Snap-point-progress mirror effect — matches React's second useEffect. createEffect( () => ({ range: snapPointRange(), swiping: swipe.swiping(), open: root.open(), nested: root.nested(), progress: snapPointProgress(), frontmost: frontmostHeight(), }), ({ range, swiping, open, nested, progress }) => { if (!range || swiping) return; const resolvedProgress = !open || nested ? 0 : (progress ?? 0); applySwipeProgress({ resolvedProgress, shouldTrackProgress: true, notifyParent: false, }); }, ); // Parent swipe-progress reset on close / unmount. createEffect( () => root.open(), (open) => { if (!notifyParentSwipeProgressChange) return undefined; if (!open) notifyParentSwipeProgressChange(0); return () => { notifyParentSwipeProgressChange(0); }; }, ); // Reset swipe state whenever the drawer re-opens. createEffect( () => root.open(), (open) => { if (open) { swipe.reset(); clearSwipeRelease(); } }, ); onCleanup(() => { setBackdropSwipingAttribute(root.backdropRef.current, false); finishNestedSwipe(); }); const state = createMemo(() => ({ open: root.open(), transitionStatus: root.transitionStatus(), nested: root.nested(), nestedDialogOpen: root.nestedOpenDialogCount() > 0, })); const setViewportRef = (el: HTMLDivElement | null): void => { root.setViewportElement(el); }; const swipeProviderValue: DrawerViewportContextValue = { swiping: swipe.swiping, getDragStyles: swipe.getDragStyles, swipeStrength: swipeRelease, setSwipeDismissed(dismissed: boolean) { setSwipeDismissedElements( root.popupRef.current, root.backdropRef.current, dismissed, ); }, }; function resetTouchTrackingState(): void { ignoreTouchSwipe = false; touchScrollState = null; lastPointerType = ""; ignoreNextTouchStartFromPen = false; } const internal = { role: "presentation", get hidden() { return !root.mounted(); }, get style(): JSX.CSSProperties { return { "pointer-events": !root.open() ? "none" : undefined, } as JSX.CSSProperties; }, get "data-open"() { return state().open ? "" : undefined; }, get "data-closed"() { return state().open ? undefined : ""; }, get "data-starting-style"() { return state().transitionStatus === "starting" ? "" : undefined; }, get "data-ending-style"() { return state().transitionStatus === "ending" ? "" : undefined; }, get [DrawerViewportDataAttributes.nested]() { return state().nested ? "" : undefined; }, onPointerDown(event: PointerEvent) { lastPointerType = event.pointerType; ignoreNextTouchStartFromPen = event.pointerType === "pen"; if (!root.open() || !root.mounted() || nestedDrawerOpen()) return; const doc = ownerDocument(event.currentTarget as Node); const elementAtPoint = getElementAtPoint( doc, event.clientX, event.clientY, ); if ( isSwipeIgnoredTarget(elementAtPoint) || isDrawerContentTarget(elementAtPoint) ) { return; } if (event.pointerType === "touch") return; swipePointerProps().onPointerDown?.(event); }, onPointerMove(event: PointerEvent) { if (event.pointerType === "touch") return; swipePointerProps().onPointerMove?.(event); }, onPointerUp(event: PointerEvent) { if (lastPointerType === event.pointerType) lastPointerType = ""; if (event.pointerType === "touch") return; swipePointerProps().onPointerUp?.(event); }, onPointerCancel(event: PointerEvent) { if (lastPointerType === event.pointerType) lastPointerType = ""; if (event.pointerType === "touch") return; swipePointerProps().onPointerCancel?.(event); }, onTouchStart(event: TouchEvent) { const startedFromPenPointerDown = lastPointerType === "pen" && ignoreNextTouchStartFromPen; if (startedFromPenPointerDown) { ignoreNextTouchStartFromPen = false; ignoreTouchSwipe = false; touchScrollState = null; return; } if (!root.open() || !root.mounted() || nestedDrawerOpen()) { ignoreTouchSwipe = false; touchScrollState = null; return; } const touch = event.touches[0]; if (!touch) return; if (isReactTouchEventOnRangeInput(event)) { ignoreTouchSwipe = false; touchScrollState = null; return; } const doc = ownerDocument(event.currentTarget as Node); const elementAtPoint = getElementAtPoint( doc, touch.clientX, touch.clientY, ); ignoreTouchSwipe = isSwipeIgnoredTarget(elementAtPoint); if (ignoreTouchSwipe) { touchScrollState = null; return; } const rootElement = root.viewportElement() ?? root.popupElement(); const eventTarget = getTarget(event); const target = eventTarget instanceof Element ? (eventTarget as Element) : null; if (rootElement && target && !contains(rootElement, target)) { ignoreTouchSwipe = true; touchScrollState = null; return; } let scrollTarget: HTMLElement | null = null; let hasCrossAxisScrollableContent = false; if (rootElement && target) { scrollTarget = findScrollableTouchTarget( target, rootElement, scrollAxis(), ); hasCrossAxisScrollableContent = findScrollableTouchTarget(target, rootElement, crossScrollAxis()) != null; } let allowSwipe: boolean | null = null; if (scrollTarget) { const canSwipeFromEdge = isAtSwipeStartEdge( scrollTarget, scrollAxis(), swipeDirection(), ); allowSwipe = canSwipeFromEdge ? null : false; } touchScrollState = { startX: touch.clientX, startY: touch.clientY, lastX: touch.clientX, lastY: touch.clientY, scrollTarget, hasCrossAxisScrollableContent, allowSwipe, preserveNativeCrossAxisScroll: false, }; swipeTouchProps().onTouchStart?.(event); }, onTouchMove(event: TouchEvent) { if (ignoreTouchSwipe) return; if (isReactTouchEventOnRangeInput(event)) return; const s = touchScrollState; if (s?.preserveNativeCrossAxisScroll) return; if ( s?.allowSwipe === false || (s?.scrollTarget != null && !s.allowSwipe) ) { return; } swipeTouchProps().onTouchMove?.(event); }, onTouchEnd(event: TouchEvent) { resetTouchTrackingState(); swipeTouchProps().onTouchEnd?.(event); }, onTouchCancel(event: TouchEvent) { resetTouchTrackingState(); swipeTouchProps().onTouchCancel?.(event); }, get children() { return ( {props.children as JSX.Element} ); }, }; const userClassStyle = resolveClassStyle(props, () => state()); const userProps = omit(props, "as", "class", "style", "attrs", "children"); let merged: Record = mergeRootProps( internal, userClassStyle, ); merged = mergeRootProps(merged, userProps); const Tag = dynamic(() => props.as ?? "div"); return ( ); }