Añadir un elemento y desplazarse a él — el patrón de chips de filtro

Una barra de chips crece cuando el usuario elige un filtro, y el nuevo chip debe acabar en pantalla, no oculto tras el borde derecho. La trampa: no puedes desplazarte a un elemento que aún no se ha renderizado. Este ejemplo divide el trabajo entre un manejador de clic y un efecto.

Pulsa «Añadir filtro»: el chip aparece al final y la fila se desplaza para mostrarlo. La x elimina un chip.

Cómo funciona

El menú recibe un apiRef, que expone la API completa fuera del árbol de componentes. addItem hace dos cosas: guarda el nuevo id en una ref lastAdded y añade el elemento al estado. A propósito no se desplaza — en ese momento el chip es solo estado, no DOM.

Por qué el desplazamiento vive en un efecto

getItemElementById busca el elemento en el DOM, así que el desplazamiento solo puede ocurrir después de que React haya confirmado el nuevo elemento. Un useEffect dependiente de items se ejecuta exactamente en ese punto: lee lastAdded, lo limpia y llama a apiRef.current.scrollToItem(el, ’smooth’, ’end’). Limpiar la ref importa — los re-renderizados por cualquier otra razón (selección, flechas) también alcanzan el efecto y no deben volver a desplazar.

Notas

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.

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();
  }
}

Ejemplos relacionados

Todos los ejemplos (21)