Cuộn đến một mục cụ thể trong danh sách ngang

Deep-link vào một hàng: một chat mở trên cuộc trò chuyện đang hoạt động, một gallery mở trên ảnh bạn đã chia sẻ. Container cuộn nằm trong thư viện, nhưng bạn không cần ref vào DOM của nó — onInit trao api cho bạn, và scrollToItem làm việc định vị.
onInit scrolls straight to quito
Tokyo
Oslo
Lima
Cairo
Sydney
Quito
Seoul
Porto
Denver
Hanoi

Ray không mount tại Tokyo — onInit nhảy thẳng đến quito. Kéo đi nơi khác rồi remount để thấy nó hạ cánh lại đó.

Cách hoạt động

ScrollMenu nhận một callback onInit và gọi nó khi menu đã render và đo các mục, truyền cùng đối tượng api mà VisibilityContext cung cấp bên trong. Handler tra phần tử bằng getItemElementById(id) và đưa cho scrollToItem(item, ’auto’, ’start’). Vì onInit chỉ kích hoạt sau khi đo, việc tra không thể trả rỗng cho một mục đã render — không setTimeout, không vòng lặp thử lại.

Hành vi và căn chỉnh

Story truyền ’auto’’start’: ’auto’ nhảy không hoạt ảnh, đúng thứ bạn muốn cho vị trí ban đầu — người dùng không bao giờ thấy ray ở mục một. ’start’ căn cạnh trái của mục với ray. Với các lần cuộn do bấm, cùng lệnh đó nhận ’smooth’’center’ — đó là ví dụ căn giữa khi bấm phía dướ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.

ScrollToItem.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 ScrollToItem() {
  const [items] = 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),
    );
  };

  // `onInit` fires once the menu has rendered and measured its items,
  // so the api is safe to use right away — no timers needed.
  const scrollToItemOnInit = (api: publicApiType) => {
    const item = api.getItemElementById(getId(5));
    // const item = api.getItemElementByIndex('5') // or by index
    if (item) {
      api.scrollToItem(item, 'auto', 'start');
    }
  };

  return (
    <ScrollMenu
      LeftArrow={LeftArrow}
      RightArrow={RightArrow}
      onWheel={onWheel}
      onInit={scrollToItemOnInit}
    >
      {items.map(({ id }) => (
        <Card
          title={id}
          itemId={id} // NOTE: itemId is required for track items
          key={id}
          onClick={() => handleItemClick(id)}
          selected={isItemSelected(id)}
        />
      ))}
    </ScrollMenu>
  );
}

export default ScrollToItem;

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 = () =>
  Array(10)
    .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ụ