Balayer sur ordinateur : un flick de souris qui pagine le menu

Le glisser pour défiler bouge la rangée en 1:1 avec le curseur. Voici l’autre geste de souris : un flick. Appuyez, bougez d’au moins 50px, relâchez — et le menu glisse d’une page dans cette direction via scrollNext ou scrollPrev. La rangée ne suit pas du tout le pointeur ; le glissement est le défilement programmatique fluide de la bibliothèque, qui donne au relâchement sa sensation d’inertie.
#01
#02
#03
#04
#05
#06
#07
#08
#09
#10
#11
#12
#13
#14
#15
#16

Appuyez n’importe où sur la rangée, bougez à gauche ou à droite d’au moins 50px et relâchez — le menu glisse d’une page. Les mouvements plus courts ne font rien.

Comment ça marche

Un hook useSwipe renvoie les trois props de souris currifiées que ScrollMenu attend — chacune reçoit l’objet API et renvoie un gestionnaire d’événement ordinaire. onMouseDown ancre le clientX du pointeur dans une ref, onMouseMove continue d’écraser la coordonnée de fin, et onMouseUp compare les deux : une différence horizontale au-delà de minSwipeDistance (50px) appelle apiObj.scrollNext() pour un flick vers la gauche ou apiObj.scrollPrev() pour un vers la droite.

Pourquoi les clics n’ont pas besoin de traitement spécial

Dans l’exemple de glissement, relâcher un glissement au-dessus d’une carte la cliquerait, donc un drapeau dragging doit survivre au geste d’un frame. Un flick contourne tout le problème : sous le seuil de 50px, onMouseUp ne fait rien, donc un clic est juste un clic — et au-delà, le pointeur a de toute façon quitté la carte sur laquelle il a appuyé. Pas de drapeaux, pas de gestionnaires supprimés.

Ce que la story ajoute pour le toucher et la molette

La story fixe aussi le défilement tactile natif : React 18+ enregistre les écouteurs touchmove comme passifs, donc preventDefault ne fonctionne que depuis un écouteur non passif. Un effet atteint le conteneur de défilement via apiRef (ref.current.scrollContainer.current) et en attache un avec { passive: false }. Son gestionnaire onWheel pagine aussi le menu, avec une heuristique — un deltaX non nul ou un petit deltaY est supposé être un trackpad et laissé tranquille.

Notes

Source complète

Complète et prête à copier-coller — c’est le fichier exact derrière la version Storybook modifiable en direct.

SwipeDesktop.source.tsx
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();
  }
}

Exemples associés

Tous les exemples (21)