클릭한 항목 가운데 정렬 — 스크롤 가능 탭 패턴
모든 탭 스트립이 필요로 하고 어떤 스크롤 컨테이너도 공짜로 주지 않는 동작. 가장자리 근처의 탭을 클릭하면 중앙으로 미끄러져 양옆 이웃을 보여줍니다. 여기서는 API 호출 하나 — Material UI도, 측정도, 스크롤 계산도 없습니다.
어느 쪽 가장자리 근처의 탭을 클릭 — 활성화되고 행의 가운데로 자신을 정렬합니다.
동작 방식
handleItemClick은 커링되어 있습니다. itemId를 받아 API 객체를 기대하는 함수를 반환합니다. 클릭은 먼저 id를 selected state에 저장한 뒤, api.getItemElementById(itemId)로 실제 DOM 요소를 찾아 api.scrollToItem(item, ’smooth’, ’center’)에 넘깁니다. 클릭 하나, 효과 둘: 탭이 선택되고 가운데 정렬됩니다.
API는 어디서 오나
부모 컴포넌트는 API ref를 결코 보유하지 않습니다. 각 Card는 VisibilityContext — ScrollMenu의 어떤 자식에게도 제공 — 에서 완전한 API를 읽어 클릭 핸들러로 넘깁니다: onClick(visibility). 대신 메뉴 외부에서 스크롤해야 한다면, 그것이 scroll-to-item 예제의 apiRef 패턴입니다.
참고
scrollToItem의 세 번째 인자는scrollIntoView의inline옵션과 같은 값을 취합니다 —’start’,’center’,’end’.- 카드는 포커스 가능하고(
role="button",tabIndex=0)onKeyDown에서 Enter를 처리해, 키보드 사용자도 같은 선택과 가운데 정렬을 얻습니다. onWheel핸들러는 마우스 휠 델타를scrollNext/scrollPrev에 매핑하지만 터치패드에서는 물러납니다 — 수평 델타나 아주 작은 수직 델타는 제스처로 보고 네이티브로 남깁니다.- 화살표는
useIsVisible(’first’)와useIsVisible(’last’)단축형으로 스스로 비활성화합니다.
전체 소스
완전하고 복사-붙여넣기 가능 — 이것이 바로 그 파일입니다. 출처는, 이 라이브 편집 가능한 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 CenterOnClick() {
const [items] = React.useState(() => getItems());
const [selected, setSelected] = React.useState<string>('');
const isItemSelected = (id: string): boolean => selected === id;
// The Card reads the api from VisibilityContext and passes it here,
// so a single click both selects the item and centers it.
const handleItemClick = (itemId: string) => (api: publicApiType) => {
setSelected(itemId);
const item = api.getItemElementById(itemId);
if (item) {
api.scrollToItem(item, 'smooth', 'center');
}
};
return (
<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>
);
}
export default CenterOnClick;
function LeftArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isFirstItemVisible = visibility.useIsVisible('first', true);
return (
<Arrow
disabled={isFirstItemVisible}
onClick={() => visibility.scrollPrev()}
testId="left-arrow"
>
Left
</Arrow>
);
}
function RightArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isLastItemVisible = visibility.useIsVisible('last', false);
return (
<Arrow
disabled={isLastItemVisible}
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(14)
.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();
}
}