휠로 메뉴를 스크롤 — 페이지는 스크롤하지 않고
onWheel 핸들러와, 아래 페이지가 움직이지 않게 하는 네이티브 비패시브 리스너. 후반부는 React만으로는 할 수 없습니다.행 위에 포인터를 두고 휠을 돌리세요 — 행은 페이지를 넘기고, 페이지는 가만히 있습니다. 행을 벗어나면 휠이 다시 페이지를 스크롤합니다.
휠을 페이지 넘김으로 바꾸기
ScrollMenu의 onWheel 프로퍼티는 API 객체와 휠 이벤트와 함께 호출됩니다. 실제 마우스 휠은 거친 단계로 Y만의 델타를 보고하므로, 핸들러는 deltaY가 음수면 scrollNext, 그렇지 않으면 scrollPrev를 호출합니다 — 틱마다 행을 페이지 넘깁니다. 그 전에 이벤트가 터치패드 제스처처럼 보이는지 확인합니다. 조금이라도 deltaX가 있거나 deltaY가 15 미만인 경우입니다.
페이지 잠금에 네이티브 리스너가 필요한 이유
React 핸들러 안에서 preventDefault를 호출하는 것이 페이지를 멈추는 자명한 방법처럼 보입니다 — 그러나 그것은 조용히 아무것도 하지 않습니다. React가 휠 리스너를 패시브로 등록하고, 패시브 리스너는 이벤트 취소가 금지되어 있기 때문입니다. 그래서 usePreventBodyScroll은 React를 우회합니다. mouseenter에서 document.addEventListener('wheel', preventDefault, { passive: false })를 실행하고, mouseleave에서 리스너를 다시 제거합니다. 포인터가 메뉴 위에 있는 동안 모든 휠 이벤트는 document까지 버블링해, 거기서 기본 동작 — 페이지 스크롤 — 이 취소됩니다. useEffect 정리는 언마운트에서 enableScroll을 호출해 페이지가 잠긴 채 남지 않게 합니다.
터치패드 탈출구
두 손가락 패닝도 휠 이벤트로 도착하고, 컨테이너는 그것들로 네이티브로 스크롤합니다 — document 리스너는 그것을 죽여 버립니다. 터치패드 휴리스틱에 맞는 이벤트에서 핸들러는 stopPropagation을 호출하고 반환합니다. 이벤트가 document 리스너에 도달하지 않아 네이티브 패닝이 살아남습니다. 터치패드를 감지할 신뢰할 방법은 없습니다. 델타 휴리스틱은 스토리의 정직한 추측이고, 실무에서 버팁니다.
참고
- 브라우저가 document 레벨 휠 리스너를 기본으로 패시브로 만든 것은 정확히 페이지가 스크롤을 버벅이지 않게 하기 위함입니다 —
passive: false는preventDefault를 다시 합법으로 만드는 명시적 옵트아웃입니다. - 휠 위로는 앞으로, 휠 아래로는 뒤로 페이지를 넘깁니다 — 그것이 스토리의 매핑입니다. 반대로 하려면
scrollNext/scrollPrev분기를 맞바꾸세요. - 터치 장치는 이 중 어느 것도 실행하지 않습니다.
mouseenter가 없고, 행 스와이프는 처음부터 네이티브 스크롤입니다. - 잠금은
mouseenter와mouseleave사이에만 존재하므로, 포인터가 레일을 떠나는 순간 페이지 나머지는 정상적으로 스크롤됩니다.
전체 소스
완전하고 복사-붙여넣기 가능 — 이것이 바로 그 파일입니다. 출처는, 이 라이브 편집 가능한 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';
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();
}
}