Un menu à défilement vertical — le même composant, tourné par CSS
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.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.
Modifier cet exemple en direct dans Storybook
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
- La seule dimension fixe est la hauteur en ligne du panneau ; le
height: 100%de l’enveloppe la porte jusqu’au conteneur de défilement. - La molette et le toucher font défiler la colonne nativement —
overflow-y: autoen fait un vrai conteneur de défilement ; les flèches sont une commodité, pas un mécanisme. - Le deuxième argument de
scrollPrev/scrollNextest la positioninline(horizontale) — les menus verticaux se soucient deblock, c’est pourquoi la story la passe explicitement.
Source complète
Complète et prête à copier-coller — c’est le fichier exact derrière la version Storybook modifiable en direct.
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) }));