Вертикальное меню прокрутки — тот же компонент, повёрнутый CSS
vertical нет, и она не нужна: меню — это flex-ряд внутри нативного контейнера прокрутки, так что направить его вниз — пара CSS-переопределений. Отслеживание видимости, хуки стрелок и scrollPrev/scrollNext продолжают работать на новой оси.Крутите колесо над колонкой или используйте стрелки — Up и Down это Header и Footer у ScrollMenu. Ряды гаснут, покидая экран.
Редактировать этот пример вживую в Storybook
Два переопределения и одна граница высоты
История перестилизует два имени классов библиотеки. Контейнер прокрутки получает flex-direction: column, overflow-y: auto и height: initial вместо дефолтного max-content; обёртка получает height: 100%, так что любая фиксированная высота родителя становится границей прокрутки. Это и есть весь вертикальный режим. История применяет переопределения через emotion; демо на этой странице вместо этого передаёт утилиты Tailwind через пропы wrapperClassName и scrollContainerClassName — подходит любой способ стилизации, имена классов стабильны.
Стрелки становятся Header и Footer
Слоты LeftArrow/RightArrow рендерятся сбоку от рельса — неправильное место для колонки. ScrollMenu также принимает компоненты Header и Footer, рендерящиеся сверху и снизу, и история монтирует там свои кнопки Up и Down. Это обычные потребители VisibilityContext: useIsVisible('first', true) отключает Up наверху, useIsVisible('last', false) отключает Down внизу. Клики передают третий аргумент — scrollPrev(undefined, undefined, 'end') и scrollNext(undefined, undefined, 'start') — позицию block для scrollIntoView. 'end' кладёт предыдущий элемент у нижнего края (на целую страницу вверх); 'start' ставит следующий элемент наверх (на целую страницу вниз). При дефолтном 'nearest' каждый клик лишь слегка вталкивал бы следующую строку в видимость.
Удержание прокрутки внутри колонки
scrollIntoView двигает каждого прокручиваемого предка своей цели, и страница — один из них — так что прыжок с выравниванием block внутри колонки тащит за собой весь документ. Опция, останавливающая это шествие, — boundary, передаваемая четвёртым аргументом: scrollNext(undefined, undefined, 'start', { boundary }) с собственным scrollContainer.current меню прокручивает только ряды и ничего больше. Ей нужен noPolyfill={false} на ScrollMenu, поскольку boundary понимает только полифил, — демо выше передаёт и то и другое. Горизонтальные меню редко с этим сталкиваются: их дефолтный block: 'nearest' с самого начала не просит у страницы вертикального движения.
У видимости нет оси
useIsVisible опирается на IntersectionObserver, а пересечение измеряется в обоих измерениях — ряды сообщают своё состояние при пересечении верхнего и нижнего краёв ровно так же, как горизонтальные элементы делают это сбоку. Демо гасит ряды вне экрана, чтобы это показать, и делает первые четыре серверно-отрисованными видимыми через аргумент defaultValue хука.
Примечания
- Единственная фиксированная размерность — это инлайновая высота панели;
height: 100%обёртки переносит её вниз к контейнеру прокрутки. - Колесо и касание прокручивают колонку нативно —
overflow-y: autoделает её настоящим контейнером прокрутки; стрелки — удобство, а не механизм. - Второй аргумент
scrollPrev/scrollNext— позицияinline(горизонтальная) — вертикальным меню важенblock, поэтому история передаёт его явно.
Полный исходник
Полный и готовый к копированию — это точный файл, стоящий за редактируемой вживую версией Storybook.
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) }));