# SortableList

> Una lista que en modo edición se reordena arrastrando la manija ⋮⋮ de cada fila, o con el teclado, con cada movimiento anunciado; y saca y agrega filas.

```tsx
import { SortableAddButton, SortableList } from "sebs7n-ui/sortable-list"
```

## Ejemplos

### Las líneas de una factura

«Editar» (o mantener apretada una fila) muestra el «−» y la manija ⋮⋮: arrastrala, o enfocala con Tab y usá Espacio, ↑/↓ y Espacio. El «+» al lado de «Listo» (`SortableAddButton`) suma una línea del catálogo.

```tsx
import { SortableAddButton, SortableList } from "sebs7n-ui/sortable-list"
import { useState } from "react"

function InvoiceLines() {
  const [lines, setLines] = useState(LINES)
  const [editing, setEditing] = useState(false)
  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <div className="flex items-center justify-end gap-1">
        {editing && (
          <SortableAddButton
            items={CATALOG.filter((line) => !lines.includes(line)).map((line) => ({ id: line.id, label: line.concept }))}
            onSelect={(id) => setLines([...lines, ...CATALOG.filter((line) => line.id === id)])}
            size="icon-sm"
          />
        )}
        <EditButton editing={editing} onEditingChange={setEditing} />
      </div>
      <SortableList
        aria-label="Líneas de la factura"
        editing={editing}
        getKey={(line) => line.id}
        getLabel={(line) => line.concept}
        items={lines}
        onEditingChange={setEditing}
        onRemove={(id) => setLines(lines.filter((line) => line.id !== id))}
        onReorder={setLines}
        renderItem={(line) => <LineRow line={line} />}
      />
    </div>
  )
}
```

### Si guardar falla

`onReorder` devuelve una promesa: el orden cambia al soltar y, si la promesa falla, vuelve el anterior y se anuncia.

```tsx
import { SortableList } from "sebs7n-ui/sortable-list"
import { useState } from "react"

function Rollback() {
  const [editing, setEditing] = useState(false)
  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <EditButton editing={editing} onEditingChange={setEditing} />
      <SortableList
        aria-label="Líneas de la factura, sin conexión"
        editing={editing}
        getKey={(line) => line.id}
        getLabel={(line) => line.concept}
        items={LINES}
        onEditingChange={setEditing}
        onReorder={() => new Promise((_, reject) => setTimeout(() => reject(new Error("Sin conexión")), 800))}
        renderItem={(line) => <LineRow line={line} />}
      />
    </div>
  )
}
```

### Cada ítem es una card

`plain` saca el separador y el padding de la fila, así `renderItem` dibuja su card; `itemClassName` recibe el ítem y su estado (`dragging`, `editing`) para las clases del `<li>`, sin apuntar a la estructura de adentro.

```tsx
import { SortableList } from "sebs7n-ui/sortable-list"
import { useState } from "react"

function Cards() {
  const [clients, setClients] = useState(CLIENTS)
  const [editing, setEditing] = useState(false)
  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <EditButton editing={editing} onEditingChange={setEditing} />
      <SortableList
        aria-label="Clientes"
        className="gap-3"
        editing={editing}
        getKey={(client) => client.id}
        getLabel={(client) => client.name}
        itemClassName={(_client, state) => (state.dragging ? "rounded-surface" : undefined)}
        items={clients}
        onEditingChange={setEditing}
        onReorder={setClients}
        plain
        renderItem={(client) => (
          <div className="flex min-w-0 flex-1 flex-col gap-0.5 rounded-surface bg-surface px-4 py-3 shadow-widget">
            <span className="truncate text-body text-label">{client.name}</span>
            <span className="text-callout text-label-secondary">{client.pending}</span>
          </div>
        )}
      />
    </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.

### SortableList

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. |
| `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`. |
| `itemClassName` | `string \| ((item: T, state: SortableListItemState) => string)` | — | Las clases del `<li>` de cada fila: un texto o `(item, { index, dragging, editing }) => string`. |
| `labels` | `Partial<SortableLabels>` | — | Textos: `handle`, `instructions`, `picked`, `dropped`, `canceled`, `position`, `of`, `failed`, `grabbed` (solo `SortableGrid` sin manija), `remove`, `removed`, `add` y `added` (el «+» de `SortableAddButton` y su anuncio), `nothingToAdd`, `editing` y `done`. Los que vienen por defecto son `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>». |
| `plain` | `boolean` | — | Filas sin separador ni padding, para un `renderItem` que dibuja su card. |
| `aria-label` | `string` | — | **Heredada de Base UI.** Nombre accesible del elemento. |

`*` obligatoria.

## Teclado

| Tecla | Qué hace |
|---|---|
| Tab | En edición, recorre los «−» y las manijas. |
| Espacio · Enter | Toma la fila; con la fila tomada, la suelta en el lugar nuevo. |
| ↑ / ↓ | Con la fila tomada, la mueven. |
| Escape | Con una fila tomada, cancela: vuelve a su lugar. Si no, sale de la edición. |

## Accesibilidad

- Cada fila es un `ListRow` de un `<ul role="list">`: nombrá la lista con `aria-label`.
- La manija es un `<button>` «Reordenar Factura 0012» (`getLabel` da el nombre) con las instrucciones en su `aria-describedby`. Fuera de edición no está: no hay paradas de Tab que no hagan nada.
- Tomar, mover, soltar y cancelar se anuncian en una región viva: «Tomaste Factura 0012, posición 2 de 5».
- El «−» es un `<button>` «Sacar Factura 0012»; al sacar se anuncia «Se sacó Factura 0012.» y el foco pasa al «−» que queda en su lugar.
- Con movimiento reducido las filas no se deslizan: saltan a su lugar.
- Entrar y salir de la edición se anuncia: «Modo edición. Arrastrá para ordenar.» y «Listo.» (`labels.editing` y `labels.done`). Si se saca la última fila, el foco queda en la lista.

## Reglas de uso

- **Solo se ordena en modo edición**, como en iOS: `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, así que sin él quien usa teclado nunca entra en edición. También entra manteniendo apretada una fila ~0,5 s (mouse o dedo; un clic normal no), y sale con Esc o un clic afuera. En edición cada fila lleva el «−» adelante (con `onRemove`) y la manija al final.
- 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ó …».
- `renderItem` devuelve **el contenido** de la fila, no un `<li>` (un `<li>` adentro de otro rompe la hidratación). Recibe `state.editing`.
- `itemClassName` pone clases en el `<li>` de cada fila: un texto, o una función del ítem y su estado (`index`, `dragging`, `editing`). `plain` saca el separador y el padding de la fila, para un `renderItem` que dibuja su propia card (el espacio entre cards, `className="gap-3"`). Nunca `[&>li]` desde la app: la estructura de adentro puede cambiar.
- `onReorder` recibe los ítems en el orden nuevo, que se ve al soltar. **Optimista:** devolvé una promesa sin tocar `items`; si falla, vuelve el anterior y se anuncia «No se pudo guardar el orden». **Si la app aplica el orden ella** (cambia `items`), revertirlo y avisar si falla también es suyo: el componente no anuncia una vuelta atrás que no hizo.
- **`@dnd-kit/core`, `@dnd-kit/sortable` y `@dnd-kit/utilities` son peers opcionales:** `npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities` en la app que lo usa. Solo por subpath (`sebs7n-ui/sortable-list`).

## Relacionados

[sortable-grid](/docs/components/sortable-grid.md) · [list-row](/docs/components/list-row.md)
