화면당 항목 하나: 같은 메뉴에서 나온 전체 너비 슬라이더
켜야 할 슬라이더 모드는 없습니다. 메뉴는 CSS가 말하는 대로 배치하므로, 한 규칙 — 라이브러리 항목 래퍼의
min-width: 100% — 이 같은 컴포넌트를 슬라이더로 바꿉니다. 모든 카드가 화면을 채우고, 평범한 페이징 화살표가 정확히 한 항목씩 전진합니다.화살표로 페이지 넘기기 — 각 슬라이드는 정확히 한 화면 너비이고, 각 슬라이드가 자신의 가시성을 보고합니다.
동작 방식
스토리는 .react-horizontal-scrolling-menu--item — 라이브러리가 각 자식 주위에 렌더링하는 div — 을 대상으로 하는 스타일 컨테이너로 메뉴를 감싸고, minWidth: ’100%’와 flex 가운데 정렬을 줍니다. 각 래퍼가 스크롤 컨테이너 전체에 걸치므로 카드 하나가 화면에 꼭 맞습니다. 화살표는 표준입니다. scrollPrev()와 scrollNext()는 가시 그룹만큼 페이지를 넘기고, 가시 그룹이 항목 하나일 때 페이지와 항목은 같은 것입니다.
화살표와 휠
화살표 상태는 useLeftArrowVisible()과 useRightArrowVisible()에서 옵니다 — 행이 그 끝에 있으면 각각 true를 반환하고, 스토리는 disabled에 넣고 버튼을 페이드아웃합니다. onWheel 프로퍼티는 이벤트와 함께 API 객체를 받으므로, 세로 마우스 휠이 deltaY의 부호로 행을 페이지 넘깁니다. 먼저 터치패드를 감지합니다. 수평 델타나 15 미만의 수직 델타는 터치패드 제스처로 보고 네이티브 스크롤에 남깁니다.
참고
- 각 자식의
itemId가 유일한 하드 요구사항입니다 — 항목이 이걸로 추적되고 스크롤됩니다. - 카드는 여전히
useIsVisible(itemId, true)를 호출합니다. 화면당 항목 하나에서는 화면 밖 슬라이드가 모두visible: false를 보고합니다. - 스크롤바는 스크롤 컨테이너의 일반 CSS(
scrollbar-width: none+ WebKit 의사 요소)로 숨깁니다 — 그 선택은 라이브러리가 아니라 여러분의 것입니다. - 너비는 전적으로 스타일시트에 있습니다. 100%를 50%로 바꾸면 화면당 둘 슬라이더입니다. 라이브러리는 아무것도 측정하지 않습니다.
전체 소스
완전하고 복사-붙여넣기 가능 — 이것이 바로 그 파일입니다. 출처는, 이 라이브 편집 가능한 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';
const WideItems = styled('div')({
'& .react-horizontal-scrolling-menu--item ': {
minWidth: '100%',
display: 'flex',
justifyContent: 'center',
},
});
export function OneItem() {
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 (
<WideItems>
<NoScrollbar>
<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>
</WideItems>
);
}
export default OneItem;
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>
);
}
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 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();
}
}