Skip to content
UI
Language:en Author:AtomForge

Sortable lists

Recipe: move tasks between two lanes

Complete component with reactive lane arrays, typed row snippets, handle-only dragging, and a local change notification. No external save function is assumed.

<script lang="ts">
  import { Button, SortableGroup, SortableList } from '@atom-forge/ui';
  import { Inbox } from 'lucide-svelte';

  type Task = { id: string; title: string };

  let todo = $state<Task[]>([
    { id: 'task-1', title: 'Plan' },
    { id: 'task-2', title: 'Design' },
  ]);
  let doing = $state<Task[]>([{ id: 'task-3', title: 'Build' }]);
  let changes = $state(0);
  let lastChangedLane = $state('');

  function changed(lane: string) {
    changes += 1;
    lastChangedLane = lane;
  }
</script>


<SortableGroup class="grid grid-cols-1 gap-4 md:grid-cols-2">
  <section aria-label="To do">
    <h2>To do</h2>
    <SortableList
      id="todo"
      bind:items={todo}
      dragHandleSelector="[data-handle]"
      class="min-h-40 gap-2"
      empty={{ icon: Inbox, title: 'No tasks to do' }}
      onchange={() => changed('To do')}
    >
      {#snippet item(task)}
        <div class="flex items-center gap-2 rounded border border-frame bg-control p-3">
          <Button type="button" ghost compact data-handle label={`Move ${task.title}`} class="cursor-grab"/>
          <span>{task.title}</span>
        </div>
      {/snippet}
    </SortableList>
  </section>
  <section aria-label="Doing">
    <h2>Doing</h2>
    <SortableList
      id="doing"
      bind:items={doing}
      dragHandleSelector="[data-handle]"
      class="min-h-40 gap-2"
      empty={{ icon: Inbox, title: 'Nothing in progress' }}
      onchange={() => changed('Doing')}
    >
      {#snippet item(task)}
        <div class="flex items-center gap-2 rounded border border-frame bg-control p-3">
          <Button type="button" ghost compact data-handle label={`Move ${task.title}`} class="cursor-grab"/>
          <span>{task.title}</span>
        </div>
      {/snippet}
    </SortableList>
  </section>
</SortableGroup>
<p role="status">{changes ? `Local list updates: ${changes}. Last changed: ${lastChangedLane}.` : 'Move a task to update the board.'}</p>

The native button rendered by Button receives data-handle, which matches the selector. The dependency supplies drag-handle interaction; test pointer and keyboard operation in your consuming app. The counter counts list notifications, not drag gestures: transfer can update both lanes.

For a single queue, omit SortableGroup and keep one uniquely identified SortableList. Independent ungrouped lists must also have distinct list IDs; identical IDs share a DnD type and can exchange items unintentionally.

Application responsibilities

  • Persist order and lane membership from onchange(nextItems, detail) with explicit error handling, retry, and rollback. Bound arrays are assigned before this callback; it is not a cancellable validation hook.
  • For a board, coordinate notifications and persist a consistent whole-board snapshot rather than assuming one lane callback represents an atomic cross-list transaction.
  • Keep IDs stable and unique across all exchanging zones. Never use the current array index as item identity.
  • Do not assume SortableGroup.rules or typeField enforces allowed transfers: the current SortableList implementation does not use that legacy rule engine. Enforce permissions/restrictions in application design; choose a different interaction if invalid drops must be prevented.
  • Handle transfer details defensively: fromIndex or toIndex can be -1 in the entering/leaving list; callbacks fire only when membership/order changes, not on every hover.
  • There is no dedicated disabled prop, and legacy preview props are compatibility-only. Own pending-state restrictions, undo, concurrency, and an alternative non-drag move workflow where required.