Faites défiler le menu à la molette — sans faire défiler la page

Un menu horizontal sous la molette est gênant : la molette fait défiler la page et la rangée reste en place. La correction a deux moitiés — un gestionnaire 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.
#01
#02
#03
#04
#05
#06
#07
#08
#09
#10
#11
#12

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.

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

Source complète

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

PreventBodyScroll.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';

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();
  }
}

Exemples associés

Tous les exemples (21)