Cargar más elementos cuando el final entra en la vista

Desplazamiento horizontal infinito sin un listener de desplazamiento: el menú ya sabe qué elementos son visibles, así que «¿ha llegado el usuario al final?» es solo una pregunta — ¿está el último elemento en pantalla? 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.

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

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.

AddItems.source.tsx
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();
  }
};

Ejemplos relacionados

Todos los ejemplos (21)