Ajouter un élément et défiler vers lui — le motif des puces de filtre

Une barre de puces grandit quand l’utilisateur choisit un filtre, et la nouvelle puce doit finir à l’écran, pas cachée au-delà du bord droit. Le piège : on ne peut pas défiler vers un élément qui n’est pas encore rendu. Cet exemple divise le travail entre un gestionnaire de clic et un effet.

Cliquez sur Ajouter un filtre — la puce apparaît à la fin et la rangée défile pour la révéler. Le x retire une puce.

Comment ça marche

Le menu reçoit un apiRef, qui expose l’API complète hors de l’arbre de composants. addItem fait deux choses : il stocke le nouvel id dans une ref lastAdded, puis ajoute l’élément à l’état. Il ne défile volontairement pas — à ce moment, la puce n’est que de l’état, pas du DOM.

Pourquoi le défilement vit dans un effet

getItemElementById cherche l’élément dans le DOM, donc le défilement ne peut se produire qu’après que React a validé le nouvel élément. Un useEffect dépendant de items s’exécute exactement à ce point : il lit lastAdded, le vide et appelle apiRef.current.scrollToItem(el, ’smooth’, ’end’). Vider la ref compte — les re-rendus pour toute autre raison (sélection, flèches) atteignent aussi l’effet et ne doivent pas défiler à nouveau.

Notes

Source complète

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

AddItemAndScrollToIt.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 AddItemAndScrollToItExample() {
  const [items, setItems] = 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 apiRef = React.useRef<publicApiType>(null);
  // The id of the item added by the last click; empty on mount and after the
  // scroll fires, so re-renders for other reasons don't scroll again.
  const lastAdded = React.useRef<string | null>(null);

  const addItem = () => {
    const newId = getId(items.length);
    lastAdded.current = newId;
    setItems((currentItems) => currentItems.concat({ id: newId }));
  };

  // `getItemElementById` looks the item up in the DOM, so the scroll can only
  // happen after React has rendered the new item — an effect keyed on `items`
  // rather than a call inside the click handler.
  React.useEffect(() => {
    const newId = lastAdded.current;
    if (!newId) {
      return;
    }
    lastAdded.current = null;

    const el = apiRef.current?.getItemElementById(newId);
    if (el) {
      apiRef.current?.scrollToItem(el, 'smooth', 'end');
    }
  }, [items]);

  return (
    <div>
      <div>
        <button onClick={addItem} data-testid="add-item">
          Add item
        </button>
      </div>

      <NoScrollbar>
        <ScrollMenu
          LeftArrow={LeftArrow}
          RightArrow={RightArrow}
          onWheel={onWheel}
          apiRef={apiRef}
        >
          {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>
    </div>
  );
}

export default AddItemAndScrollToItExample;

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 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)