/** * Scroll-axis utilities used by the swipe-dismiss state machine. * * 1:1 port of Base UI React's `packages/react/src/utils/scrollable.ts`. * Identifies scrollable ancestors of a touch target so the swipe hook can * arbitrate between "drag to dismiss" and "scroll the container". */ export type ScrollAxis = "horizontal" | "vertical"; function isHtmlElement(node: unknown): node is HTMLElement { return ( typeof HTMLElement !== "undefined" && node != null && typeof (node as { nodeType?: unknown }).nodeType === "number" && (node as HTMLElement).nodeType === 1 && node instanceof HTMLElement ); } export function isScrollable(element: HTMLElement, axis: ScrollAxis): boolean { const ownerDoc = element.ownerDocument ?? document; const win = ownerDoc.defaultView ?? window; const style = win.getComputedStyle(element); if (axis === "vertical") { const overflowY = style.overflowY; return ( (overflowY === "auto" || overflowY === "scroll") && element.scrollHeight > element.clientHeight ); } const overflowX = style.overflowX; return ( (overflowX === "auto" || overflowX === "scroll") && element.scrollWidth > element.clientWidth ); } export function hasScrollableAncestor( target: HTMLElement, root: HTMLElement, axes: ScrollAxis[], ): boolean { let node: HTMLElement | null = target; while (node && node !== root) { for (const axis of axes) { if (isScrollable(node, axis)) return true; } node = node.parentElement; } return false; } export function findScrollableTouchTarget( target: EventTarget | null, root: HTMLElement, axis: ScrollAxis = "vertical", ): HTMLElement | null { let node = isHtmlElement(target) ? target : null; while (node && node !== root) { if (isScrollable(node, axis)) return node; node = node.parentElement; } return isScrollable(root, axis) ? root : null; }