Guardar y restaurar la posición de desplazamiento
Desplaza la fila a algún sitio, desmonta el menú y móntalo de nuevo — el carril vuelve al mismo desplazamiento.
Edita este ejemplo en vivo en Storybook
Cómo funciona
Dos callbacks cargan con toda la función. onUpdate se dispara cuando cambia el estado de visibilidad del menú mientras el usuario se desplaza; savePos lee api.scrollContainer.current.scrollLeft y lo escribe en sessionStorage. En el siguiente montaje, onInit asigna el valor guardado directamente a scrollLeft — una escritura de propiedad simple, así que la restauración es instantánea en lugar de una animación que se reproduce ante el usuario.
Sobrevivir a remontajes, recargas y navegación atrás
sessionStorage sobrevive al componente: los cambios de ruta en el cliente, los renders condicionales y las recargas de página completas vuelven al desplazamiento guardado, y el valor es por pestaña, de modo que dos pestañas no se sobrescriben. Para la navegación por historial, la historia también fija window.history.scrollRestoration = ’manual’, evitando que la restauración de desplazamiento del navegador luche contra la manual al ir atrás y adelante.
Notas
- Restaurar con
scrollLeftcrudo es exacto al píxel y no le importa qué elementos existan — sin id que recordar, sin nada que buscar. - El botón Recargar de la historia cambia la
keydel menú para forzar un remontaje; el conmutador desmontar/montar de la demo es la misma prueba hecha explícita. - Reiniciar solo elimina la clave de almacenamiento — el siguiente montaje empieza de cero, como una primera visita.
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 Position() {
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),
);
};
const { getPosition, setPosition, reset } = usePosition();
const savePos = React.useCallback(
(api: publicApiType) => {
setPosition(api.scrollContainer.current?.scrollLeft ?? 0);
},
[setPosition],
);
const restorePosition = React.useCallback(
(api: publicApiType) => {
const node = api.scrollContainer.current;
if (node) {
node.scrollLeft = getPosition();
}
},
[getPosition],
);
const [key, setKey] = React.useState(() => String(Math.random()));
const reload = React.useCallback(() => setKey(String(Math.random())), []);
return (
<>
<ScrollMenu
LeftArrow={LeftArrow}
RightArrow={RightArrow}
onWheel={onWheel}
onUpdate={savePos}
onInit={restorePosition}
key={key}
>
{items.map(({ id }) => (
<Card
title={id}
itemId={id} // NOTE: itemId is required for track items
key={id}
onClick={() => handleItemClick(id)}
selected={isItemSelected(id)}
/>
))}
</ScrollMenu>
<div>
<button onClick={reset} data-testid="reset">
Reset position
</button>
<button onClick={reload} data-testid="reload">
Reload
</button>
</div>
</>
);
}
const usePosition = () => {
React.useEffect(() => {
window.history.scrollRestoration = 'manual';
}, []);
const setPosition = React.useCallback((pos: number | string) => {
sessionStorage.setItem('position', String(pos));
}, []);
const getPosition = () => +(sessionStorage.getItem('position') || 0);
const reset = React.useCallback(
() => sessionStorage.removeItem('position'),
[],
);
return { getPosition, setPosition, reset };
};
export default Position;
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();
}
}