Añadir un elemento y desplazarse a él — el patrón de chips de filtro
Pulsa «Añadir filtro»: el chip aparece al final y la fila se desplaza para mostrarlo. La x elimina un chip.
Edita este ejemplo en vivo en Storybook
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
lastAddedes una ref, no estado: escribirla no debe causar por sí mismo un render, y su valor solo tiene sentido para la siguiente ejecución del efecto.’end’alinea el nuevo chip con el borde derecho de la fila;’center’funciona igual si lo quieres en medio.- Las flechas aquí usan los hooks
useLeftArrowVisible()yuseRightArrowVisible()— una forma más corta del paruseIsVisible(’first’/’last’). - La barra de desplazamiento se oculta con CSS simple sobre la clase
scroll-containerde la biblioteca; el desplazamiento en sí sigue siendo nativo.
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.
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();
}
}