Thêm chỉ báo tiến độ cuộn vào một menu ngang

Một carousel ẩn thanh cuộn vẫn nợ người dùng một câu trả lời cho «còn bao nhiêu?». Menu đã biết: nó theo dõi khả năng hiển thị của mọi mục, nên vị trí là chuyện đếm. Story render các nút trang được đánh số cộng số mục còn lại trái/phải từ dữ liệu đó; demo này chưng cất cùng phép toán đó thành một thanh tiến độ.
#01
#02
#03
#04
#05
#06
#07
#08
#09
#10
#11
#12
#13
#14
#15
#16
#17
#18
#19
#20

Cuộn hàng, kéo nó, hoặc dùng mũi tên — thanh đầy lên từng trang và bộ đếm cho biết bạn đang ở đâu.

Cách hoạt động

Chỉ báo được truyền dưới dạng prop Footer, nên ScrollMenu render nó bên trong menu nơi VisibilityContext sẵn có. Từ context nó lấy items — bản đồ phía sau theo dõi hiển thị — và đăng ký bằng items.subscribe(’onUpdate’, cb). Sự kiện đó kích hoạt trên mỗi callback IntersectionObserver, nên story debounce nó (một timeout cộng requestAnimationFrame) trước khi đọc items.getVisible().

Từ mục hiển thị đến số trang

Số mục hiển thị là kích thước trang. Tổng số trang là Math.ceil(items.size / visibleItemsLen); trang hiện tại đến từ index của mục hiển thị cuối. Story biến chúng thành các nút trang bấm được — mỗi nút gọi scrollToItem(getItemByIndex(itemInd)), định vị một mục theo vị trí mà không cần biết id — và suy ra số mục bên trái và bên phải từ cùng những con số đó. Thanh của demo chỉ là currentPage / totalPages dưới dạng phần trăm chiều rộng.

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.

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

Các ví dụ liên quan

Tất cả 21 ví dụ