Menu cuộn dọc — cùng component, xoay bằng CSS

Không có prop vertical, và không cần: menu là một hàng flex trong container cuộn gốc, nên hướng nó xuống dưới là vài ghi đè CSS. Theo dõi hiển thị, hook mũi tên và scrollPrev/scrollNext đều tiếp tục hoạt động trên trục mới.
Tokyo
Oslo
Lima
Cairo
Sydney
Quito
Seoul
Porto
Denver
Hanoi

Lăn con lăn trên cột hoặc dùng mũi tên — Lên và Xuống là Header và Footer của ScrollMenu. Các hàng mờ đi khi rời khỏi view.

Hai ghi đè và một giới hạn chiều cao

Story đổi style hai tên lớp của thư viện. Container cuộn nhận flex-direction: column, overflow-y: autoheight: initial thay cho max-content mặc định; bao bọc nhận height: 100%, nên bất kỳ chiều cao cố định nào của cha đều thành giới hạn cuộn. Đó là toàn bộ chế độ dọc. Story áp dụng ghi đè bằng emotion; demo trên trang này truyền utility Tailwind qua prop wrapperClassNamescrollContainerClassName thay vào đó — bất kỳ lối style nào cũng hoạt động, tên lớp ổn định.

Mũi tên thành Header và Footer

Các slot LeftArrow/RightArrow render cạnh ray — sai chỗ cho một cột. ScrollMenu cũng nhận component HeaderFooter render trên và dưới, và story mount các nút Lên và Xuống ở đó. Chúng là những consumer VisibilityContext thường: useIsVisible('first', true) vô hiệu hóa Lên trên đỉnh, useIsVisible('last', false) vô hiệu hóa Xuống dưới đáy. Các cú bấm truyền đối số thứ ba — scrollPrev(undefined, undefined, 'end')scrollNext(undefined, undefined, 'start') — vị trí block cho scrollIntoView. 'end' đặt mục trước ở cạnh dưới (một trang đầy lên trên); 'start' đặt mục kế lên đỉnh (một trang đầy xuống dưới). Với 'nearest' mặc định, mỗi cú bấm chỉ khẽ đẩy hàng kế vào view.

Giữ cuộn bên trong cột

scrollIntoView di chuyển mọi tổ tiên cuộn được của mục tiêu, và trang là một trong số đó — nên một cú nhảy căn theo block trong cột kéo cả tài liệu theo. Tùy chọn dừng bước đi đó là boundary, truyền ở đối số thứ tư: scrollNext(undefined, undefined, 'start', { boundary }) với scrollContainer.current riêng của menu cuộn các hàng và không gì khác. Nó cần noPolyfill={false} trên ScrollMenu, vì chỉ polyfill hiểu boundary — demo phía trên truyền cả hai. Menu ngang hiếm khi vướng điều này: block: 'nearest' mặc định của chúng ngay từ đầu không yêu cầu trang di chuyển dọc.

Khả năng hiển thị không có trục

useIsVisible được IntersectionObserver chống lưng, và giao cắt được đo ở cả hai chiều — các hàng báo trạng thái khi vượt cạnh trên và dưới đúng như mục ngang làm ở hai bên. Demo làm mờ hàng ngoài view để cho thấy, với bốn hàng đầu được vẽ hiển thị trên server qua đối số defaultValue của hook.

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.

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

const NoScrollbar = styled('div')({
  '& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': {
    display: 'none',
  },
  // NOTE: also need to set on parent:
  // display: 'flex' and position: 'relative'
  '& .react-horizontal-scrolling-menu--wrapper': {
    height: '100%',
  },

  '& .react-horizontal-scrolling-menu--scroll-container': {
    height: 'initial',
    scrollbarWidth: 'none',
    '-ms-overflow-style': 'none',
    overflowY: 'auto',
    flexDirection: 'column',
  },
});

export function VerticalExample() {
  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 (
    <NoScrollbar>
      <ScrollMenu Header={UpArrow} Footer={DownArrow}>
        {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>
  );
}

export default VerticalExample;

function UpArrow() {
  const visibility = React.useContext<publicApiType>(VisibilityContext);
  const isFirstItemVisible = visibility.useIsVisible('first', true);

  return (
    <Arrow
      disabled={isFirstItemVisible}
      onClick={() => visibility.scrollPrev(undefined, undefined, 'end')}
      testId="up-arrow"
    >
      Up
    </Arrow>
  );
}

function DownArrow() {
  const visibility = React.useContext<publicApiType>(VisibilityContext);
  const isLastItemVisible = visibility.useIsVisible('last', false);

  return (
    <Arrow
      disabled={isLastItemVisible}
      onClick={() => visibility.scrollNext(undefined, undefined, 'start')}
      testId="down-arrow"
    >
      Down
    </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: '125px',
    },
  }),
);

const getId = (index: number) => `${'test'}${index}`;

const getItems = () =>
  Array(10)
    .fill(0)
    .map((_, ind) => ({ id: getId(ind) }));

Các ví dụ liên quan

Tất cả 21 ví dụ