Sauvegarder et restaurer la position de défilement

Un rail horizontal oublie son décalage à chaque démontage : quittez la route et revenez, repliez une section, et il saute au début. Cet exemple enregistre le décalage pendant que l’utilisateur défile et le réécrit au montage, pour que le menu réapparaisse exactement où il l’a laissé.
#01
#02
#03
#04
#05
#06
#07
#08
#09
#10
#11
#12
#13
#14

Faites défiler la rangée quelque part, démontez le menu puis remontez-le — le rail revient au même décalage.

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

Source complète

Complète et prête à copier-coller — c’est le fichier exact derrière la version Storybook modifiable en direct.

Position.source.tsx
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();
  }
}

Exemples associés

Tous les exemples (21)