UNPKG

react-horizontal-scrolling-menu

Version:

Horizontal scrolling menu component for React with per-item visibility tracking. Supports mouse and touch devices, SSR-safe.

456 lines (367 loc) 12.8 kB
--- name: 'menu-interactions' description: >- Wire mouse, wheel and touch input for react-horizontal-scrolling-menu: onWheel/onScroll are plain (api, event) => void callbacks, while ALL mouse/touch props (onMouseDown, onMouseUp, onMouseMove, onMouseLeave, onTouchStart, onTouchMove, onTouchEnd) are handler factories (api) => (event) => void. Covers mouse drag-to-scroll with the DragDealer pattern and dragging guard, onMouseLeave to stop drags, body-scroll locking, touchpad-vs-wheel detection, desktop swipe. Load when adding drag-to-scroll or custom wheel behavior, or when fixing clicks firing after drags or "Unable to preventDefault inside passive event listener". metadata: type: core library: 'react-horizontal-scrolling-menu' library_version: '8.2.3' sources: - 'asmyshlyaev177/react-horizontal-scrolling-menu:README.md' - 'asmyshlyaev177/react-horizontal-scrolling-menu:src/index.tsx' - 'asmyshlyaev177/react-horizontal-scrolling-menu:stories/MouseDrag/MouseDrag.source.tsx' - 'asmyshlyaev177/react-horizontal-scrolling-menu:stories/PreventBodyScroll/PreventBodyScroll.source.tsx' --- # Pointer, Wheel and Touch Interactions ScrollMenu has two callback shapes. Getting this wrong is the #1 input bug: - `onWheel` / `onScroll` are **plain callbacks**: `(api, event) => void` - **ALL** mouse and touch props (`onMouseDown`, `onMouseUp`, `onMouseMove`, `onMouseLeave`, `onTouchStart`, `onTouchMove`, `onTouchEnd`) are **handler factories**: `(api) => (event) => void`. ScrollMenu invokes the factory with the api during render and attaches the returned function as the DOM handler. All wheel/mouse/touch handlers attach to the outermost wrapper div (so they also fire over arrows, Header and Footer); `onScroll` attaches to the scroll container itself (src/index.tsx:319-347). `api` is the same `publicApiType` object that `VisibilityContext` provides. Touch devices scroll the menu natively — no touch props are needed for basic mobile swipe. Drag-to-scroll below is the desktop "swipe" equivalent. ## Setup Both shapes wired on a minimal menu: ```tsx import React from 'react'; import { ScrollMenu, VisibilityContext, type publicApiType, } from 'react-horizontal-scrolling-menu'; import 'react-horizontal-scrolling-menu/dist/styles.css'; function Card({ itemId }: { itemId: string }) { const api = React.useContext<publicApiType>(VisibilityContext); const visible = api.useIsVisible(itemId, true); return <div style={{ width: 160, opacity: visible ? 1 : 0.5 }}>{itemId}</div>; } // Plain shape — (api, event) => void const onWheel = (api: publicApiType, ev: React.WheelEvent): void => { if (ev.deltaY < 0) api.scrollNext(); else api.scrollPrev(); }; // Factory shape — (api) => (event) => void const onMouseDown = (_api: publicApiType) => (ev: React.MouseEvent): void => { ev.preventDefault(); // block native image/link drag inside items }; const ids = ['a', 'b', 'c', 'd', 'e', 'f']; export function Menu() { return ( <ScrollMenu onWheel={onWheel} onMouseDown={onMouseDown}> {ids.map((id) => ( <Card itemId={id} key={id} /> ))} </ScrollMenu> ); } ``` ## Core Patterns ### Mouse drag-to-scroll (DragDealer) The complete pattern from the MouseDrag story: track pointer position, flip a `dragging` flag once movement exceeds 5px, scroll the container by the delta, clear the flag in `requestAnimationFrame` after mouseup so item `onClick` (which fires after the drag ends) can still see it and bail, and stop the drag via `onMouseLeave` so the menu does not keep following a cursor that left it. ```tsx import React from 'react'; import { ScrollMenu, VisibilityContext, type publicApiType, } from 'react-horizontal-scrolling-menu'; import 'react-horizontal-scrolling-menu/dist/styles.css'; class DragDealer { clicked = false; dragging = false; position = 0; dragStart = (ev: React.MouseEvent) => { this.position = ev.clientX; this.clicked = true; }; // Clear flags one frame later: item onClick fires after mouseup and must // still be able to read `dragging` to skip selection. dragStop = () => { window.requestAnimationFrame(() => { this.dragging = false; this.clicked = false; }); }; dragMove = (ev: React.MouseEvent, cb: (posDiff: number) => void) => { const newDiff = this.position - ev.clientX; const movedEnough = Math.abs(newDiff) > 5; if (this.clicked && movedEnough) { this.dragging = true; } if (this.dragging && movedEnough) { this.position = ev.clientX; cb(newDiff); } }; } function Card({ itemId, onClick, selected, }: { itemId: string; onClick: () => void; selected: boolean; }) { const api = React.useContext<publicApiType>(VisibilityContext); const visible = api.useIsVisible(itemId, true); return ( <div onClick={onClick} role="button" tabIndex={0} style={{ width: 160, height: 120, userSelect: 'none', // no text selection while dragging opacity: visible ? 1 : 0.5, background: selected ? 'lightgreen' : 'bisque', }} > {itemId} </div> ); } const ids = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']; export function MouseDragMenu() { const [selected, setSelected] = React.useState<string[]>([]); const dragState = React.useRef(new DragDealer()); const handleDrag = ({ scrollContainer }: publicApiType) => (ev: React.MouseEvent) => dragState.current.dragMove(ev, (posDiff) => { if (scrollContainer.current) { scrollContainer.current.scrollLeft += posDiff; } }); const handleItemClick = (itemId: string) => { if (dragState.current.dragging) { return; // a drag just ended — not a real click } setSelected((cur) => cur.includes(itemId) ? cur.filter((el) => el !== itemId) : [...cur, itemId], ); }; return ( <ScrollMenu onMouseDown={() => dragState.current.dragStart} onMouseUp={() => dragState.current.dragStop} onMouseMove={handleDrag} onMouseLeave={() => dragState.current.dragStop} > {ids.map((id) => ( <Card itemId={id} key={id} selected={selected.includes(id)} onClick={() => handleItemClick(id)} /> ))} </ScrollMenu> ); } ``` ### Touchpad vs mouse-wheel detection One menu page per wheel tick, while touchpad gestures fall through to native horizontal scrolling. There is no standard way to distinguish the two; the heuristic is that touchpads emit `deltaX` (or small `deltaY`), a mouse wheel emits large `deltaY` only. ```tsx import React from 'react'; import { ScrollMenu, type publicApiType, } from 'react-horizontal-scrolling-menu'; import 'react-horizontal-scrolling-menu/dist/styles.css'; function onWheel(api: publicApiType, ev: React.WheelEvent): void { const isTouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15; if (isTouchpad) { ev.stopPropagation(); // let native scroll handle the gesture return; } if (ev.deltaY < 0) { api.scrollNext(); } else { api.scrollPrev(); } } function Item({ itemId }: { itemId: string }) { return <div style={{ width: 160, height: 80 }}>{itemId}</div>; } const ids = ['a', 'b', 'c', 'd', 'e', 'f']; export function WheelMenu() { return ( <ScrollMenu onWheel={onWheel}> {ids.map((id) => ( <Item itemId={id} key={id} /> ))} </ScrollMenu> ); } ``` ### Locking body scroll while over the menu React attaches wheel listeners as passive, so `ev.preventDefault()` inside the `onWheel` prop throws. Lock the page with a non-passive document-level listener toggled on hover instead. ScrollMenu has no `onMouseEnter` prop, so the toggle lives on a wrapper div. ```tsx import React from 'react'; import { ScrollMenu, type publicApiType, } from 'react-horizontal-scrolling-menu'; import 'react-horizontal-scrolling-menu/dist/styles.css'; function usePreventBodyScroll() { const preventDefault = React.useCallback((ev: Event) => { ev.preventDefault(); }, []); const enableScroll = React.useCallback(() => { document.removeEventListener('wheel', preventDefault, false); }, [preventDefault]); const disableScroll = React.useCallback(() => { document.addEventListener('wheel', preventDefault, { passive: false }); }, [preventDefault]); // re-enable body scroll on unmount React.useEffect(() => enableScroll, [enableScroll]); return { disableScroll, enableScroll }; } const onWheel = (api: publicApiType, ev: React.WheelEvent): void => { const isTouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15; if (isTouchpad) { ev.stopPropagation(); return; } if (ev.deltaY < 0) api.scrollNext(); else api.scrollPrev(); }; function Item({ itemId }: { itemId: string }) { return <div style={{ width: 160, height: 80 }}>{itemId}</div>; } const ids = ['a', 'b', 'c', 'd', 'e', 'f']; export function LockedMenu() { const { disableScroll, enableScroll } = usePreventBodyScroll(); return ( <div onMouseEnter={disableScroll} onMouseLeave={enableScroll}> <ScrollMenu onWheel={onWheel}> {ids.map((id) => ( <Item itemId={id} key={id} /> ))} </ScrollMenu> </div> ); } ``` ## Common Mistakes ### CRITICAL Plain handler passed where factory expected Wrong: ```tsx <ScrollMenu onMouseMove={(ev) => drag(ev)}> ``` Correct: ```tsx <ScrollMenu onMouseMove={(api) => (ev) => drag(ev)}> ``` `onWheel`/`onScroll` are plain `(api, event) => void`, but ALL mouse/touch props are factories `(api) => (event) => void` — ScrollMenu calls the prop with the api during render, so a plain handler receives the api object as its "event", runs once per render, and its return value is attached as the DOM handler, which silently does nothing useful. Source: src/index.tsx:101-107,322-328; README.md props table note ### HIGH Drag-to-scroll fires item clicks on release Wrong: ```tsx <Card onClick={() => select(id)} /> /* fires after every drag */ ``` Correct: ```tsx <Card onClick={() => { if (!dragState.current.dragging) select(id); }} /> // dragging is cleared inside requestAnimationFrame after mouseUp, // so it is still true when the post-drag click fires (DragDealer.dragStop) ``` After a drag ends the browser still dispatches a click on the item under the cursor; guard click handlers with a dragging flag that is cleared one animation frame after mouseup. Source: stories/MouseDrag/MouseDrag.source.tsx (DragDealer class) ### HIGH Drag keeps scrolling after cursor leaves Wrong: ```tsx <ScrollMenu onMouseDown={() => dragState.current.dragStart} onMouseUp={() => dragState.current.dragStop} onMouseMove={handleDrag} > ``` Correct: ```tsx <ScrollMenu onMouseDown={() => dragState.current.dragStart} onMouseUp={() => dragState.current.dragStop} onMouseMove={handleDrag} onMouseLeave={() => dragState.current.dragStop} > ``` Without wiring `onMouseLeave` to end the drag, the container keeps following a mouse that is no longer over it (mouseup outside the menu is never seen). Source: https://github.com/asmyshlyaev177/react-horizontal-scrolling-menu/issues/278 (v7.1.0) ### MEDIUM preventDefault in passive wheel/touchmove listeners Wrong: ```tsx onWheel={(api, ev) => { ev.preventDefault(); api.scrollNext(); }} ``` Correct: ```tsx onWheel={(api, ev) => { const isTouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15; if (isTouchpad) return; // let native scroll handle it ev.deltaY < 0 ? api.scrollPrev() : api.scrollNext(); }} ``` React 17+ attaches wheel/touchmove as passive listeners, so `ev.preventDefault()` throws "Unable to preventDefault inside passive event listener" — body-scroll locking needs the non-passive workaround (the document-level listener pattern above, or CSS `overscroll-behavior`). Source: stories/PreventBodyScroll/PreventBodyScroll.source.tsx; stories/Simple onWheel recipe ### MEDIUM Reading positions in onScroll mid-animation Wrong: ```tsx onScroll={(api) => savePosition(api.scrollContainer.current.scrollLeft)} ``` Correct: ```tsx onUpdate={(api) => savePosition(api.scrollContainer.current?.scrollLeft ?? 0)} ``` `onScroll` fires before the scroll settles (documented in the props table), so position reads there are intermediate mid-animation values — save state from `onUpdate` or poll after the transition finishes. Source: README.md props table (onScroll fires before scroll settles); stories/SaveRestorePosition ## See also - `../menu-recipes/SKILL.md` — drag, wheel and body-scroll recipes compose these pointer callback shapes into features (autoplay, one item per scroll, save/restore position).