Cuộn menu bằng con lăn — không cuộn trang
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.Đỗ 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.
Chỉnh sửa ví dụ này trực tiếp trong Storybook
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ú
- Trình duyệt biến listener con lăn cấp document thành passive mặc định chính xác để trang không giật khi cuộn —
passive: falselà lựa chọn thoát tường minh khiếnpreventDefaultlại hợp lệ. - Con lăn lên phân trang tới và con lăn xuống phân trang lui — đó là ánh xạ của story; hoán đổi nhánh
scrollNext/scrollPrevđể đảo. - Thiết bị cảm ứng không chạy gì trong số này: không có
mouseenter, và vuốt hàng là cuộn gốc từ đầu. - Khóa chỉ tồn tại giữa
mouseentervàmouseleave, nên phần còn lại của trang cuộn bình thường ngay khi con trỏ rời ray.
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';
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();
}
}