// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file usePointerDragScroll.ts * @input A scroll container ref * @output Lets a mouse drag the container's contents the way a finger does * @position Internal behavior hook; consumed by Wheel.tsx * * ## Why this exists * * A wheel is a scroll container, which is what makes it feel right under a * finger: the platform supplies panning, momentum, rubber-banding and the * snap. A mouse gets none of that. Browsers do not drag-scroll an overflow * container — press and pull on one and nothing happens at all, which is * exactly what a wheel invites you to try. * * That leaves the mouse with the scroll wheel and a click on a visible row. * Both work, and neither is the gesture the control is shaped like. It * matters more than "mouse users are not the target": the touch surface is * reviewed, themed and screenshotted on desktop browsers, where a wheel that * ignores the pointer reads as broken rather than as touch-only. * * ## Mouse only, deliberately * * Touch and pen already pan natively, and this hook would be strictly worse * than what they get — no momentum, no rubber-banding, no compositor * threading. So it ignores every pointer type but `mouse` and leaves those * gestures untouched. * * ## Snap has to be suspended for the duration * * `scroll-snap-type: y mandatory` re-snaps after every scroll, including a * programmatic one, so a drag that assigns `scrollTop` is fighting it on * every frame. Measured: dragging 5px at a time with mandatory snap on, 7 of * 8 steps were yanked back to a snap position — the wheel sticks to a row and * then jumps a whole one. The drag therefore suspends snapping, and restores * it once the release has settled somewhere legal. * * SYNC: When modified, update: * - /packages/core/src/DateInput/Wheel.tsx * - /packages/core/src/DateInput/DateInputTouch.test.tsx */ import { useEffect, useRef } from 'react'; /** * Movement below this stays a click. A mouse shifts a pixel or two under the * press of the button itself, and a wheel row is a click target. */ export const DRAG_SLOP = 4; /** How long to wait for a release's settle before restoring snapping. */ const SETTLE_FALLBACK_MS = 260; /** * Let a mouse drag `ref`'s contents, on a container that already scrolls and * snaps for touch. * * @param ref - the scroll container * @param isEnabled - skip while the wheel is hidden; a hidden panel keeps its * layout box, so its listeners would otherwise still be live */ export function usePointerDragScroll(ref: React.RefObject, isEnabled: boolean = true): void { useEffect(() => { const element = ref.current; if (element == null || !isEnabled) { return; } let pointerId: number | null = null; let originY = 0; let originScrollTop = 0; let isDragging = false; // Survives from the drag's end to the click it produces, which is the // whole reason it is not just `isDragging`. let wasDragged = false; let restoreTimer: number | undefined; const restoreSnap = () => { window.clearTimeout(restoreTimer); element.removeEventListener('scrollend', restoreSnap); // Back to the stylesheet's value rather than a hardcoded one, so this // cannot drift from Wheel's own `scrollSnapType`. element.style.removeProperty('scroll-snap-type'); }; const suspendSnap = () => { window.clearTimeout(restoreTimer); element.removeEventListener('scrollend', restoreSnap); element.style.scrollSnapType = 'none'; }; const onPointerDown = (event: PointerEvent) => { // Touch and pen pan natively, and better. Secondary buttons are for the // context menu. if (event.pointerType !== 'mouse' || event.button !== 0) { return; } // Keep the press away from BottomSheet, which starts its own // drag-to-dismiss from a `pointerdown` on its body and CAPTURES the // pointer for it. Two things go wrong if it gets there first: the drag // below is fighting the sheet for the same gesture, and — measured on // the calendar too, so this is not new — every later pointer event // retargets to the sheet body, which means a click that wobbles more // than a pixel or two never reaches the row under it and selects // nothing at all. // // A native listener during the real bubble phase is what it takes: // the sheet's handler is a React prop, delegated at the root container, // which is an ANCESTOR of this element. Same reasoning as // useOwnScrollGesture, and the same trade — mouse drag-to-dismiss now // starts from the grab handle, the header or the scrim rather than // from inside a wheel, which is where a picker sheet puts it anyway. event.stopPropagation(); pointerId = event.pointerId; originY = event.clientY; originScrollTop = element.scrollTop; isDragging = false; // Not captured yet: a press that never moves has to stay a click on the // row under it, and capturing here would retarget that click. }; const onPointerMove = (event: PointerEvent) => { if (pointerId !== event.pointerId) { return; } const delta = event.clientY - originY; if (!isDragging) { if (Math.abs(delta) < DRAG_SLOP) { return; } isDragging = true; element.setPointerCapture(event.pointerId); suspendSnap(); // Dragging across rows would otherwise select their text, leaving the // wheel looking highlighted after the gesture. element.style.userSelect = 'none'; } // Content follows the hand: pull up and the list moves up, which means // scrolling further down it. element.scrollTop = originScrollTop - delta; }; const onPointerEnd = (event: PointerEvent) => { if (pointerId !== event.pointerId) { return; } pointerId = null; if (!isDragging) { return; } isDragging = false; wasDragged = true; element.style.removeProperty('user-select'); if (element.hasPointerCapture(event.pointerId)) { element.releasePointerCapture(event.pointerId); } // The release lands mid-row. Glide to the nearest one and only then let // snapping back on — restoring it here instead would jump the distance // rather than travel it, and a jump is what this hook exists to avoid. const rowHeight = firstRowHeight(element); if (rowHeight > 0) { const nearest = Math.round(element.scrollTop / rowHeight) * rowHeight; element.scrollTo({ top: nearest, behavior: 'smooth' }); } // `scrollend` is the honest signal; the timer covers browsers that do // not fire it, and the case where the glide had nowhere to go. // Not `{once: true}`: a listener that never fires is never removed by // it, so a component unmounted mid-glide would leave one attached. // `restoreSnap` removes it itself, whichever of the two gets there // first, and the cleanup below calls `restoreSnap` on unmount. // // eslint-disable-next-line @eslint-react/web-api-no-leaked-event-listener -- removed by restoreSnap, which the cleanup calls element.addEventListener('scrollend', restoreSnap); restoreTimer = window.setTimeout(restoreSnap, SETTLE_FALLBACK_MS); }; // A drag ends over whichever row the mouse happens to be on, and that row // would take the click as a selection. Swallow it — but only the one, and // only after a real drag, so an ordinary click still selects. const onClickCapture = (event: MouseEvent) => { if (!wasDragged) { return; } wasDragged = false; event.stopPropagation(); event.preventDefault(); }; element.addEventListener('pointerdown', onPointerDown); element.addEventListener('pointermove', onPointerMove); element.addEventListener('pointerup', onPointerEnd); element.addEventListener('pointercancel', onPointerEnd); element.addEventListener('click', onClickCapture, { capture: true }); return () => { element.removeEventListener('pointerdown', onPointerDown); element.removeEventListener('pointermove', onPointerMove); element.removeEventListener('pointerup', onPointerEnd); element.removeEventListener('pointercancel', onPointerEnd); element.removeEventListener('click', onClickCapture, { capture: true }); restoreSnap(); }; }, [ref, isEnabled]); } /** * The pitch of the rows, read off the first one. Taken from layout rather * than the token so a themed `--date-input-touch-wheel-item-size` is honoured * without this hook having to know the variable exists. */ function firstRowHeight(element: HTMLElement): number { const row = element.querySelector('[role="option"]'); return row instanceof HTMLElement ? row.offsetHeight : 0; }