Un menu à défilement vertical — le même composant, tourné par CSS

Il n’y a pas de prop vertical, et aucune n’est nécessaire : le menu est une rangée flex dans un conteneur de défilement natif, donc le pointer vers le bas est un couple de surcharges CSS. Le suivi de visibilité, les hooks de flèche et scrollPrev/scrollNext continuent tous de fonctionner sur le nouvel axe.
Tokyo
Oslo
Lima
Cairo
Sydney
Quito
Seoul
Porto
Denver
Hanoi

Passez la molette sur la colonne ou utilisez les flèches — Haut et Bas sont le Header et le Footer de ScrollMenu. Les rangées s’estompent en quittant la vue.

Deux surcharges et une borne de hauteur

La story re-style deux noms de classe de la bibliothèque. Le conteneur de défilement reçoit flex-direction: column, overflow-y: auto et height: initial à la place du max-content par défaut ; l’enveloppe reçoit height: 100%, donc toute hauteur fixe du parent devient la borne de défilement. C’est tout le mode vertical. La story applique les surcharges avec emotion ; la démo de cette page passe des utilitaires Tailwind via les props wrapperClassName et scrollContainerClassName à la place — n’importe quelle voie de style fonctionne, les noms de classe sont stables.

Les flèches deviennent Header et Footer

Les emplacements LeftArrow/RightArrow se rendent à côté du rail — le mauvais endroit pour une colonne. ScrollMenu accepte aussi des composants Header et Footer rendus au-dessus et en dessous, et la story monte ses boutons Haut et Bas là. Ce sont des consommateurs de VisibilityContext ordinaires : useIsVisible('first', true) désactive Haut en haut, useIsVisible('last', false) désactive Bas en bas. Les clics passent un troisième argument — scrollPrev(undefined, undefined, 'end') et scrollNext(undefined, undefined, 'start') — la position block pour scrollIntoView. 'end' dépose l'élément précédent au bord inférieur (une page entière vers le haut) ; 'start' met l'élément suivant en haut (une page entière vers le bas). Avec le 'nearest' par défaut, chaque clic ne ferait que pousser la rangée suivante dans la vue.

Garder le défilement dans la colonne

scrollIntoView déplace tous les ancêtres défilables de sa cible, et la page en est un — donc un saut aligné par block dans une colonne emporte tout le document. L'option qui arrête la marche est boundary, passée au quatrième argument : scrollNext(undefined, undefined, 'start', { boundary }) avec le propre scrollContainer.current du menu fait défiler les rangées et rien d'autre. Elle nécessite noPolyfill={false} sur ScrollMenu, puisque seul le polyfill comprend boundary — la démo ci-dessus passe les deux. Les menus horizontaux s'y heurtent rarement : leur block: 'nearest' par défaut ne demande aucun mouvement vertical à la page en premier lieu.

La visibilité n’a pas d’axe

useIsVisible est porté par IntersectionObserver, et l’intersection est mesurée dans les deux dimensions — les rangées signalent leur état en croisant les bords haut et bas exactement comme les éléments horizontaux le font sur les côtés. La démo estompe les rangées hors vue pour le montrer, avec les quatre premières peintes visibles côté serveur via l’argument defaultValue du hook.

Notes

Source complète

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

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

const NoScrollbar = styled('div')({
  '& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': {
    display: 'none',
  },
  // NOTE: also need to set on parent:
  // display: 'flex' and position: 'relative'
  '& .react-horizontal-scrolling-menu--wrapper': {
    height: '100%',
  },

  '& .react-horizontal-scrolling-menu--scroll-container': {
    height: 'initial',
    scrollbarWidth: 'none',
    '-ms-overflow-style': 'none',
    overflowY: 'auto',
    flexDirection: 'column',
  },
});

export function VerticalExample() {
  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 (
    <NoScrollbar>
      <ScrollMenu Header={UpArrow} Footer={DownArrow}>
        {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 VerticalExample;

function UpArrow() {
  const visibility = React.useContext<publicApiType>(VisibilityContext);
  const isFirstItemVisible = visibility.useIsVisible('first', true);

  return (
    <Arrow
      disabled={isFirstItemVisible}
      onClick={() => visibility.scrollPrev(undefined, undefined, 'end')}
      testId="up-arrow"
    >
      Up
    </Arrow>
  );
}

function DownArrow() {
  const visibility = React.useContext<publicApiType>(VisibilityContext);
  const isLastItemVisible = visibility.useIsVisible('last', false);

  return (
    <Arrow
      disabled={isLastItemVisible}
      onClick={() => visibility.scrollNext(undefined, undefined, 'start')}
      testId="down-arrow"
    >
      Down
    </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: '125px',
    },
  }),
);

const getId = (index: number) => `${'test'}${index}`;

const getItems = () =>
  Array(10)
    .fill(0)
    .map((_, ind) => ({ id: getId(ind) }));

Exemples associés

Tous les exemples (21)