垂直滚动菜单——同一个组件,靠 CSS 翻转
vertical 属性,也不需要:菜单是原生滚动容器里的一行 flex,所以让它朝下只需几条 CSS 覆盖。可见性追踪、箭头 hook 与 scrollPrev/scrollNext 在新轴上都能继续工作。在列上滚动滚轮或用箭头——Up 与 Down 是 ScrollMenu 的 Header 与 Footer。行离开视图时会变暗。
两条覆盖和一个高度界限
该 story 重设了库的两个类名样式。滚动容器获得 flex-direction: column、overflow-y: auto 与 height: initial,取代默认的 max-content;包装器获得 height: 100%,于是父容器任何固定的高度都成为滚动界限。这就是完整的垂直模式。story 用 emotion 应用这些覆盖;本页的演示则改经 wrapperClassName 与 scrollContainerClassName 属性传入 Tailwind 工具类——任何样式途径都可以,类名是稳定的。
箭头变成 Header 与 Footer
LeftArrow/RightArrow 插槽渲染在轨道两侧——对一列来说位置不对。ScrollMenu 还接受渲染在上方与下方的 Header 和 Footer 组件,story 把它的 Up 与 Down 按钮挂在那里。它们是普通的 VisibilityContext 消费者:useIsVisible('first', true) 在顶部禁用 Up,useIsVisible('last', false) 在底部禁用 Down。点击会传入第三个参数——scrollPrev(undefined, undefined, 'end') 与 scrollNext(undefined, undefined, 'start')——即 scrollIntoView 的 block 位置。'end' 把上一个项目放到下边缘(整页向上);'start' 把下一个项目放到顶部(整页向下)。在默认的 'nearest' 下,每次点击只会把下一行刚刚推进可视区。
把滚动留在列内
scrollIntoView 会移动目标的所有可滚动祖先,而页面正是其中之一——因此列内一次 block 对齐的跳转会把整个文档一起带走。能阻止这种蔓延的选项是 boundary,作为第四个参数传入:scrollNext(undefined, undefined, 'start', { boundary }) 用菜单自身的 scrollContainer.current,只滚动行、不滚动别的。它需要在 ScrollMenu 上设置 noPolyfill={false},因为只有补丁才理解 boundary——上面的演示两者都传了。横向菜单很少遇到这种情况:它们默认的 block: 'nearest' 从一开始就不会要求页面做任何垂直移动。
可见性没有轴
useIsVisible 由 IntersectionObserver 支撑,而相交在两个维度都有测量——行在跨越上下边缘时报告自己的状态,与横向项目在两侧所做的完全一样。演示把视图之外的行变暗来展示这一点,并用 hook 的 defaultValue 参数让前四行在服务端就被绘制为可见。
注意
- 唯一固定的尺寸是面板的行内高度;包装器的
height: 100%把它一路传到滚动容器。 - 滚轮与触摸原生地滚动这一列——
overflow-y: auto让它成为真正的滚动容器;箭头是便利,不是机制。 scrollPrev/scrollNext的第二个参数是inline(水平)位置——垂直菜单关心的是block,所以 story 显式地传它。
完整源码
完整且可直接复制粘贴——这正是背后这份文件的 可在 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) }));