Cargar más elementos cuando el final entra en la vista
onUpdate la hace tras cada desplazamiento y añade el siguiente lote cuando la respuesta es sí.Desplázate al extremo derecho — aparece una tarjeta cargadora y llega el siguiente lote. La demo se detiene en 30 elementos.
Edita este ejemplo en vivo en Storybook
Cómo funciona
onUpdate se dispara siempre que cambia la visibilidad de un elemento. El manejador lee api.items.last()?.visible — la biblioteca rastrea cada elemento por su itemId y mantiene una bandera de visibilidad por elemento, así que detectar el final cuesta una búsqueda, sin un IntersectionObserver propio y sin matemática de posición de desplazamiento. Luego pushNewItems simula un fetch: un timeout de un segundo, cinco elementos más, listo.
Proteger el fetch
Las actualizaciones de visibilidad llegan en ráfagas, así que el manejador debe ser seguro de llamar repetidamente. Una bandera loading lo hace idempotente: tanto onUpdate como pushNewItems la comprueban, y solo el primer disparo inicia un fetch. La misma bandera renderiza un componente Loader como un elemento de menú real (con su propio itemId) que llama a scrollIntoView() al montar, manteniendo el final de la fila a la vista mientras se carga el lote.
Notas
- La flecha derecha se pasa como elemento,
RightArrow={<RightArrow disabled={...} />}— funcionan tanto la forma de componente como la de elemento, y la de elemento deja que el padre pase props como el tope de elementos. - Esa flecha solo se deshabilita cuando se alcanza el tope y el último elemento es visible — antes del tope, llegar al final significa que vienen más elementos.
newItemsLimitdetiene esta demo en 24 elementos; en código real la señal equivalente es que tu API se quede sin páginas.
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 AddItemsExample() {
const [items, setItems] = 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),
);
};
const newItemsLimit = 24;
const [loading, setLoading] = React.useState(false);
const pushNewItems = () => {
if (loading || items.length > newItemsLimit) {
return false;
}
setLoading(true);
const newItems = items.concat(
Array(5)
.fill(0)
.map((_, ind) => ({ id: getId(items.length + ind) })),
);
new Promise((res) => {
setTimeout(() => res(setItems(newItems)), 1000);
}).finally(() => setLoading(false));
};
return (
<div>
<div>
<NoScrollbar>
<ScrollMenu
LeftArrow={LeftArrow}
RightArrow={<RightArrow disabled={items.length >= newItemsLimit} />}
onWheel={onWheel}
onUpdate={(api) => {
const lastVisible = api?.items?.last?.()?.visible;
if (lastVisible && !loading) {
pushNewItems();
}
}}
>
{items.map(({ id }) => (
<Card
title={id}
itemId={id} // NOTE: itemId is required for track items
key={id}
onClick={() => handleItemClick(id)}
selected={isItemSelected(id)}
/>
))}
{loading && <Loader itemId="loader" key="loader" />}
</ScrollMenu>
</NoScrollbar>
</div>
</div>
);
}
function Loader({ itemId }: { itemId: string }) {
const ref = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
ref.current?.scrollIntoView();
}, [ref]);
return <div ref={ref}>Loading...</div>;
}
export default AddItemsExample;
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({ disabled }: { disabled: boolean }) {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isLastItemVisible = visibility.useIsVisible('last', false);
return (
<Arrow
disabled={disabled && isLastItemVisible}
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 = () =>
Array(10)
.fill(0)
.map((_, ind) => ({ id: getId(ind) }));
const onWheel = (apiObj: publicApiType, ev: React.WheelEvent) => {
// 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();
}
};