# SortableGrid

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

```tsx
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.

```tsx
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 «−».

```tsx
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.

```tsx
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

### 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. |

`*` obligatoria.

### 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. |

`*` obligatoria.

## Teclado

| Tecla | Qué hace |
|---|---|
| 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>`: sin `handle`, cada `<li>` es la parada de Tab (con el anillo de foco por fuera) y lleva las instrucciones en `aria-describedby`; tomada, su descripción empieza con «En movimiento» (un `listitem` no puede llevar el `aria-pressed` de 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` + `onEditingChange` con un botón «Editar»/«Listo» de la app, o `defaultEditing`. **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 de `items`. El «+» para agregar es `SortableAddButton` (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`, `renderItem` recibe la manija en `state.handle` y la pone donde vaya (en la cabecera de la tarjeta).
- `columns` fija las columnas; sin `columns`, las pone `className` (`@2xl:grid-cols-2`). `itemClassName` para una tarjeta más ancha (`col-span-2`).
- `onReorder` es optimista, con vuelta atrás si su promesa falla y la app no tocó `items` (el mismo contrato que `SortableList`). Si la app cambia `items` a mitad del arrastre, lo nuevo se ve y el orden al soltar sale de ahí. Peers opcionales `@dnd-kit/*`, como `SortableList`. Solo por subpath (`sebs7n-ui/sortable-grid`).

## Relacionados

[sortable-list](/docs/components/sortable-list.md) · [widget-card](/docs/components/widget-card.md) · [card](/docs/components/card.md)
