가로 목록의 특정 항목으로 스크롤
행으로 딥링크하기. 채팅은 진행 중인 대화에, 갤러리는 공유한 사진에 열립니다. 스크롤 컨테이너는 라이브러리 내부에 있지만, 그 DOM으로의 ref는 필요 없습니다 —
onInit이 api를 건네고, scrollToItem이 위치를 잡습니다.onInit scrolls straight to quito
레일은 Tokyo에서 마운트하지 않습니다 — onInit이 곧장 quito로 점프합니다. 다른 곳으로 드래그한 뒤 재마운트해 다시 그곳에 착지하는 것을 보세요.
동작 방식
ScrollMenu는 onInit 콜백을 받아, 메뉴가 렌더링되고 항목을 측정한 뒤, 내부의 VisibilityContext가 제공하는 것과 같은 api 객체를 건네며 호출합니다. 핸들러는 getItemElementById(id)로 요소를 찾아 scrollToItem(item, ’auto’, ’start’)에 넘깁니다. onInit은 측정 후에만 발화하므로, 렌더링된 항목에 대한 검색이 비어 돌아올 수 없습니다 — setTimeout도, 재시도 루프도 없습니다.
동작과 정렬
스토리는 ’auto’와 ’start’를 전달합니다. ’auto’는 애니메이션 없이 점프하는데, 초기 위치에 원하는 것이 바로 그것입니다 — 사용자가 레일을 첫 항목에서 보는 일이 없습니다. ’start’는 항목의 왼쪽 끝을 레일에 맞춥니다. 클릭 구동 스크롤에서는 같은 호출이 ’smooth’와 ’center’를 취합니다 — 그것이 아래의 클릭 가운데 정렬 예제입니다.
참고
- 슬롯은 알지만 id는 모를 때,
getItemElementByIndex가 위치 기반 대안입니다. - 건네는 id는 항목의
itemId— 가시성 추적에 메뉴가 쓰는 것과 같은 키입니다. - 데모는 새
key로 메뉴를 재마운트해 동작을 재생합니다. 새 마운트마다onInit이 다시 실행됩니다.
전체 소스
완전하고 복사-붙여넣기 가능 — 이것이 바로 그 파일입니다. 출처는, 이 라이브 편집 가능한 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';
export function ScrollToItem() {
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),
);
};
// `onInit` fires once the menu has rendered and measured its items,
// so the api is safe to use right away — no timers needed.
const scrollToItemOnInit = (api: publicApiType) => {
const item = api.getItemElementById(getId(5));
// const item = api.getItemElementByIndex('5') // or by index
if (item) {
api.scrollToItem(item, 'auto', 'start');
}
};
return (
<ScrollMenu
LeftArrow={LeftArrow}
RightArrow={RightArrow}
onWheel={onWheel}
onInit={scrollToItemOnInit}
>
{items.map(({ id }) => (
<Card
title={id}
itemId={id} // NOTE: itemId is required for track items
key={id}
onClick={() => handleItemClick(id)}
selected={isItemSelected(id)}
/>
))}
</ScrollMenu>
);
}
export default ScrollToItem;
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();
}
}