Un menú horizontal de derecha a izquierda

Para interfaces en árabe o hebreo, la fila debe empezar en el borde derecho y crecer hacia la izquierda. Una prop booleana invierte el contenedor de desplazamiento; el único trabajo real que te queda es decidir qué significan las flechas cuando «siguiente» apunta a la izquierda.

Voltea el interruptor — la fila se reinicia desde el borde opuesto y las flechas intercambian roles.

Cómo funciona

RTL={true} pone el contenedor de desplazamiento en modo derecha-a-izquierda: el primer elemento se sienta en el borde derecho y el desplazamiento avanza hacia la izquierda. Todo lo lógico sigue siendo lógico — useIsVisible(’first’) sigue significando el primer elemento de tus datos, scrollNext() sigue moviéndose hacia el último — solo se invierte la dirección en pantalla.

Las flechas cambian de ranura, no de lógica

La prop LeftArrow siempre se renderiza en el lado izquierdo de la pantalla. En RTL ese lado es donde vive «siguiente», así que la historia alimenta las ranuras con elementos intercambiados: LeftArrow={RTL ? <RightArrow /> : <LeftArrow />}. Los propios componentes mantienen su lógica — el conectado a scrollPrev sigue deshabilitándose mediante useIsVisible(’first’) — solo cambian su posición en pantalla y su etiqueta.

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.

RTL.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 RTL() {
  const [RTL, setRTL] = React.useState(true);
  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),
    );
  };

  return (
    <>
      <NoScrollbar>
        <ScrollMenu
          LeftArrow={RTL ? <RightArrow RTL={RTL} /> : <LeftArrow RTL={RTL} />}
          RightArrow={RTL ? <LeftArrow RTL={RTL} /> : <RightArrow RTL={RTL} />}
          onWheel={onWheel}
          RTL={RTL}
          noPolyfill={true}
        >
          {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>

      <Checkbox label="RTL" value={RTL} onClick={setRTL} />
    </>
  );
}

export default RTL;

function LeftArrow({ RTL }: { RTL: boolean }) {
  const visibility = React.useContext<publicApiType>(VisibilityContext);
  const isFirstItemVisible = visibility.useIsVisible('first', true);

  return (
    <Arrow
      disabled={isFirstItemVisible}
      onClick={() => visibility.scrollPrev('smooth', 'end')}
      testId={RTL ? 'right-arrow' : 'left-arrow'}
    >
      {RTL ? 'Right' : 'Left'}
    </Arrow>
  );
}

function RightArrow({ RTL }: { RTL: boolean }) {
  const visibility = React.useContext<publicApiType>(VisibilityContext);
  const isLastItemVisible = visibility.useIsVisible('last', false);

  return (
    <Arrow
      disabled={isLastItemVisible}
      onClick={() => visibility.scrollNext('smooth', 'start')}
      testId={RTL ? 'left-arrow' : 'right-arrow'}
    >
      {RTL ? 'Left' : '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',
}));

const Checkbox = ({
  onClick,
  value,
  label,
}: {
  value: boolean;
  label: string;
  onClick: (val: boolean) => void;
}) => {
  return (
    <CheckboxWrapper>
      <BigCheckbox
        type="checkbox"
        id={label}
        onChange={(ev: React.ChangeEvent<HTMLInputElement>) =>
          onClick(ev?.target?.checked)
        }
        checked={value}
        defaultChecked={value}
      />
      <label htmlFor={label}>{label}</label>
    </CheckboxWrapper>
  );
};
const CheckboxWrapper = styled('div')({
  display: 'flex',
  alignItems: 'center',
  margin: '16px',
  '& *:first-child': {
    marginRight: '4px',
  },
});
const BigCheckbox = styled('input')({
  height: '24px',
  width: '24px',
  cursor: 'pointer',
});

function Card({
  onClick,
  selected,
  title,
  itemId,
}: {
  onClick: (visibility: 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 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',
  },
});

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 {
  const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15;

  if (isThouchpad) {
    ev.stopPropagation();
    return;
  }

  if (ev.deltaY < 0) {
    apiObj.scrollPrev('smooth', 'end');
  } else {
    apiObj.scrollNext('smooth', 'start');
  }
}

Ejemplos relacionados

Todos los ejemplos (21)