Sauvegarder et restaurer la position de défilement
Faites défiler la rangée quelque part, démontez le menu puis remontez-le — le rail revient au même décalage.
Modifier cet exemple en direct dans Storybook
Comment ça marche
Deux callbacks portent toute la fonction. onUpdate se déclenche quand l’état de visibilité du menu change pendant que l’utilisateur défile ; savePos lit api.scrollContainer.current.scrollLeft et l’écrit dans sessionStorage. Au montage suivant, onInit assigne la valeur enregistrée directement à scrollLeft — une simple écriture de propriété, donc la restauration est instantanée plutôt qu’une animation rejouée devant l’utilisateur.
Survivre aux remontages, rechargements et navigations arrière
sessionStorage survit au composant : les changements de route côté client, les rendus conditionnels et les rechargements complets de la page reviennent tous au décalage enregistré, et la valeur est par onglet, donc deux onglets ne s’écrasent pas. Pour la navigation par historique, la story définit aussi window.history.scrollRestoration = ’manual’, empêchant la restauration de défilement du navigateur de se battre avec la restauration manuelle sur retour et avant.
Notes
- Restaurer par
scrollLeftbrut est précis au pixel et ne se soucie pas des éléments existants — aucun id à retenir, rien à chercher. - Le bouton Recharger de la story change la
keydu menu pour forcer un remontage ; le commutateur démonter/remonter de la démo est le même test rendu explicite. - Réinitialiser supprime simplement la clé de stockage — le montage suivant repart de zéro, comme une première visite.
Source complète
Complète et prête à copier-coller — c’est le fichier exact derrière la version Storybook modifiable en direct.
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();
}
}