Sortable Grid
Tarjetas en una grilla que, en modo edición, tiemblan y se reordenan arrastrando —las demás se corren— o con el teclado; se sacan con «−» y se agregan con el «+» de SortableAddButton, como la pantalla de inicio de iOS.
import { SortableAddButton, SortableGrid } from "sebs7n-ui/sortable-grid"Ejemplos
Widgets
Se ordena en modo edición: «Editar», o mantené apretada una tarjeta. La tarjeta entera se arrastra y las demás se corren; con el teclado, Tab hasta una, Espacio, flechas y Espacio. Esc o un clic afuera sale.
import { SortableGrid } from "sebs7n-ui/sortable-grid"
import { useState } from "react"
function Widgets() {
const [widgets, setWidgets] = useState(WIDGETS)
const [editing, setEditing] = useState(false)
return (
<div className="flex w-full max-w-lg flex-col gap-3">
<EditButton editing={editing} onEditingChange={setEditing} />
<SortableGrid
aria-label="Resumen"
columns={2}
editing={editing}
getKey={(widget) => widget.id}
getLabel={(widget) => widget.title}
items={widgets}
onEditingChange={setEditing}
onReorder={setWidgets}
renderItem={(widget) => <WidgetCardDemo widget={widget} />}
/>
</div>
)
}Sacar y agregar
`onRemove` pone un «−» en cada tarjeta, que la saca sin confirmar. `SortableAddButton` es el «+» al lado de «Listo»: un menú con las que sacaste; la que elegís vuelve al final, con el foco en su «−».
import { SortableAddButton, SortableGrid } from "sebs7n-ui/sortable-grid"
import { useState } from "react"
function EditMode() {
const [widgets, setWidgets] = useState(WIDGETS)
const [editing, setEditing] = useState(true)
const removed = WIDGETS.filter((widget) => !widgets.includes(widget))
return (
<div className="flex w-full max-w-lg flex-col gap-3">
<div className="flex items-center justify-end gap-1">
{editing && (
<SortableAddButton
items={removed.map((widget) => ({ id: widget.id, label: widget.title }))}
onSelect={(id) => setWidgets([...widgets, ...WIDGETS.filter((widget) => widget.id === id)])}
size="icon-sm"
/>
)}
<EditButton editing={editing} onEditingChange={setEditing} />
</div>
<SortableGrid
aria-label="Resumen editable"
columns={2}
editing={editing}
getKey={(widget) => widget.id}
getLabel={(widget) => widget.title}
items={widgets}
onEditingChange={setEditing}
onRemove={(id) => setWidgets(widgets.filter((widget) => widget.id !== id))}
onReorder={setWidgets}
renderItem={(widget) => <WidgetCardDemo widget={widget} />}
/>
</div>
)
}Con manija
`handle`: en edición, se arrastra solo desde la manija ⋮⋮, que `renderItem` pone donde va. El resto de la tarjeta queda para sus propios clicks.
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "sebs7n-ui"
import { SortableGrid } from "sebs7n-ui/sortable-grid"
import { useState } from "react"
function WithHandle() {
const [widgets, setWidgets] = useState(WIDGETS)
const [editing, setEditing] = useState(false)
return (
<div className="flex w-full max-w-lg flex-col gap-3">
<EditButton editing={editing} onEditingChange={setEditing} />
<SortableGrid
aria-label="Resumen con manija"
columns={2}
editing={editing}
getKey={(widget) => widget.id}
getLabel={(widget) => widget.title}
handle
items={widgets}
onEditingChange={setEditing}
onReorder={setWidgets}
renderItem={(widget, state) => (
<Card size="sm">
<CardHeader>
<CardTitle>{widget.title}</CardTitle>
<CardDescription>{widget.value}</CardDescription>
</CardHeader>
{state.handle && <CardContent className="flex justify-end">{state.handle}</CardContent>}
</Card>
)}
/>
</div>
)
}Props
Generadas del TypeScript del paquete. Las propias del componente, más las heredadas del primitivo que tienen algo que explicar —marcadas «heredada de Base UI»—. El resto está en la línea «hereda de».
SortableAddButton
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
items* | readonly SortableAddItem[] | — | Lo que se puede agregar: { id, label, icon? }[] (lo que se sacó, lo que falta). El ícono es decorativo (aria-hidden). Vacío, el «+» queda deshabilitado pero enfocable y dice «No hay más para agregar». |
onSelect* | (id: string) => void | — | Recibe el id elegido; la app lo suma a items de la que está en edición. Si el id es su clave (getKey) y llega dentro del medio segundo, el foco va a su «−» y se anuncia «Se agregó …»; si no llega, el foco vuelve al «+». |
labels | Partial<Pick<SortableLabels, "add" | "nothingToAdd">> | — | Textos: add (el nombre del «+») y nothingToAdd (por qué está deshabilitado). Le gana a LabelsProvider (sortable). |
size | "icon-sm" | "icon-md" | "icon-md" | icon-md (36) como los botones de una barra; icon-sm (28) al lado de un «Listo» sm. |
className | string | — | Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana. |
SortableGrid
Hereda las props de <ul>.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
getKey* | (item: T) => string | — | La clave estable de cada ítem (su id). |
items* | readonly T[] | — | Los ítems en el orden actual. |
onReorder* | (items: T[]) => void | Promise<unknown> | — | Recibe los ítems en el orden nuevo. El orden cambia en pantalla al soltar. - Optimista: devolvé una promesa y no toques items hasta que se resuelva. Si falla, el componente vuelve al orden anterior y anuncia labels.failed. - La app lo aplica: si cambiás items vos (con o sin promesa), el orden es tuyo. Si después falla, revertilo y avisá vos: el componente no puede deshacer lo que ya es tu estado y no anuncia una vuelta atrás que no pasó. |
renderItem* | (item: T, state: SortableItemState) => React.ReactNode | — | El contenido de cada ítem. Recibe la manija, si está arrastrando y su posición. |
columns | number | — | Columnas fijas. Sin columns, las pone className (grid-cols-2, @2xl:grid-cols-3…). |
defaultEditing | boolean | — | Si arranca en modo edición, sin controlarlo. |
disabled | boolean | — | Apaga el arrastre: la lista se ve igual y no se mueve, aun en modo edición. |
editing | boolean | — | Modo edición, controlado. Como la pantalla de inicio de iOS: solo en edición se arrastra. La app lo prende con su botón («Editar») y lo apaga con «Listo». |
getLabel | (item: T) => string | — | El nombre del ítem en la manija y en los anuncios. Por defecto, getKey. |
handle | boolean | false | Arrastrar solo desde la manija que renderItem recibe en state.handle. |
itemClassName | string | ((item: T, index: number) => string) | — | Clases del <li> de cada tarjeta: col-span-2 para una más ancha. |
labels | Partial<SortableLabels> | — | Los mismos textos que SortableList (sortableLabels). |
onEditingChange | (editing: boolean) => void | — | Cuando se entra (mantener apretado un ítem ~0,5 s) o se sale (Esc, clic en un espacio vacío). |
onRemove | (key: string) => void | — | Con esto, en edición cada ítem trae un «−» (arriba a la izquierda en la grilla, adelante en la lista) que lo saca sin confirmar: recibe su clave, la app lo saca de items, y se anuncia «Se sacó <nombre>». |
style | CSSProperties | — | Se fusiona con el gridTemplateColumns que pone columns. |
aria-label | string | — | Heredada de Base UI. Nombre accesible del elemento. |
Teclado
- Tab
- En edición, recorre las tarjetas (o las manijas, con
handle), sus «−» y lo interactivo de adentro. - Espacio · Enter
- Sobre la tarjeta, la toma; tomada, la suelta. En un botón de adentro es del botón.
- ← → ↑ ↓
- Con la tarjeta tomada, la mueven en la grilla.
- Escape
- Con una tarjeta tomada, cancela: vuelve a su lugar. Si no, sale de la edición.
Accesibilidad
- Es un
<ul role="list">de<li>: sinhandle, cada<li>es la parada de Tab (con el anillo de foco por fuera) y lleva las instrucciones enaria-describedby; tomada, su descripción empieza con «En movimiento» (unlistitemno puede llevar elaria-pressedde la manija). - Fuera de edición la tarjeta no es parada de Tab ni tiene instrucciones: el arrastre no existe.
- Los anuncios son los de
SortableList: «Tomaste Facturas, posición 1 de 4». El «−» es un<button>«Sacar Facturas» y al sacar se anuncia «Se sacó Facturas.». - Con movimiento reducido las tarjetas no se deslizan ni tiemblan: en edición llevan un contorno punteado sutil.
- Entrar y salir de la edición se anuncia («Modo edición. Arrastrá para ordenar.» · «Listo.»). Si la app saca una tarjeta tarde (después de guardar), el foco espera en su «−» y pasa al siguiente cuando se fue.
Reglas de uso
- Solo se ordena en modo edición:
editing+onEditingChangecon un botón «Editar»/«Listo» de la app, odefaultEditing. El botón es obligatorio para el teclado: mantener apretado es solo de puntero. Se edita una grilla por vez: entrar en otra saca a la que estaba. También entra manteniendo apretada una tarjeta ~0,5 s (mouse o dedo; un clic normal no, y el clic que sigue no abre nada), y sale con Esc o un clic en un espacio vacío. En edición las tarjetas tiemblan (±1°, menos en las anchas para que el borde no se corra más de ~2 px; la que se arrastra no). onRemove(key)pone en cada tarjeta el «−» de iOS, arriba a la izquierda: saca sin confirmar y la app la saca deitems. El «+» para agregar esSortableAddButton(del mismo subpath): va al lado del «Listo» de la app y abre un menú con lo que se puede agregar (items: { id, label, icon? }[],onSelect(id)); vacío, queda deshabilitado y dice «No hay más para agregar». Si el id es la clave del ítem, al aparecer el foco va a su «−» y se anuncia «Se agregó …». Hay que dejar lugar arriba a la izquierda: el «−» sobresale 8 px.- Sin `handle` se arrastra la tarjeta entera: con el mouse arranca a los 8 px, así un click en un botón de adentro sigue siendo un click; con el dedo, después de 250 ms apretado, así deslizar sigue scrolleando.
- Con
handle,renderItemrecibe la manija enstate.handley la pone donde vaya (en la cabecera de la tarjeta). columnsfija las columnas; sincolumns, las poneclassName(@2xl:grid-cols-2).itemClassNamepara una tarjeta más ancha (col-span-2).onReorderes optimista, con vuelta atrás si su promesa falla y la app no tocóitems(el mismo contrato queSortableList). Si la app cambiaitemsa mitad del arrastre, lo nuevo se ve y el orden al soltar sale de ahí. Peers opcionales@dnd-kit/*, comoSortableList. Solo por subpath (sebs7n-ui/sortable-grid).