Añadir un indicador de progreso de desplazamiento a un menú horizontal
Desplaza la fila, arrástrala o usa las flechas — la barra se llena página a página y el contador muestra dónde estás.
Edita este ejemplo en vivo en Storybook
Cómo funciona
El indicador se pasa como prop Footer, así que ScrollMenu lo renderiza dentro del menú donde está disponible VisibilityContext. Del contexto toma items — el mapa detrás del seguimiento de visibilidad — y se suscribe con items.subscribe(’onUpdate’, cb). Ese evento se dispara en cada callback de IntersectionObserver, así que la historia lo hace debounce (un timeout más requestAnimationFrame) antes de leer items.getVisible().
De elementos visibles a número de página
El número de elementos visibles es el tamaño de página. El total de páginas es Math.ceil(items.size / visibleItemsLen); la página actual viene del index de la última entrada visible. La historia las convierte en botones de página clicables — cada uno llama a scrollToItem(getItemByIndex(itemInd)), dirigiéndose a un elemento por posición sin conocer su id — y deriva los contadores de elementos a la izquierda y a la derecha de los mismos números. La barra de la demo es simplemente currentPage / totalPages como porcentaje de ancho.
Notas
- Nada se mide en píxeles — la matemática corre enteramente sobre datos de visibilidad, así que sigue funcionando cuando los anchos de los elementos difieren.
- Redimensiona el viewport y el tamaño de página le sigue: caben más elementos,
getVisible()devuelve más entradas, y el número de páginas se recalcula en la siguiente actualización. - El efecto devuelve una limpieza que llama a
items.unsubscribey limpia el temporizador pendiente — omítela y un pie desmontado seguirá siendo llamado. - Antes del primer informe del observador
getVisible()está vacío; la historia devuelvenullhasta entonces, y la demo pinta una pista vacía.
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';
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();
}
}