Deslizar en escritorio: un flick de ratón que pagina el menú
scrollNext o scrollPrev. La fila no sigue al puntero en absoluto; el deslizamiento es el desplazamiento programático suave de la biblioteca, que es lo que da a la suelta su sensación de inercia.Pulsa en cualquier parte de la fila, muévete a izquierda o derecha al menos 50px y suelta — el menú se desliza una página. Los movimientos más cortos no hacen nada.
Edita este ejemplo en vivo en Storybook
Cómo funciona
Un hook useSwipe devuelve las tres props de ratón currificadas que ScrollMenu espera — cada una recibe el objeto API y devuelve un manejador de evento normal. onMouseDown ancla el clientX del puntero en una ref, onMouseMove sigue sobrescribiendo la coordenada final, y onMouseUp compara ambas: una diferencia horizontal más allá de minSwipeDistance (50px) llama a apiObj.scrollNext() para un flick hacia la izquierda o apiObj.scrollPrev() para uno hacia la derecha.
Por qué los clics no necesitan manejo especial
En el ejemplo de arrastre, soltar un arrastre sobre una tarjeta la clicaría, así que una bandera dragging tiene que sobrevivir al gesto por un frame. Un flick esquiva todo el problema: por debajo del umbral de 50px onMouseUp no hace nada, así que un clic es solo un clic — y más allá de él, el puntero ya se ha ido de la tarjeta que pulsó. Sin banderas, sin manejadores suprimidos.
Qué añade la historia para tacto y rueda
La historia también fija el paneo táctil nativo: React 18+ registra los listeners de touchmove como pasivos, así que preventDefault solo funciona desde un listener no pasivo. Un efecto llega al contenedor de desplazamiento a través de apiRef (ref.current.scrollContainer.current) y engancha uno con { passive: false }. Su manejador onWheel también pagina el menú, con una heurística — un deltaX distinto de cero o un deltaY pequeño se asume como trackpad y se deja en paz.
Notas
- Las coordenadas viven en una ref, no en estado — rastrear
mousemoveen estado re-renderizaría en cada píxel. - La demo re-ancla la coordenada final en
mousedown, de modo que una posición sobrante del gesto anterior nunca puede contar para un nuevo deslizamiento. - Afina
minSwipeDistancea gusto: más bajo es más ágil, más alto tolera clics más descuidados. La variante táctil de esta receta usa 20px.
Fuente completa
Completa y lista para copiar y pegar: este es el archivo exacto que está detrás de la versión de Storybook editable en vivo.
import 'react-horizontal-scrolling-menu/dist/styles.css';
import styled from '@emotion/styled';
import React from 'react';
import {
type publicApiType,
ScrollMenu,
VisibilityContext,
} from 'react-horizontal-scrolling-menu';
export function SwipeDesktop() {
const [items] = React.useState(() => getItems());
const [selected, setSelected] = React.useState<string[]>([]);
const isItemSelected = (id: string): boolean =>
!!selected.find((el) => el === id);
const handleItemClick = (itemId: string) => {
const itemSelected = isItemSelected(itemId);
setSelected((currentSelected: string[]) =>
itemSelected
? currentSelected.filter((el) => el !== itemId)
: currentSelected.concat(itemId),
);
};
const { onMouseDown, onMouseMove, onMouseUp } = useSwipe();
const ref = React.useRef<publicApiType>(null);
// React 18+ attaches touchmove listeners as passive, so preventDefault only
// works from a non-passive listener added manually to the scroll container.
React.useEffect(() => {
const onTouchMove = (ev: TouchEvent) => {
ev.preventDefault();
};
const node = ref.current?.scrollContainer.current;
node?.addEventListener('touchmove', onTouchMove, { passive: false });
return () => node?.removeEventListener('touchmove', onTouchMove);
}, [ref]);
return (
<NoScrollbar>
<ScrollMenu
LeftArrow={LeftArrow}
RightArrow={RightArrow}
onWheel={onWheel}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
apiRef={ref}
>
{items.map(({ id }) => (
<Card
title={id}
itemId={id} // NOTE: itemId is required for track items
key={id}
onClick={() => handleItemClick(id)}
selected={isItemSelected(id)}
/>
))}
</ScrollMenu>
</NoScrollbar>
);
}
export default SwipeDesktop;
export const useSwipe = () => {
const pos = React.useRef({ start: { x: 0, y: 0 }, end: { x: 0, y: 0 } });
// the required distance between touchStart and touchEnd to be detected as a swipe
const minSwipeDistance = 50;
const onMouseDown = () => (ev: React.MouseEvent) => {
pos.current.start = { x: ev.clientX, y: ev.clientY };
};
const onMouseMove = () => (ev: React.MouseEvent) => {
pos.current.end = { x: ev.clientX, y: ev.clientY };
};
const onMouseUp = (apiObj: publicApiType) => () => {
// disable it for native touch screen devices
// if ('ontouchstart' in window) { return false }
const horDiff = pos.current.end.x - pos.current.start.x;
// const vertDiff = pos.current.end.y - pos.current.start.y;
const toLeft = horDiff < 0 && Math.abs(horDiff) > minSwipeDistance;
const toRight = horDiff > 0 && Math.abs(horDiff) > minSwipeDistance;
// for vertical menu
// const toTop = vertDiff < 0 && Math.abs(vertDiff) > minSwipeDistance;
// const toBottom = vertDiff > 0 && Math.abs(vertDiff) > minSwipeDistance;
if (toLeft) {
apiObj.scrollNext();
}
if (toRight) {
apiObj.scrollPrev();
}
};
return { onMouseDown, onMouseMove, onMouseUp };
};
const NoScrollbar = styled('div')({
'&': {
position: 'relative',
},
'& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': {
display: 'none',
},
'& .react-horizontal-scrolling-menu--scroll-container': {
scrollbarWidth: 'none',
'-ms-overflow-style': 'none',
},
});
function LeftArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const disabled = visibility.useLeftArrowVisible();
return (
<Arrow
disabled={disabled}
onClick={() => visibility.scrollPrev()}
testId="left-arrow"
>
Left
</Arrow>
);
}
function RightArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const disabled = visibility.useRightArrowVisible();
return (
<Arrow
disabled={disabled}
onClick={() => visibility.scrollNext()}
testId="right-arrow"
>
Right
</Arrow>
);
}
function Arrow({
children,
disabled,
onClick,
className,
testId,
}: {
children: React.ReactNode;
disabled: boolean;
onClick: VoidFunction;
className?: string;
testId: string;
}) {
return (
<ArrowButton
disabled={disabled}
onClick={onClick}
className={'arrow' + `-${className}`}
data-testid={testId}
>
{children}
</ArrowButton>
);
}
const ArrowButton = styled('button')((props) => ({
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
marginBottom: '2px',
opacity: props.disabled ? '0' : '1',
userSelect: 'none',
borderRadius: '6px',
borderWidth: '1px',
}));
function Card({
onClick,
selected,
title,
itemId,
}: {
onClick: (context: publicApiType) => void;
selected: boolean;
title: string;
itemId: string;
}) {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isVisible = visibility.useIsVisible(itemId, true);
return (
<CardBody
data-cy={itemId}
onClick={() => onClick(visibility)}
onKeyDown={(ev: React.KeyboardEvent) => {
ev.code === 'Enter' && onClick(visibility);
}}
data-testid="card"
role="button"
tabIndex={0}
className="card"
visible={isVisible}
selected={selected}
>
<div className="header">
<div>{title}</div>
<div className="visible">visible: {JSON.stringify(isVisible)}</div>
<div className="selected">selected: {JSON.stringify(!!selected)}</div>
</div>
<div className="background" />
</CardBody>
);
}
const CardBody = styled('div')<{ selected?: boolean; visible?: boolean }>(
(props) => ({
border: '1px solid',
display: 'inline-block',
margin: '0 10px',
width: '160px',
userSelect: 'none',
borderRadius: '8px',
overflow: 'hidden',
'& .header': {
backgroundColor: 'white',
},
'& .visible': {
backgroundColor: props.visible ? 'transparent' : 'gray',
},
'& .background': {
backgroundColor: props.selected ? 'green' : 'bisque',
height: '200px',
},
}),
);
const getId = (index: number) => `${'test'}${index}`;
const getItems = () =>
Array(20)
.fill(0)
.map((_, ind) => ({ id: getId(ind) }));
function onWheel(apiObj: publicApiType, ev: React.WheelEvent): void {
// NOTE: no good standart way to distinguish touchpad scrolling gestures
// but can assume that gesture will affect X axis, mouse scroll only Y axis
// of if deltaY too small probably is it touchpad
const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15;
if (isThouchpad) {
ev.stopPropagation();
return;
}
if (ev.deltaY < 0) {
apiObj.scrollNext();
} else {
apiObj.scrollPrev();
}
}