Cuộn từng mục một thay vì cả trang
onClick của mũi tên gọi. Cùng menu, cùng mục, khác mục tiêu cuộn.Bấm một mũi tên — hàng tiến một thẻ, không phải một trang. Các mũi tên vô hiệu hóa ở hai đầu.
Chỉnh sửa ví dụ này trực tiếp trong Storybook
Cách hoạt động
getNextElement() trả mục đầu tiên quá nhóm hiển thị; getPrevElement() cái ngay trước nó. Mũi tên phải gọi scrollToItem(visibility.getNextElement(), ’smooth’, ’end’) — căn mục đó với cạnh cuối của container cuộn vừa đủ để đưa nó vào view, làm hàng di chuyển đúng một thẻ. Mũi tên trái là gương của nó: phần tử trước, căn vào ’start’.
Căn chỉnh là toàn bộ mẹo
scrollNext() chuẩn giải cùng phần tử kế bên trong, nhưng căn nó vào cạnh đầu — view cuộn qua cả nhóm hiển thị để đặt mục đó lên đầu. Một đối số ScrollLogicalPosition là khác biệt giữa phân trang và bước. Tham số thứ ba của scrollToItem là căn chỉnh inline chuẩn của scroll-into-view; cái thứ hai là hành vi, ở đây là ’smooth’.
Ghi chú
- Trạng thái mũi tên dùng các dạng ngắn
’first’và’last’:useIsVisible(’first’, true)vô hiệu hóa mũi tên trái ở đầu,useIsVisible(’last’, false)mũi tên phải ở cuối. - Ở hai đầu
getNextElement()trả undefined vàscrollToItemâm thầm no-op, nên mũi tên đang bật vẫn không thể cuộn quá. - Handler
onWheelcủa story vẫn phân trang cả view mỗi nấc con lăn — bước là hành vi của mũi tên, không phải chế độ toàn cục. - Cú bấm mục không bị đụng: các thẻ bật tắt chọn qua
onClickriêng, độc lập với cách mũi tên cuộn.
Nguồn đầy đủ
Đầy đủ và sẵn sàng sao chép-dán — đây là file chính xác phía sau phiên bản Storybook có thể chỉnh sửa trực tiếp.
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 OneItemScroll() {
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 (
<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>
);
}
export default OneItemScroll;
function LeftArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isFirstItemVisible = visibility.useIsVisible('first', true);
// NOTE: Look here
const onClick = () =>
visibility.scrollToItem(visibility.getPrevElement(), 'smooth', 'start');
return (
<Arrow disabled={isFirstItemVisible} onClick={onClick} testId="left-arrow">
Left
</Arrow>
);
}
function RightArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isLastItemVisible = visibility.useIsVisible('last', false);
// NOTE: Look here
const onClick = () =>
visibility.scrollToItem(visibility.getNextElement(), 'smooth', 'end');
return (
<Arrow disabled={isLastItemVisible} onClick={onClick} 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();
}
}