Ajouter un indicateur de progression de défilement à un menu horizontal
Faites défiler la rangée, glissez-la ou utilisez les flèches — la barre se remplit page par page et le compteur montre où vous êtes.
Modifier cet exemple en direct dans Storybook
Comment ça marche
L’indicateur est passé comme prop Footer, donc ScrollMenu le rend à l’intérieur du menu où VisibilityContext est disponible. Du contexte, il prend items — la carte derrière le suivi de visibilité — et s’abonne avec items.subscribe(’onUpdate’, cb). Cet événement se déclenche à chaque callback d’IntersectionObserver, donc la story le déboune (un timeout plus requestAnimationFrame) avant de lire items.getVisible().
Des éléments visibles au numéro de page
Le nombre d’éléments visibles est la taille de page. Le total de pages est Math.ceil(items.size / visibleItemsLen) ; la page courante vient de l’index de la dernière entrée visible. La story les transforme en boutons de page cliquables — chacun appelle scrollToItem(getItemByIndex(itemInd)), adressant un élément par position sans connaître son id — et déduit les compteurs d’éléments à gauche et à droite des mêmes nombres. La barre de la démo n’est que currentPage / totalPages en pourcentage de largeur.
Notes
- Rien n’est mesuré en pixels — la mathématique tourne entièrement sur les données de visibilité, donc elle continue de fonctionner quand les largeurs d’éléments diffèrent.
- Redimensionnez le viewport et la taille de page suit : plus d’éléments tiennent,
getVisible()renvoie plus d’entrées, et le nombre de pages se recalcule à la mise à jour suivante. - L’effet renvoie un nettoyage qui appelle
items.unsubscribeet vide le minuteur en attente — sautez-le et un pied de page démonté continue d’être appelé. - Avant le premier rapport de l’observateur,
getVisible()est vide ; la story renvoienulljusque-là, et la démo peint une piste vide.
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';
export function Progress() {
const [items] = React.useState(() => getItems(30));
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>
<NoScrollbar>
<ScrollMenu
LeftArrow={LeftArrow}
RightArrow={RightArrow}
onWheel={onWheel}
Footer={Footer}
>
{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>
);
}
const Footer = () => {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const { items } = visibility;
const [visible, setVisible] = React.useState<[string, { index: string }][]>(
[],
);
// Need to update this component
// listening to 'onUpdate' event with some debounce.
// 'onInit' covers the very first classification (it fires instead of
// 'onUpdate' there), and the immediate call picks the state up when the
// first batch landed before this effect ran — otherwise the footer would
// wait for the next scroll to appear.
React.useEffect(() => {
if (items) {
let timer: ReturnType<typeof setTimeout>;
const cb = () => {
clearTimeout(timer);
timer = setTimeout(
() => requestAnimationFrame(() => setVisible(items.getVisible())),
200,
);
};
items.subscribe('onInit', cb);
items.subscribe('onUpdate', cb);
cb();
return () => {
clearTimeout(timer);
items.unsubscribe('onInit', cb);
items.unsubscribe('onUpdate', cb);
};
}
}, [items]);
if (!visible.length) {
return null;
}
const total = items?.size;
const visibleItemsLen = visible.length;
const totalPages = Math.ceil(total / visibleItemsLen);
const lastVisibleInd = +visible.slice(-1)[0][1].index;
const currentPage = Math.ceil(lastVisibleInd / visibleItemsLen);
const pages = Array(totalPages)
.fill(1)
.map((_, ind) => ind + 1);
const itemsLeft = (currentPage - 1) * visibleItemsLen;
const itemsRight = total - visibleItemsLen * currentPage;
const scrollToPage = (page: number) => {
const itemInd = page * visibleItemsLen - 1;
visibility.scrollToItem(visibility.getItemByIndex(itemInd));
};
return (
<>
<FooterContainer>
{pages.map((page) => (
<button
key={page}
data-testid={`page-${page}`}
onClick={() => scrollToPage(page)}
onKeyDown={(ev) => {
if (ev.code === 'Space') {
scrollToPage(page);
}
}}
className={`page-btn ${page === currentPage ? 'active' : ''}`}
>
{page}
</button>
))}
</FooterContainer>
<FooterContainer>
<div>
<div>
Items on the left: <span data-testid="items-left">{itemsLeft}</span>
</div>
<div>
Items on the right:{' '}
<span data-testid="items-right">{itemsRight}</span>
</div>
</div>
</FooterContainer>
</>
);
};
const FooterContainer = styled('div')({
'&': {
display: 'flex',
justifyContent: 'center',
gap: '5px',
margin: '10px',
},
'& button.active': {
color: 'red',
},
});
export default Progress;
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 = (count: number = 10) =>
Array(count)
.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();
}
}