Un menú de desplazamiento vertical — el mismo componente, girado con CSS
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.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.
Edita este ejemplo en vivo en Storybook
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
- La única dimensión fija es la altura inline del panel; el
height: 100%del envoltorio la lleva hasta el contenedor de desplazamiento. - La rueda y el tacto desplazan la columna de forma nativa —
overflow-y: autola hace un contenedor de desplazamiento real; las flechas son conveniencia, no mecanismo. - El segundo argumento de
scrollPrev/scrollNextes la posicióninline(horizontal) — a los menús verticales les importablock, por eso la historia lo pasa explícitamente.
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.
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) }));