Masquez les flèches sur mobile — défilement au seul toucher sur petits écrans
Ouvrez sur un téléphone, ou activez l’émulation tactile dans DevTools — les flèches disparaissent et le balayage fait tout le travail.
Modifier cet exemple en direct dans Storybook
Comment la démo masque les flèches
LeftArrow et RightArrow sont des props optionnelles — passez undefined et l’emplacement n’est pas rendu du tout, donc il n’y a rien à masquer en CSS et aucun bouton ne reste dans l’ordre de tabulation. Le commutateur est une vérification matchMedia(’(pointer: coarse)’) dans un effet : le serveur ne peut pas connaître le type de pointeur, donc le premier paint est desktop-first avec les flèches, et l’hydratation les retire une fois un pointeur grossier confirmé. Un écouteur change le garde vivant — l’émulation d’appareil de DevTools le bascule sans recharger.
Ce que la story fait au toucher
Le hook useSwipe de la story transforme le défilement libre en pagination. Les props currifiées onTouchStart, onTouchMove et onTouchEnd reçoivent chacune l’objet API ; start réinitialise la coordonnée de fin et enregistre targetTouches[0].clientX, move la suit, et end mesure la distance parcourue. Au-delà de minSwipeDistance (20px), il appelle apiObj.scrollPrev() ou apiObj.scrollNext() — une page fluide par balayage, quelle que soit la vitesse du doigt.
Supprimer le défilement tactile natif
Pour que la pagination soit le seul mouvement, le défilement du navigateur doit s’arrêter, et React 18+ enregistre les écouteurs touchmove comme passifs, où preventDefault est ignoré. L’effet de la story atteint le vrai élément de défilement via apiRef (ref.current.scrollContainer.current) et attache son propre écouteur avec { passive: false }, là où l’appel fonctionne.
Notes
- Choisissez le défaut SSR délibérément : rendre les flèches d’abord favorise les crawlers et les utilisateurs de bureau, et les appareils tactiles les perdent juste après l’hydratation.
(pointer: coarse)cible l’entrée, pas la taille d’écran — une fenêtre de bureau étroite garde ses flèches, une tablette non.- Si vous voulez seulement masquer les flèches et garder le balayage natif (le comportement de la démo), sautez l’effet
touchmovede la story — le défilement libre et les flèches masquées cohabitent bien. - Le seuil tactile est de 20px contre les 50px du flick de bureau — voyez l’exemple swipe-on-desktop pour la variante souris.
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';
export function MobileSwipeOnly() {
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 { onTouchEnd, onTouchMove, onTouchStart } = 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}
onTouchEnd={onTouchEnd}
onTouchMove={onTouchMove}
onTouchStart={onTouchStart}
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 MobileSwipeOnly;
export const useSwipe = () => {
const [touchStart, setTouchStart] = React.useState(0);
const [touchEnd, setTouchEnd] = React.useState(0);
// the required distance between touchStart and touchEnd to be detected as a swipe
const minSwipeDistance = 20;
const onTouchStart = () => (ev: React.TouchEvent) => {
setTouchEnd(0);
setTouchStart(ev.targetTouches[0].clientX);
};
const onTouchMove = () => (ev: React.TouchEvent) => {
setTouchEnd(ev.targetTouches[0].clientX);
};
const onTouchEnd = (apiObj: publicApiType) => () => {
if (!touchStart || !touchEnd) return false;
const distance = touchStart - touchEnd;
const isSwipe = Math.abs(distance) > minSwipeDistance;
const isLeftSwipe = distance < minSwipeDistance;
if (isSwipe) {
if (isLeftSwipe) {
apiObj.scrollPrev();
} else {
apiObj.scrollNext();
}
}
};
return { onTouchStart, onTouchEnd, onTouchMove };
};
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(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();
}
}