Thêm một mục và cuộn đến nó — pattern chip bộ lọc

Một thanh chip lớn lên khi người dùng chọn một bộ lọc, và chip mới nên kết thúc trên màn hình, không bị ẩn sau cạnh phải. Điểm khó: bạn không thể cuộn đến một phần tử chưa render. Ví dụ này chia công việc giữa một handler bấm và một effect.

Bấm Thêm bộ lọc — chip xuất hiện ở cuối và hàng cuộn để hiện nó. Dấu x xóa một chip.

Cách hoạt động

Menu nhận một apiRef, thứ phơi bày toàn bộ API bên ngoài cây component. addItem làm hai việc: lưu id mới vào một ref lastAdded, rồi thêm mục vào state. Nó cố ý không cuộn — tại thời điểm đó chip chỉ là state, không phải DOM.

Vì sao việc cuộn nằm trong một effect

getItemElementById tra mục trong DOM, nên việc cuộn chỉ có thể xảy ra sau khi React đã commit mục mới. Một useEffect phụ thuộc items chạy đúng tại thời điểm đó: nó đọc lastAdded, xóa nó và gọi apiRef.current.scrollToItem(el, ’smooth’, ’end’). Xóa ref rất quan trọng — các lần render lại vì bất kỳ lý do nào khác (chọn, mũi tên) cũng chạm tới effect và không được cuộn lạ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.

AddItemAndScrollToIt.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 AddItemAndScrollToItExample() {
  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 apiRef = React.useRef<publicApiType>(null);
  // The id of the item added by the last click; empty on mount and after the
  // scroll fires, so re-renders for other reasons don't scroll again.
  const lastAdded = React.useRef<string | null>(null);

  const addItem = () => {
    const newId = getId(items.length);
    lastAdded.current = newId;
    setItems((currentItems) => currentItems.concat({ id: newId }));
  };

  // `getItemElementById` looks the item up in the DOM, so the scroll can only
  // happen after React has rendered the new item — an effect keyed on `items`
  // rather than a call inside the click handler.
  React.useEffect(() => {
    const newId = lastAdded.current;
    if (!newId) {
      return;
    }
    lastAdded.current = null;

    const el = apiRef.current?.getItemElementById(newId);
    if (el) {
      apiRef.current?.scrollToItem(el, 'smooth', 'end');
    }
  }, [items]);

  return (
    <div>
      <div>
        <button onClick={addItem} data-testid="add-item">
          Add item
        </button>
      </div>

      <NoScrollbar>
        <ScrollMenu
          LeftArrow={LeftArrow}
          RightArrow={RightArrow}
          onWheel={onWheel}
          apiRef={apiRef}
        >
          {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>
  );
}

export default AddItemAndScrollToItExample;

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