Прокрутка к конкретному элементу в горизонтальном списке
onInit передаёт api, а scrollToItem делает позиционирование.Рельс не монтируется на Tokyo — onInit сразу прыгает на quito. Перетащите в другое место и перемонтируйте, чтобы увидеть, как он снова приземляется туда.
Редактировать этот пример вживую в Storybook
Как это работает
ScrollMenu принимает колбэк onInit и вызывает его, когда меню отрендерилось и измерило свои элементы, передавая тот же объект api, что VisibilityContext предоставляет внутри. Обработчик ищет элемент через getItemElementById(id) и передаёт его в scrollToItem(item, ’auto’, ’start’). Поскольку onInit срабатывает только после измерения, поиск не может вернуться пустым для отрендеренного элемента — ни setTimeout, ни цикла повторов.
Поведение и выравнивание
История передаёт ’auto’ и ’start’: ’auto’ прыгает без анимации — именно это нужно для начальной позиции, ведь пользователь никогда не видит рельс на первом элементе. ’start’ выравнивает левый край элемента по рельсу. Для прокрутки по клику тот же вызов принимает ’smooth’ и ’center’ — это пример «по центру по клику» ниже.
Примечания
getItemElementByIndex— позиционная альтернатива, когда вы знаете слот, но не id.- Передаваемый id — это
itemIdэлемента, тот же ключ, что меню использует для отслеживания видимости. - Демо воспроизводит поведение, перемонтируя меню с новым
key; каждое свежее монтирование снова запускаетonInit.
Полный исходник
Полный и готовый к копированию — это точный файл, стоящий за редактируемой вживую версией 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 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();
}
}
Похожие примеры
- Центрировать кликнутый элементscrollToItem с inline: center — паттерн прокручиваемых вкладок.
- Сохранение и восстановление позиции прокруткиСохраняйте смещение прокрутки между размонтированиями и перезагрузками.
- Добавить элемент и прокрутить к немуПаттерн чипов-фильтров: добавить, затем привести в видимость.