Tải thêm mục khi điểm cuối vào view

Cuộn ngang vô hạn không có listener cuộn: menu đã biết mục nào hiển thị, nên «người dùng đã đến cuối chưa?» chỉ là một câu hỏi — mục cuối có trên màn hình không? onUpdate hỏi nó sau mỗi lần cuộn và thêm lô kế khi câu trả lời là có.

Cuộn đến đầu bên phải — một thẻ loader xuất hiện và lô kế đến. Demo dừng ở 30 mục.

Cách hoạt động

onUpdate kích hoạt mỗi khi khả năng hiển thị của mục đổi. Handler đọc api.items.last()?.visible — thư viện theo dõi mỗi mục theo itemId và giữ một cờ hiển thị theo mục, nên phát hiện điểm cuối tốn một lần tra, không cần IntersectionObserver riêng và không cần phép tính vị trí cuộn. Rồi pushNewItems giả lập một lần fetch: một timeout một giây, năm mục nữa, xong.

Bảo vệ lần fetch

Các cập nhật hiển thị đến theo cơn, nên handler phải an toàn để gọi lặp. Cờ loading làm nó idempotent: cả onUpdatepushNewItems đều kiểm tra nó, và chỉ trigger đầu tiên khởi động fetch. Cùng cờ đó render component Loader như một mục menu thật (với itemId riêng) gọi scrollIntoView() khi mount, giữ đầu cuối của hàng trong view lúc lô đang tải.

Ghi chú

Nguồn đầy đủ

Đầy đủ và sẵn sàng sao chép-dán — đây là file chính xác phía sau phiên bản Storybook có thể chỉnh sửa trực tiếp.

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();
  }
};

Các ví dụ liên quan

Tất cả 21 ví dụ