Ir al contenido
Contenido y datos

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.

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.

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.

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.

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

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

PropTipoPor defectoDescripció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 «+».
labelsPartial<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.
classNamestring—Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana.

SortableList

Hereda las props de <ul>.

PropTipoPor defectoDescripció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.
defaultEditingboolean—Si arranca en modo edición, sin controlarlo.
disabledboolean—Apaga el arrastre: la lista se ve igual y no se mueve, aun en modo edición.
editingboolean—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.
itemClassNamestring | ((item: T, state: SortableListItemState) => string)—Las clases del <li> de cada fila: un texto o (item, { index, dragging, editing }) => string.
labelsPartial<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>».
plainboolean—Filas sin separador ni padding, para un renderItem que dibuja su card.
aria-labelstring—Heredada de Base UI. Nombre accesible del elemento.

Teclado

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