Cuộn menu bằng con lăn — không cuộn trang

Một menu ngang dưới con lăn chuột thật vụng về: con lăn cuộn trang và hàng đứng yên. Cách sửa có hai nửa — một handler onWheel biến tíc con lăn thành phân trang, và một listener gốc không passive ngăn trang di chuyển bên dưới. Nửa sau không thể làm bằng mỗi React.
#01
#02
#03
#04
#05
#06
#07
#08
#09
#10
#11
#12

Đỗ con trỏ trên hàng và xoay con lăn: hàng phân trang, trang đứng yên. Rời khỏi hàng và con lăn lại cuộn trang.

Biến con lăn thành phân trang

Prop onWheel của ScrollMenu được gọi với đối tượng API và sự kiện con lăn. Một con lăn chuột thật báo delta chỉ theo Y theo bước thô, nên handler gọi scrollNext khi deltaY âm và scrollPrev nếu không — mỗi tíc phân trang hàng. Trước tất cả, nó kiểm tra sự kiện có giống cử chỉ touchpad không: bất kỳ deltaX nào, hoặc deltaY dưới 15.

Vì sao khóa trang cần listener gốc

Gọi preventDefault trong handler React sẽ là cách hiển nhiên để dừng trang — và nó âm thầm chẳng làm gì, vì React đăng ký listener con lăn là passive, và listener passive bị cấm hủy sự kiện. Nên usePreventBodyScroll đi vòng qua React: trên mouseenter nó chạy document.addEventListener('wheel', preventDefault, { passive: false }), trên mouseleave nó gỡ listener. Khi con trỏ trên menu, mỗi sự kiện con lăn nổi lên document và bị hủy hành động mặc định — cuộn trang — ở đó. Một cleanup useEffect gọi enableScroll khi unmount, nên trang không bao giờ bị khóa lại.

Lối thoát touchpad

Pan hai ngón cũng đến như sự kiện con lăn, và container cuộn gốc từ chúng — listener document sẽ giết điều đó. Với các sự kiện khớp heuristic touchpad, handler gọi stopPropagation và trả về: sự kiện không bao giờ tới listener document, nên pan gốc sống sót. Không có cách đáng tin để phát hiện touchpad; heuristic delta là phỏng đoán trung thực của story, và nó đứng vững trong thực tế.

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.

PreventBodyScroll.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';

function usePreventBodyScroll() {
  const preventDefault = React.useCallback((ev: Event) => {
    ev?.preventDefault?.();
  }, []);

  const enableScroll = React.useCallback(() => {
    document && document.removeEventListener('wheel', preventDefault, false);
  }, [preventDefault]);
  const disableScroll = React.useCallback(() => {
    document &&
      document.addEventListener('wheel', preventDefault, {
        passive: false,
      });
  }, [preventDefault]);

  React.useEffect(() => {
    return enableScroll;
  }, [enableScroll]);

  return { disableScroll, enableScroll };
}

export function PreventBodyScroll() {
  const { disableScroll, enableScroll } = usePreventBodyScroll();

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

  return (
    <div style={{ height: '200vh' }}>
      <div>
        <NoScrollbar onMouseEnter={disableScroll} onMouseLeave={enableScroll}>
          <ScrollMenu
            LeftArrow={LeftArrow}
            RightArrow={RightArrow}
            onWheel={onWheel}
          >
            {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>
    </div>
  );
}

export default PreventBodyScroll;

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ụ