Desplazarse de un elemento en uno en lugar de una página completa
onClick de la flecha. Mismo menú, mismos elementos, distinto objetivo de desplazamiento.Haz clic en una flecha — la fila avanza una tarjeta, no una página. Las flechas se deshabilitan en los extremos.
Edita este ejemplo en vivo en Storybook
Cómo funciona
getNextElement() devuelve el primer elemento más allá del grupo visible; getPrevElement() el que está justo antes. La flecha derecha llama a scrollToItem(visibility.getNextElement(), ’smooth’, ’end’) — alinear ese elemento con el borde final del contenedor desplaza lo justo para traerlo a la vista, lo que mueve la fila exactamente una tarjeta. La flecha izquierda es su espejo: elemento anterior, alineado a ’start’.
La alineación es todo el truco
El scrollNext() estándar resuelve el mismo elemento siguiente internamente, pero lo alinea con el borde inicial — la vista se desplaza más allá de todo el grupo visible para poner ese elemento primero. Un argumento ScrollLogicalPosition es la diferencia entre paginar y dar pasos. El tercer parámetro de scrollToItem es la alineación inline estándar de scroll-into-view; el segundo es el comportamiento, aquí ’smooth’.
Notas
- El estado de las flechas usa los atajos
’first’y’last’:useIsVisible(’first’, true)deshabilita la flecha izquierda al inicio,useIsVisible(’last’, false)la derecha al final. - En los extremos
getNextElement()devuelve undefined yscrollToItemno hace nada en silencio, así que una flecha habilitada tampoco puede sobre-desplazarse. - El manejador
onWheelde la historia sigue paginando una vista completa por muesca de rueda — dar pasos es el comportamiento de las flechas, no un modo global. - Los clics de elemento están intactos: las tarjetas alternan la selección mediante su propio
onClick, independiente de cómo desplacen las flechas.
Fuente completa
Completa y lista para copiar y pegar: este es el archivo exacto que está detrás de la versión de Storybook editable en vivo.
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();
}
}