Un menú de desplazamiento vertical — el mismo componente, girado con CSS

No hay una prop vertical, y no hace falta: el menú es una fila flex dentro de un contenedor de desplazamiento nativo, así que apuntarlo hacia abajo es un par de sobrescrituras de CSS. El seguimiento de visibilidad, los hooks de flechas y scrollPrev/scrollNext siguen funcionando en el nuevo eje.
Tokyo
Oslo
Lima
Cairo
Sydney
Quito
Seoul
Porto
Denver
Hanoi

Pasa la rueda sobre la columna o usa las flechas — Arriba y Abajo son el Header y el Footer de ScrollMenu. Las filas se atenúan al salir de la vista.

Dos sobrescrituras y una cota de altura

La historia reestiliza dos nombres de clase de la biblioteca. El contenedor de desplazamiento recibe flex-direction: column, overflow-y: auto e height: initial en lugar del max-content por defecto; el envoltorio recibe height: 100%, así que cualquier altura fija que tenga el padre se convierte en la cota de desplazamiento. Ese es todo el modo vertical. La historia aplica las sobrescrituras con emotion; la demo de esta página pasa utilidades de Tailwind mediante las props wrapperClassName y scrollContainerClassName en su lugar — cualquier vía de estilo funciona, los nombres de clase son estables.

Las flechas se convierten en Header y Footer

Las ranuras LeftArrow/RightArrow se renderizan junto al carril — el sitio equivocado para una columna. ScrollMenu también acepta componentes Header y Footer renderizados arriba y abajo, y la historia monta ahí sus botones Arriba y Abajo. Son consumidores de VisibilityContext corrientes: useIsVisible('first', true) deshabilita Arriba en la parte superior, useIsVisible('last', false) deshabilita Abajo en la inferior. Los clics pasan un tercer argumento — scrollPrev(undefined, undefined, 'end') y scrollNext(undefined, undefined, 'start') — la posición block para scrollIntoView. 'end' deja el elemento anterior en el borde inferior (una página completa arriba); 'start' pone el siguiente en la parte superior (una página completa abajo). Con el 'nearest' por defecto, cada clic solo empujaría la siguiente fila a la vista.

Mantener el desplazamiento dentro de la columna

scrollIntoView mueve a cada ancestro desplazable de su objetivo, y la página es uno de ellos — así que un salto alineado por block dentro de una columna se lleva todo el documento. La opción que detiene el paseo es boundary, pasada en el cuarto argumento: scrollNext(undefined, undefined, 'start', { boundary }) con el propio scrollContainer.current del menú desplaza las filas y nada más. Necesita noPolyfill={false} en ScrollMenu, ya que solo el polyfill entiende boundary — la demo de arriba pasa ambas cosas. Los menús horizontales rara vez se topan con esto: su block: 'nearest' por defecto no pide a la página movimiento vertical en primer lugar.

La visibilidad no tiene eje

useIsVisible se apoya en IntersectionObserver, y la intersección se mide en ambas dimensiones — las filas informan de su estado al cruzar los bordes superior e inferior exactamente igual que los elementos horizontales lo hacen por los lados. La demo atenúa las filas fuera de vista para mostrarlo, con las primeras cuatro pintadas como visibles en el servidor mediante el argumento defaultValue del hook.

Notas

Fuente completa

Completa y lista para copiar y pegar: este es el archivo exacto que está detrás de la versión de Storybook editable en vivo.

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

Ejemplos relacionados

Todos los ejemplos (21)