Menu cuộn dọc — cùng component, xoay bằng CSS
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.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.
Chỉnh sửa ví dụ này trực tiếp trong Storybook
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: auto và height: 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 wrapperClassName và scrollContainerClassName 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 Header và Footer 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') và 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ú
- Chiều duy nhất cố định là chiều cao inline của panel;
height: 100%của bao bọc mang nó xuống container cuộn. - Con lăn và cảm ứng cuộn cột một cách gốc —
overflow-y: autobiến nó thành container cuộn thật; mũi tên là tiện lợi, không phải cơ chế. - Đối số thứ hai của
scrollPrev/scrollNextlà vị tríinline(ngang) — menu dọc quan tâm tớiblock, đó là lý do story truyền nó tường minh.
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.
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) }));