Cuộn đến một mục cụ thể trong danh sách ngang
onInit trao api cho bạn, và scrollToItem làm việc định vị.Ray không mount tại Tokyo — onInit nhảy thẳng đến quito. Kéo đi nơi khác rồi remount để thấy nó hạ cánh lại đó.
Chỉnh sửa ví dụ này trực tiếp trong Storybook
Cách hoạt động
ScrollMenu nhận một callback onInit và gọi nó khi menu đã render và đo các mục, truyền cùng đối tượng api mà VisibilityContext cung cấp bên trong. Handler tra phần tử bằng getItemElementById(id) và đưa cho scrollToItem(item, ’auto’, ’start’). Vì onInit chỉ kích hoạt sau khi đo, việc tra không thể trả rỗng cho một mục đã render — không setTimeout, không vòng lặp thử lại.
Hành vi và căn chỉnh
Story truyền ’auto’ và ’start’: ’auto’ nhảy không hoạt ảnh, đúng thứ bạn muốn cho vị trí ban đầu — người dùng không bao giờ thấy ray ở mục một. ’start’ căn cạnh trái của mục với ray. Với các lần cuộn do bấm, cùng lệnh đó nhận ’smooth’ và ’center’ — đó là ví dụ căn giữa khi bấm phía dưới.
Ghi chú
getItemElementByIndexlà phương án theo vị trí khi bạn biết slot nhưng không biết id.- Id bạn truyền là
itemIdcủa mục — cùng khóa menu dùng để theo dõi hiển thị. - Demo phát lại hành vi bằng cách remount menu với một
keymới; mỗi lần mount mới lại chạyonInit.
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 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();
}
}