Faites défiler le menu à la molette — sans faire défiler la page
onWheel qui transforme les tics de molette en pagination, et un écouteur natif non passif qui empêche la page de bouger en dessous. La seconde moitié ne peut pas être faite avec React seul.Posez le pointeur sur la rangée et tournez la molette : la rangée pagine, la page reste immobile. Sortez de la rangée et la molette refait défiler la page.
Modifier cet exemple en direct dans Storybook
Transformer la molette en pagination
La prop onWheel de ScrollMenu est appelée avec l’objet API et l’événement de molette. Une vraie molette de souris signale des deltas uniquement en Y par pas grossiers, donc le gestionnaire appelle scrollNext quand deltaY est négatif et scrollPrev sinon — chaque tic pagine la rangée. Avant tout cela, il vérifie si l’événement ressemble à un geste de trackpad : n’importe quel deltaX, ou un deltaY inférieur à 15.
Pourquoi le verrouillage de page a besoin d’un écouteur natif
Appeler preventDefault dans le gestionnaire React serait le moyen évident d'arrêter la page — et il ne fait rien en silence, parce que React enregistre les écouteurs de molette comme passifs, et qu'un écouteur passif n'a pas le droit d'annuler l'événement. Alors usePreventBodyScroll contourne React : au mouseenter, il exécute document.addEventListener('wheel', preventDefault, { passive: false }), au mouseleave il retire l'écouteur. Pendant que le pointeur est sur le menu, chaque événement de molette remonte jusqu'à document et y voit son action par défaut — faire défiler la page — annulée. Un nettoyage de useEffect appelle enableScroll au démontage, donc la page ne peut jamais rester verrouillée.
L’échappatoire du trackpad
Le défilement à deux doigts arrive aussi comme des événements de molette, et le conteneur défile nativement avec eux — l’écouteur de document tuerait cela. Pour les événements correspondant à l’heuristique de trackpad, le gestionnaire appelle stopPropagation et renvoie : l’événement n’atteint jamais l’écouteur de document, donc le défilement natif survit. Il n’y a pas de moyen fiable de détecter un trackpad ; l’heuristique du delta est le pari honnête de la story, et il tient en pratique.
Notes
- Les navigateurs ont rendu passifs par défaut les écouteurs de molette au niveau du document précisément pour que les pages ne saccadent pas le défilement —
passive: falseest l’exclusion explicite qui rendpreventDefaultde nouveau légal. - Molette vers le haut pagine en avant et molette vers le bas en arrière — c’est le mappage de la story ; échangez les branches
scrollNext/scrollPrevpour l’inverse. - Les appareils tactiles n’exécutent rien de tout cela : il n’y a pas de
mouseenter, et balayer la rangée est du défilement natif depuis le début. - Le verrou n’existe qu’entre
mouseenteretmouseleave, donc le reste de la page défile normalement dès que le pointeur quitte le rail.
Source complète
Complète et prête à copier-coller — c’est le fichier exact derrière la version Storybook modifiable en direct.
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';
function usePreventBodyScroll() {
const preventDefault = React.useCallback((ev: Event) => {
ev?.preventDefault?.();
}, []);
const enableScroll = React.useCallback(() => {
document && document.removeEventListener('wheel', preventDefault, false);
}, [preventDefault]);
const disableScroll = React.useCallback(() => {
document &&
document.addEventListener('wheel', preventDefault, {
passive: false,
});
}, [preventDefault]);
React.useEffect(() => {
return enableScroll;
}, [enableScroll]);
return { disableScroll, enableScroll };
}
export function PreventBodyScroll() {
const { disableScroll, enableScroll } = usePreventBodyScroll();
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),
);
};
return (
<div style={{ height: '200vh' }}>
<div>
<NoScrollbar onMouseEnter={disableScroll} onMouseLeave={enableScroll}>
<ScrollMenu
LeftArrow={LeftArrow}
RightArrow={RightArrow}
onWheel={onWheel}
>
{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>
</div>
</div>
);
}
export default PreventBodyScroll;
const NoScrollbar = styled('div')({
'& .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(10)
.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();
}
}