공개 API 위에 지은 무한 루프 메뉴
scrollLeft가 정확히 루프 한 바퀴만큼 점프합니다. 점프 양쪽의 프레임이 동일하므로 아무것도 움직인 것처럼 보이지 않습니다. 화살표, 휠, 터치, 마우스 드래그 모두 이음새를 건넙니다.어느 방향으로든 계속 가세요 — 화살표, 휠, 터치, 드래그로 — 행은 결코 끝나지 않습니다.
동작 방식
getSlides는 항목을 행의 양 끝으로 복사합니다. itemId는 유일해야 하므로 복제에는 접미사가 붙습니다 — 왼쪽은 -lc, 오른쪽은 -rc — 한편 실제 id는 제목·선택·클릭을 위해 realId로 유지합니다. useInfiniteLoop가 나머지를 묶습니다. normalize()는 첫 실제 항목과 그 오른쪽 복제의 offsetLeft에서 루프 길이를 재고, 위치가 복제 영역에 들어갈 때마다 scrollLeft를 정확히 그 거리만큼 이동합니다. 순수 기하이고, 멱등입니다 — 고칠 것이 없을 때 호출해도 아무것도 하지 않습니다.
텔레포트가 발화하는 때
스크롤 중간의 점프는 브라우저와 눈에 띄게 싸우므로, normalize는 스크롤이 안정될 때 실행됩니다. 컨테이너의 네이티브 scrollend 리스너(containerRef 프로퍼티로 도달)에, scrollend를 발화하지 않는 Safari를 위한 150ms 디바운스 onScroll 폴백을 덧붙입니다. 누가 무엇을 보기 전에 점프가 하나 더 있습니다. 레이아웃 이펙트가 초기 scrollLeft를 페인트 전에 첫 실제 항목으로 맞춰, 페이지가 왼쪽 복제에서 열리는 일이 없습니다.
드래그 중 이음새 건너기
마우스 드래그 콜백은 각 델타를 scrollLeft에 더하고, 제스처 안에서 바로 그 자리에서 loop.normalize()를 호출합니다. 그것 없이는 복제 영역으로 드래그하면 텔레포트까지 드래그 끝을 기다려야 합니다 — 있으면, 이음새를 무한히 드래그해도 결코 눈치채지 못합니다.
참고
- 여기의 화살표는 커스텀이고 항상 활성입니다. 표준
first/last훅은 가장 바깥 항목을 추적하는데, 여기서는 그것이 복제입니다 — 이음새에서 비활성으로 깜빡입니다. - 카드는 쌍합집합 가시성을 표시합니다 — 항목은 자신이나 어느 한 복제가 보일 때 보이는 것으로 셉니다. 텔레포트 뒤 요소별 플래그가 한 프레임 낡아 헤더를 깜빡이기 때문입니다.
- 한쪽에 두 페이지 분량의 복제. 영역은 전체 뷰포트를(점프 주위의 동일 프레임을) 여유 있게 덮어야 해서, 이음새에 걸친 페이지의 Next 클릭이 행 끝에서 끼는 일이 없습니다.
- 여기 쓰인 모든 것 —
containerRef,onScroll,itemId, 커링된 마우스 프로퍼티 — 은 공개 API입니다.
전체 소스
완전하고 복사-붙여넣기 가능 — 이것이 바로 그 파일입니다. 출처는, 이 라이브 편집 가능한 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';
import { useDebounceCallback, useUnmount } from 'usehooks-ts';
// Two pages per side: the clone zone must cover a viewport (identical
// frames around a jump), with room to spare so a Next click from the page
// straddling the seam never clamps at the end of the row.
const CLONES_PER_SIDE = 6;
export function InfiniteLoop() {
const [selected, setSelected] = React.useState<string[]>([]);
// NOTE: for drag by mouse
const [dragManager] = React.useState(() => new DragDealer());
const loop = useInfiniteLoop(getItemIds());
// normalize() inside the drag keeps the seam crossable mid-gesture.
const handleDrag =
({ scrollContainer }: publicApiType) =>
(ev: React.MouseEvent) =>
dragManager.dragMove(ev, (posDiff) => {
if (scrollContainer.current) {
scrollContainer.current.scrollLeft += posDiff;
loop.normalize();
}
});
const isItemSelected = (id: string): boolean => selected.includes(id);
// Keyed by real id — clicking a clone selects its twin.
const handleItemClick = (realId: string) => {
if (dragManager.dragging) {
return;
}
setSelected((currentSelected) =>
currentSelected.includes(realId)
? currentSelected.filter((el) => el !== realId)
: currentSelected.concat(realId),
);
};
return (
<NoScrollbar onMouseLeave={() => dragManager.dragStop()}>
<ScrollMenu
{...loop.menuProps}
LeftArrow={LeftArrow}
RightArrow={RightArrow}
onMouseDown={() => dragManager.dragStart}
onMouseUp={() => dragManager.dragStop}
onMouseMove={handleDrag}
>
{loop.slides.map(({ itemId, realId }) => (
<Card
realId={realId}
itemId={itemId} // NOTE: must be unique — clones get a suffix
key={itemId}
onClick={() => handleItemClick(realId)}
selected={isItemSelected(realId)}
/>
))}
</ScrollMenu>
</NoScrollbar>
);
}
export default InfiniteLoop;
// The loop, packaged: cloned slides, the pre-paint start jump and the
// seam teleport. Spread `menuProps` onto ScrollMenu, render `slides`,
// and call `normalize()` after moving scrollLeft by hand (e.g. inside a
// drag). `itemIds` are read once, on the first render.
function useInfiniteLoop(
itemIds: string[],
clonesPerSide: number = CLONES_PER_SIDE,
) {
const [slides] = React.useState(() => getSlides(itemIds, clonesPerSide));
// Receives the scroll container div itself.
const containerRef = React.useRef<HTMLDivElement | null>(null);
// Seam markers come from the data — itemId can be anything.
const firstRealId = slides[clonesPerSide].itemId;
const firstRightCloneId = slides[slides.length - clonesPerSide].itemId;
// Shift by one loop length when settled inside a clone zone. Pure
// geometry and idempotent — visibility flags lag and must not gate it.
const normalize = React.useCallback(() => {
const el = containerRef.current;
const first = el?.querySelector<HTMLElement>(`[data-key='${firstRealId}']`);
const firstClone = el?.querySelector<HTMLElement>(
`[data-key='${firstRightCloneId}']`,
);
if (!el || !first || !firstClone) {
return;
}
const realStart = first.offsetLeft;
const loopLength = firstClone.offsetLeft - realStart;
const x = el.scrollLeft;
if (x >= realStart + loopLength) {
el.scrollLeft = x - loopLength;
} else if (x < realStart) {
el.scrollLeft = x + loopLength;
}
}, [firstRealId, firstRightCloneId]);
// 'scrollend' fires when scrolling truly ends; debounce covers Safari.
const settle = useDebounceCallback(normalize, 150);
useUnmount(() => settle.cancel());
const hasScrollEnd = typeof window !== 'undefined' && 'onscrollend' in window;
React.useEffect(() => {
const el = containerRef.current;
if (!el || !hasScrollEnd) {
return;
}
el.addEventListener('scrollend', normalize);
return () => el.removeEventListener('scrollend', normalize);
}, [normalize, hasScrollEnd]);
// Start on the first real item, before first paint.
React.useLayoutEffect(() => {
const el = containerRef.current;
const first = el?.querySelector<HTMLElement>(`[data-key='${firstRealId}']`);
if (el && first) {
el.scrollLeft = first.offsetLeft;
}
}, [firstRealId]);
return {
slides,
normalize,
menuProps: {
containerRef,
onScroll: hasScrollEnd ? undefined : () => settle(),
},
};
}
const leftCloneId = (id: string) => `${id}-lc`;
const rightCloneId = (id: string) => `${id}-rc`;
// Clones render exactly like their twins; unique itemId is the only
// difference — title, selection and clicks all use the real id.
const getSlides = (ids: string[], clonesPerSide: number) => {
const left = ids
.slice(-clonesPerSide)
.map((id) => ({ itemId: leftCloneId(id), realId: id }));
const right = ids
.slice(0, clonesPerSide)
.map((id) => ({ itemId: rightCloneId(id), realId: id }));
const real = ids.map((id) => ({ itemId: id, realId: id }));
return [...left, ...real, ...right];
};
// An item is visible when any twin is: the raw per-element flag goes
// stale for a frame right after a teleport and would blink the header.
function useLoopItemVisible(realId: string) {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const realVisible = visibility.useIsVisible(realId, true);
const leftTwinVisible = visibility.useIsVisible(leftCloneId(realId), false);
const rightTwinVisible = visibility.useIsVisible(rightCloneId(realId), false);
return realVisible || leftTwinVisible || rightTwinVisible;
}
class DragDealer {
clicked: boolean;
dragging: boolean;
position: number;
resetId: number;
constructor() {
this.clicked = false;
this.dragging = false;
this.position = 0;
this.resetId = 0;
}
public dragStart = (ev: React.MouseEvent) => {
// A pending reset from the previous drag would kill this one.
window.cancelAnimationFrame(this.resetId);
this.position = ev.clientX;
this.clicked = true;
};
public dragStop = () => {
// Stop applying immediately; clear `dragging` a frame later so item
// onClick (which fires after mouseup) still sees it and suppresses
// the click.
this.clicked = false;
this.resetId = window.requestAnimationFrame(() => {
this.dragging = false;
});
};
public dragMove = (ev: React.MouseEvent, cb: (posDiff: number) => void) => {
const newDiff = this.position - ev.clientX;
if (this.clicked && Math.abs(newDiff) > 5) {
this.dragging = true;
this.position = ev.clientX;
cb(newDiff);
}
};
}
const getId = (index: number) => `${'test'}${index}`;
const getItemIds = () =>
Array(10)
.fill(0)
.map((_, ind) => getId(ind));
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',
},
});
// Always enabled: the stock arrow hooks track the outermost items — here
// those are clones, so they'd flash disabled at the seam.
function LeftArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
return (
<Arrow onClick={() => visibility.scrollPrev()} testId="left-arrow">
Left
</Arrow>
);
}
function RightArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
return (
<Arrow onClick={() => visibility.scrollNext()} testId="right-arrow">
Right
</Arrow>
);
}
function Arrow({
children,
onClick,
testId,
}: {
children: React.ReactNode;
onClick: VoidFunction;
testId: string;
}) {
return (
<ArrowButton onClick={onClick} data-testid={testId}>
{children}
</ArrowButton>
);
}
const ArrowButton = styled('button')({
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
marginBottom: '2px',
userSelect: 'none',
borderRadius: '6px',
borderWidth: '1px',
});
function Card({
onClick,
selected,
realId,
itemId,
}: {
onClick: VoidFunction;
selected: boolean;
realId: string;
itemId: string;
}) {
const visibility = React.useContext<publicApiType>(VisibilityContext);
// Raw flag of this element — kept on data-visible for the play tests.
const ownVisible = visibility.useIsVisible(itemId, true);
const isVisible = useLoopItemVisible(realId);
return (
<CardBody
data-cy={itemId}
data-visible={ownVisible}
onClick={onClick}
onKeyDown={(ev: React.KeyboardEvent) => {
ev.code === 'Enter' && onClick();
}}
data-testid="card"
role="button"
tabIndex={0}
className="card"
visible={isVisible}
selected={selected}
>
<div className="header">
<div>{realId}</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',
},
}),
);