Theme
Design your own
On this page (12)
Component Skill — components/sortable/component-skill.md

Native basis

HTML Drag and Drop API + keyboard reordering for accessible drag-and-drop lists.

Web Platform APIs

Drag and Drop APIdraggableDataTransferCustomEventaria-live:focus-visibleprefers-reduced-motionforced-colorsprefers-contrast

Classes

.sortable.sortable-item.sortable-handle.sortable-live

State attributes (managed by JS)

• data-dragging - item being dragged

• data-over="before|after" - drop indicator position

• data-active - keyboard-focused item

• aria-disabled="true" - non-interactive item

Keyboard

• ↑ / ↓ - navigate items (vertical)

• ← / → - navigate items (horizontal)

• Alt + Arrow - reorder focused item

• Home / End - jump to first/last item

§Default

Drag items to reorder, or use keyboard: Tab to focus, Arrow keys to navigate, Alt+Arrow to move items.

§Horizontal

Set data-orientation="horizontal" for a row layout. Arrow keys switch to Left/Right.

§With Icons

Rich content with icons alongside labels. The handle provides a clear drag affordance.

§Disabled Items

Set aria-disabled="true" to lock an item in place: it cannot be dragged, keyboard focus skips it, and it keeps its position while the other items move around it. A locked row in the middle is a fixed divider - move an item across it (drag it, or Alt+↑/Alt+↓) and the item steps over the locked slot while its neighbour on the other side shifts across, so the locked row stays put and both groups keep their size.

§Keyboard Reordering

Focus the list with Tab, navigate with ↑/↓, reorder with Alt+↑/Alt+↓. A screen reader live region announces each move.

§Move buttons

Up / Down buttons per item (.sortable-moves > .sortable-move[data-move=up|down]) reorder by tap or click - the way to sort on a touch screen, where drag-and-drop mostly never starts, and a visible alternative to Alt+Arrow for anyone not using a mouse. Up disables on the first item, Down on the last; focus stays on the button you pressed, so repeated taps keep moving the same item. Each button is labelled for screen readers ("Move Run tests up") and every move is announced. Dragging still works.

§Between two lists

Lists that share a data-group exchange items: drag from Backlog to Done and back, and still reorder inside each one. Dropping on a row inserts before / after it; dropping on the list's free space adds to the end, and an emptied list stays a drop zone (its data-empty text says so). Keyboard: Alt+→ / Alt+← moves the focused item to the next / previous list. Both lists fire sortable-change - the receiving one with detail.from, the one it left with detail.to.

§Density

Set data-density on the component root to scale its internal whitespace. A whitespace policy, not a zoom: only gaps and padding scale (ratio 0.75 / 1 / 1.25), typography and fixed dimensions stay identical. comfortable matches the unsized default.

§States

Named states via the shared State API, driven per instance through the bound api:

  • default - authored item order; setState('default') restores that order after any drag/keyboard moves, { index } activates one item, and getState().config reports the live order + activeIndex

The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/sortable-{state}.png.

Machine contract - verified against sortable.schema.json by bun run verify:

StateTypeValuesDefaultDescription
activeIndexnumber—-1Position of the active item (-1 = none); setState('default', { index }) activates one, drag/keyboard follow, mirrored as data-active-index.

§API

Generated from sortable.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type SortableState = 'default' - setState(name, config) takes the config of the state it names.

StateDescription
default
The authored order (setting it restores that order).
Config fieldTypeDescription
index?numberthe item to make active (roving focus), 0-based
order?string[]reported by getState(): the items' labels in the current order
activeIndex?numberreported by getState(): the active item's index, -1 for none

Every element

MemberDescription
el.api.setState<S extends SortableState>(name: S, config?: SortableStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?SortableStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: SortableState; config: SortableStateConfigs[SortableState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: SortableState; config: SortableStateConfigs[SortableState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: SortableState; config: SortableStateConfigs[SortableState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: SortableState; config: SortableStateConfigs[SortableState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: SortableState; config: SortableStateConfigs[SortableState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.sortableApi.setState<S extends SortableState>(el: HTMLElement, name: S, config?: SortableStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?SortableStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.sortableApi.getState(el: HTMLElement): { name: SortableState; config: SortableStateConfigs[SortableState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: SortableState; config: SortableStateConfigs[SortableState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.sortableApi.render(state: { name: SortableState; config: SortableStateConfigs[SortableState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: SortableState; config: SortableStateConfigs[SortableState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.sortableApi.store(el: HTMLElement): Store<{ name: SortableState; config: SortableStateConfigs[SortableState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: SortableState; config: SortableStateConfigs[SortableState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.sortableApi.commit<S extends SortableState>(el: HTMLElement, name: S, config?: SortableStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?SortableStateConfigs[S]its config
df$.shadcn.sortableStates: SortableState[]The declared states, 'default' first: default.

Events

EventDescription
sortable-change
Fires after a move (drag or keyboard) - the item, its new index, and the positions it moved from and to.

detail: SortableChangeDetail

FieldTypeDescription
itemHTMLElementthe item that moved
indexnumberits index in this list now; -1 on the list it left
from?HTMLElementa move between lists, on the list it joined: the list it came from
to?HTMLElement | nulla move between lists, on the list it left: the list it went to

Types

TypeDescription
SortableChangeDetail
What sortable-change carries - one event per list a move touches.
FieldTypeDescription
itemHTMLElementthe item that moved
indexnumberits index in this list now; -1 on the list it left
from?HTMLElementa move between lists, on the list it joined: the list it came from
to?HTMLElement | nulla move between lists, on the list it left: the list it went to

§CSS view file

Styles for the sortable component. Includes drag states, drop indicators, keyboard focus, disabled state, horizontal orientation, and accessibility media queries.

@layer components {
  .sortable {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
    /* Horizontal orientation */
    &[data-orientation="horizontal"] {
      flex-direction: row;
      flex-wrap: wrap;
    }
  }
  .sortable-item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.5rem 0.75rem;
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    background-color: var(--card);
    font-size: 0.875rem;
    color: var(--foreground);
    cursor: grab;
    transition: box-shadow 150ms ease, opacity 150ms ease, border-color 150ms ease;
    user-select: none;
    &:active { cursor: grabbing; }
    /* Keyboard focus (roving tabindex) */
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 2px;
    }
    /* Active descendant highlight (keyboard navigation) */
    &[data-active] {
      border-color: var(--ring);
      background-color: var(--accent);
      color: var(--accent-foreground);
    }
    /* Drag in progress */
    &[data-dragging] {
      opacity: 0.5;
      box-shadow: var(--shadow-md);
      border-style: dashed;
    }
    /* Drop target indicator */
    &[data-over="before"] {
      border-top: 2px solid var(--primary);
    }
    &[data-over="after"] {
      border-bottom: 2px solid var(--primary);
    }
    /* Horizontal drop indicators */
    .sortable[data-orientation="horizontal"] &[data-over="before"] {
      border-top: 1px solid var(--border);
      border-inline-start: 2px solid var(--primary);
    }
    .sortable[data-orientation="horizontal"] &[data-over="after"] {
      border-bottom: 1px solid var(--border);
      border-inline-end: 2px solid var(--primary);
    }
    /* Disabled */
    &[aria-disabled="true"] {
      opacity: 0.5;
      cursor: not-allowed;
      pointer-events: none;
    }
  }
  .sortable-handle {
    color: var(--muted-foreground);
    cursor: grab;
    font-size: 1rem;
    line-height: 1;
    flex-shrink: 0;
    display: inline-flex;
    align-items: center;
    &:active { cursor: grabbing; }
    & svg { width: 1rem; height: 1rem; }
  }
  /* -- Move buttons: reorder by tap / click --------------------
     Native drag-and-drop mostly never starts on touch screens, so a list
     people must reorder by tapping carries Up / Down buttons per item.
     They sit at the item's end; Up/Down disable at the list's edges. */
  .sortable-moves {
    display: inline-flex;
    gap: 0.25rem;
    flex-shrink: 0;
    margin-inline-start: auto;
  }
  .sortable-move {
    display: inline-grid;
    place-items: center;
    box-sizing: border-box;
    width: 2rem;
    height: 2rem;
    padding: 0;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--background);
    color: var(--foreground);
    cursor: pointer;
    transition: background-color 150ms, color 150ms;
    & svg { width: 1rem; height: 1rem; }
    &:hover { background: var(--accent); color: var(--accent-foreground); }
    &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
    /* legible but inert, like every disabled control (no opacity fade) */
    &:disabled {
      background: var(--muted);
      color: var(--muted-foreground);
      cursor: not-allowed;
    }
    /* a finger needs a bigger target than a pointer (WCAG 2.5.5: 44px) */
    @media (pointer: coarse) {
      width: 2.75rem;
      height: 2.75rem;
    }
  }
  /* -- Connected lists (data-group) ------------------------------
     A list that items can be dragged INTO needs a body even when empty:
     an empty grouped list is a dashed drop zone (its data-empty text, if
     any, says what goes there), and a drag over the list's own box - gap,
     padding, empty zone - marks it as "drop at the end". */
  .sortable[data-group] {
    min-height: 2.75rem;
    &:not(:has(.sortable-item)) {
      align-items: center;
      justify-content: center;
      border: 1px dashed var(--border);
      border-radius: var(--radius-md);
      color: var(--muted-foreground);
      font-size: 0.875rem;
      &::before { content: attr(data-empty); }
    }
  }
  .sortable[data-over="end"] {
    outline: 2px dashed var(--primary);
    outline-offset: 2px;
    border-radius: var(--radius-md);
  }
  /* Live region for screen reader announcements */
  .sortable-live {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }
  @media (prefers-reduced-motion: reduce) {
    .sortable-item, .sortable-move { transition: none; }
  }
  @media (prefers-contrast: more) {
    .sortable-item {
      border-width: 2px;
      &[data-active] { outline: 2px solid LinkText; }
      &[data-over="before"] { border-top-width: 3px; }
      &[data-over="after"] { border-bottom-width: 3px; }
    }
  }
  @media (forced-colors: active) {
    .sortable-item {
      border-color: ButtonBorder;
      color: ButtonText;
      background-color: ButtonFace;
      &[data-active] { border-color: Highlight; color: HighlightText; }
      &[data-dragging] { border-color: GrayText; }
      &[data-over="before"] { border-top-color: Highlight; }
      &[data-over="after"] { border-bottom-color: Highlight; }
      &[aria-disabled="true"] { color: GrayText; border-color: GrayText; }
    }
    .sortable-handle { color: GrayText; }
    .sortable-move {
      border-color: ButtonText;
      color: ButtonText;
      &:disabled { border-color: GrayText; color: GrayText; }
    }
    .sortable[data-over="end"] { outline-color: Highlight; }
  }
  /* -- Density ----------------------------------------------------
     data-density on the .sortable root scales the list gap and the item
     padding. comfortable == the unsized default. */
  .sortable:where([data-density="compact"]) {
    gap: 0.125rem;
    & .sortable-item { padding: 0.375rem 0.5rem; }
  }
  .sortable:where([data-density="comfortable"]) {
    gap: 0.25rem;
    & .sortable-item { padding: 0.5rem 0.75rem; }
  }
  .sortable:where([data-density="spacious"]) {
    gap: 0.375rem;
    & .sortable-item { padding: 0.625rem 1rem; }
  }
}

§JavaScript view file

Drag-and-drop + keyboard reordering. Arrow keys navigate, Alt+Arrow reorders, live region announces moves to screen readers.

// -- Sortable -------------------------------------------------
// Drag-and-drop + keyboard reordering for sortable lists.
// Keyboard: Arrow keys navigate, Alt+Arrow reorders, Home/End jump.
// Move buttons (.sortable-move[data-move="up|down"]) reorder by tap/click -
// the path for touch screens, where native drag-and-drop mostly never starts.
// Connected lists (same data-group) exchange items: drag across, or
// Alt + the cross-axis arrow moves the focused item to the neighbouring list.
// Live region announces position changes to screen readers.
// Named-state API (AGENTS.md "State API"): the list's observable state is
// its item order + active item, so 'default' restores the authored order
// (optional { index } activates one item) and getState() reports both live.
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
// defussQuery: the callable runtime - order restoration moves existing nodes
// through query .append(), drops/reorders through query .before()/.after(),
// and drag/active flags ride query scalar writes (§3 sortable row: native
// moves already keep identity; the shared adapter is the win, no morph).
import { defussGlobals, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type
// this file's API docs state - arguments, return values, event details - holds
// against its code: a wrong one is a new type error and fails the build.
/** What sortable-change carries - one event per list a move touches. */
interface SortableChangeDetail {
  /** the item that moved */
  item: HTMLElement;
  /** its index in this list now; -1 on the list it left */
  index: number;
  /** a move between lists, on the list it joined: the list it came from */
  from?: HTMLElement;
  /** a move between lists, on the list it left: the list it went to */
  to?: HTMLElement | null;
}
const sortableStates = ['default'];
/** setState() configs per state (getState() reports the order and the active item). */
export interface SortableStateConfigs {
  /** The authored order (setting it restores that order). */
  default: {
    /** the item to make active (roving focus), 0-based */
    index?: number;
    /** reported by getState(): the items' labels in the current order */
    order?: string[];
    /** reported by getState(): the active item's index, -1 for none */
    activeIndex?: number;
  };
}
/**
 * The one drag in flight on this document: the picked-up item and the list it
 * left. Transient pointer (set on dragstart, cleared on dragend) - shared at
 * module level because a drop into a CONNECTED list is handled by that list,
 * not the one the drag started in. Never state: the State API stays per list.
 */
let drag: { item: HTMLElement; from: HTMLElement } | null = null;
const sortableLabels = (list) =>
  dfDollar(list)
    .find('.sortable-item')
    .map((item: HTMLElement) => dfDollar(item).find('span:not(.sortable-handle):not(.sortable-moves)').text().trim());
/**
 * The markup of a state, for render(): the attributes a state writes, applied
 * to a detached copy of the authored markup ('default' IS the authored
 * markup). The live element gets the same markup from triggerStateChange -
 * the e2e render round trip proves they agree.
 */
function applyMarkup(_el, _stateName) {
  // one state: the authored order - which the authored copy already has;
  // { index } only moves the roving focus stop (runtime-owned, see the e2e)
}
/**
 * UI side of setState: 'default' restores the authored order snapshot (taken
 * at init) and optionally activates the item at config.index.
 */
function triggerStateChange(list, stateName, config) {
  if (stateName !== 'default') return;
  // one query move of the existing nodes in snapshot order (§3: .append on
  // one parent - nodes are MOVED, never re-created, handlers survive)
  dfDollar(list).append(list._defaultOrder ?? []);
  list._syncMoves?.();
  if (config?.index !== undefined) {
    const item = dfDollar(list).find('.sortable-item')[Number(config.index)];
    list._setActive?.(item);
  }
}
/** Registry-level API; pass the list element explicitly. Unknown names throw. */
export const sortableApi = componentState({
  component: 'sortable',
  states: sortableStates,
  apply: (list, state) => triggerStateChange(list, state.name, state.config),
  read: (list, state) => {
    const items = Array.from(dfDollar(list).find('.sortable-item'));
    const active = dfDollar(list).find('.sortable-item[data-active]')[0];
    return {
      name: list.dataset.stateName || 'default',
      config: {
        ...state.config,
        order: sortableLabels(list),
        activeIndex: active ? items.indexOf(active) : -1,
      },
    };
  },
  markup: (el, state) => applyMarkup(el, state.name),
});
df$.sortableApi = sortableApi;
df$.sortableStates = sortableStates;
function init() {
dfDollar('.sortable:not([data-init])').toArray().forEach((list) => {
  list.dataset.init = '';
  // el.store + el.api (AGENTS.md "State through stores")
  bindComponent(list, sortableApi);
  const isHorizontal = list.dataset.orientation === 'horizontal';
  const NEXT_KEY = isHorizontal ? 'ArrowRight' : 'ArrowDown';
  const PREV_KEY = isHorizontal ? 'ArrowLeft' : 'ArrowUp';
  // connected lists sit side by side (vertical) or stacked (horizontal):
  // the cross-axis arrows step between them
  const NEXT_LIST_KEY = isHorizontal ? 'ArrowDown' : 'ArrowRight';
  const PREV_LIST_KEY = isHorizontal ? 'ArrowUp' : 'ArrowLeft';
  // -- Live region for announcements --
  // Look only at the node directly after the list (where init inserts it): a
  // parent-wide query would hand sibling lists the same region, so one list
  // would announce through a region anchored to the other list.
  let liveRegion = list.nextElementSibling;
  if (!liveRegion || !liveRegion.classList.contains('sortable-live')) {
    liveRegion = document.createElement('span');
    liveRegion.className = 'sortable-live';
    liveRegion.setAttribute('aria-live', 'assertive');
    liveRegion.setAttribute('role', 'status');
    // mount right after the list (§5.1: query's exact .after(), one branch)
    dfDollar(list).after(liveRegion);
  }
  function announce(msg) {
    // double write re-triggers the live region for repeated identical messages
    dfDollar(liveRegion).text('');
    requestAnimationFrame(() => { dfDollar(liveRegion).text(msg); });
  }
  function getItems() {
    return Array.from(dfDollar(list).find('.sortable-item:not([aria-disabled="true"])'));
  }
  function getAllItems() {
    return Array.from(dfDollar(list).find('.sortable-item'));
  }
  const isLocked = (el) => el.getAttribute('aria-disabled') === 'true';
  const listName = () => list.getAttribute('aria-label') || 'the list';
  /**
   * Why: a locked item (aria-disabled="true") is a fixed SLOT, not just an
   * item you can't pick up - it keeps its position while the others move
   * around it, so a locked row in the middle stays a stable divider (the
   * groups above and below keep their size). Moves are therefore expressed
   * as "take this item to slot t": if t is locked, the item continues to the
   * next free slot in its direction of travel (or, at the list's edge, the
   * nearest free slot back); the movable items then refill the free slots in
   * order around the untouched locked ones. One query .append() of the final
   * order moves the existing nodes (identity + handlers survive).
   * Returns the slot the item landed in, or -1 for a no-op.
   */
  function place(item, target) {
    const all = getAllItems();
    const from = all.indexOf(item);
    const n = all.length;
    const fixed = all.map(isLocked);
    const t = Math.max(0, Math.min(n - 1, target));
    const dir = t < from ? -1 : 1;
    let slot = t;
    while (slot >= 0 && slot < n && fixed[slot]) slot += dir;
    if (slot < 0 || slot >= n) {
      slot = t;
      while (slot >= 0 && slot < n && fixed[slot]) slot -= dir;
    }
    if (slot < 0 || slot >= n || slot === from) return -1;
    const movable = all.filter((el) => !isLocked(el) && el !== item);
    // the item becomes the k-th free slot; the rest fill the others in order
    const k = fixed.slice(0, slot).filter((f) => !f).length;
    movable.splice(k, 0, item);
    let m = 0;
    dfDollar(list).append(all.map((el, i) => (fixed[i] ? el : movable[m++])));
    return slot;
  }
  /**
   * Move buttons: Up is disabled where the item has no free slot before it,
   * Down where it has none after it (locked slots don't count - place() steps
   * over them). Unlabelled buttons get "Move {item} up/down".
   */
  function syncMoves() {
    const all = getAllItems();
    const free = all.map((el) => !isLocked(el));
    all.forEach((item, i) => {
      const label = getItemLabel(item);
      dfDollar(item).find('.sortable-move').each(function (this: HTMLButtonElement) {
        const up = this.dataset.move === 'up';
        const room = up ? free.slice(0, i).some(Boolean) : free.slice(i + 1).some(Boolean);
        dfDollar(this).prop('disabled', isLocked(item) || !room);
        if (!this.hasAttribute('aria-label') || this.dataset.autoLabel !== undefined) {
          dfDollar(this).attr('aria-label', `Move ${label} ${up ? 'up' : 'down'}`).data('autoLabel', '');
        }
      });
    });
  }
  list._syncMoves = syncMoves;
  /** announce + activate + sortable-change after a move (index = slot in the full list). */
  function moved(item, slot, focus = true) {
    const n = getAllItems().length;
    announce(`${getItemLabel(item)}, moved to position ${slot + 1} of ${n}`);
    setActive(item, focus);
    syncMoves();
    // Fires after a move (drag or keyboard) - the item, its new index, and the positions it moved from and to.
    list.dispatchEvent(new CustomEvent<SortableChangeDetail>('sortable-change', {
      bubbles: true,
      detail: { item, index: slot }
    }));
  }
  /**
   * Take an item from a connected list and insert it at `index` (clamped;
   * past the end = append). The receiving list owns the follow-up: roving
   * tabindex, move buttons, announcement and both lists' sortable-change
   * (`detail.from` here, `detail.to` on the list it left).
   */
  function receive(item, index, from, focus = true) {
    const all = getAllItems();
    const before = all[Math.max(0, index)];
    if (before) dfDollar(before).before(item);
    else dfDollar(list).append(item);
    const slot = getAllItems().indexOf(item);
    announce(`${getItemLabel(item)}, moved to ${listName()}, position ${slot + 1} of ${getAllItems().length}`);
    setActive(item, focus);
    syncMoves();
    from._released?.(item);
    list.dispatchEvent(new CustomEvent<SortableChangeDetail>('sortable-change', {
      bubbles: true,
      detail: { item, index: slot, from }
    }));
  }
  list._receive = receive;
  /** The list an item just left: repair roving tabindex + buttons, report it. */
  list._released = (item) => {
    const items = getItems();
    if (items.length && !items.some((el) => el.getAttribute('tabindex') === '0')) {
      dfDollar(items[0]).attr('tabindex', '0');
    }
    if (!items.length) list.removeAttribute('data-active-index');
    syncMoves();
    list.dispatchEvent(new CustomEvent<SortableChangeDetail>('sortable-change', {
      bubbles: true,
      detail: { item, index: -1, to: item.closest('.sortable') }
    }));
  };
  /** Lists this one exchanges items with (same data-group), document order. */
  const groupLists = () => {
    const group = list.dataset.group;
    return group
      ? Array.from(dfDollar('.sortable[data-group]').toArray()).filter((l) => (l as HTMLElement).dataset.group === group)
      : [list];
  };
  function getActiveItem() {
    return dfDollar(list).find('.sortable-item[data-active]')[0];
  }
  function setActive(item, focus = true) {
    // active flag + roving tabindex through query scalars
    getAllItems().forEach((el) => { dfDollar(el).data('active', null).attr('tabindex', '-1'); });
    if (item) {
      dfDollar(item).data('active', '').attr('tabindex', '0');
      // mirror the active position onto the LIST root - the schema's
      // activeIndex observation reads one stable attribute instead of
      // scanning children (and survives item reorder/moves)
      list.dataset.activeIndex = String(getItems().indexOf(item));
      if (focus) item.focus(); // native focus protocol stays native
    } else {
      list.removeAttribute('data-active-index');
    }
  }
  // expose for the State API (element member, not module scope)
  list._setActive = setActive;
  // authored-order snapshot for setState('default')
  list._defaultOrder = getAllItems();
  function getItemLabel(item) {
    const clone = item.cloneNode(true);
    dfDollar(clone).find('.sortable-handle, .sortable-moves, .sortable-move').toArray().forEach((el) => el.remove());
    return clone.textContent.trim();
  }
  // -- Initialize tabindex + move buttons --
  const allItems = getAllItems();
  allItems.forEach((item, i) => {
    dfDollar(item).attr('tabindex', i === 0 ? '0' : '-1');
  });
  syncMoves();
  // -- Drag and drop (delegated on the list, so an item that arrives from a
  //    connected list is draggable here without re-binding) --
  const accepts = () =>
    !!drag && (drag.from === list || (!!list.dataset.group && list.dataset.group === drag.from.dataset.group));
  const clearOver = () => {
    dfDollar(list).find('[data-over]').data('over', null);
    dfDollar(list).data('over', null);
  };
  list.addEventListener('dragstart', (e) => {
    const item = (e.target as HTMLElement).closest?.('.sortable-item') as HTMLElement | null;
    if (!item || !list.contains(item) || isLocked(item)) return;
    drag = { item, from: list };
    dfDollar(item).data('dragging', '');
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.setData('text/plain', '');
  });
  list.addEventListener('dragend', () => {
    if (drag) dfDollar(drag.item).data('dragging', null);
    groupLists().forEach((l) => {
      dfDollar(l).data('over', null);
      dfDollar(l).find('[data-over]').data('over', null);
    });
    drag = null;
  });
  list.addEventListener('dragover', (e) => {
    if (!accepts()) return;
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';
    const item = (e.target as HTMLElement).closest?.('.sortable-item') as HTMLElement | null;
    clearOver();
    if (item && list.contains(item)) {
      if (item === drag!.item) return;
      const rect = item.getBoundingClientRect();
      const midpoint = isHorizontal ? rect.left + rect.width / 2 : rect.top + rect.height / 2;
      const pos = isHorizontal ? e.clientX : e.clientY;
      dfDollar(item).data('over', pos < midpoint ? 'before' : 'after');
    } else {
      // over the list's own box (gap, padding, an empty list): drop at the end
      dfDollar(list).data('over', 'end');
    }
  });
  list.addEventListener('dragleave', (e) => {
    if (!list.contains(e.relatedTarget as Node)) clearOver();
  });
  list.addEventListener('drop', (e) => {
    if (!accepts()) return;
    e.preventDefault();
    const target = dfDollar(list).find('.sortable-item[data-over]')[0] as HTMLElement | undefined;
    const position = target ? dfDollar(target).data('over') : 'end';
    clearOver();
    const { item: dragged, from } = drag!;
    const all = getAllItems();
    if (from !== list) {
      // from a connected list: insert at the drop point
      const index = target ? all.indexOf(target) + (position === 'before' ? 0 : 1) : all.length;
      receive(dragged, index, from);
      return;
    }
    if (target === dragged) return;
    // the drop point as a slot in the full list (the dragged item's own
    // slot frees up first when it moves down), then a slot-preserving move
    const fromIndex = all.indexOf(dragged);
    let slotTarget = target ? all.indexOf(target) + (position === 'before' ? 0 : 1) : all.length;
    if (fromIndex < slotTarget) slotTarget -= 1;
    const slot = place(dragged, slotTarget);
    if (slot >= 0) moved(dragged, slot);
  });
  // -- Move buttons (tap / click reordering) --
  list.addEventListener('click', (e) => {
    const button = (e.target as HTMLElement).closest?.('.sortable-move') as HTMLButtonElement | null;
    if (!button || button.disabled || !list.contains(button)) return;
    const item = button.closest('.sortable-item') as HTMLElement;
    const from = getAllItems().indexOf(item);
    const slot = place(item, from + (button.dataset.move === 'up' ? -1 : 1));
    if (slot < 0) return;
    // keep focus on the pressed button so repeated taps keep moving the item;
    // at the list's edge the button disables itself - hand focus to its twin
    moved(item, slot, false);
    const target = button.disabled
      ? (dfDollar(item).find(`.sortable-move[data-move="${button.dataset.move === 'up' ? 'down' : 'up'}"]`).get(0) as HTMLElement | null)
      : button;
    target?.focus();
  });
  // -- Keyboard navigation --
  list.addEventListener('keydown', (e) => {
    // keys pressed on a move button belong to the button (Enter / Space)
    if ((e.target as HTMLElement).closest?.('.sortable-move')) return;
    const active = getActiveItem() || dfDollar(list).find('.sortable-item[tabindex="0"]')[0];
    if (!active) return;
    const items = getItems();
    const idx = items.indexOf(active);
    // Arrow navigation
    if (e.key === NEXT_KEY && !e.altKey) {
      e.preventDefault();
      const next = items[idx + 1];
      if (next) setActive(next);
    } else if (e.key === PREV_KEY && !e.altKey) {
      e.preventDefault();
      const prev = items[idx - 1];
      if (prev) setActive(prev);
    } else if (e.key === 'Home') {
      e.preventDefault();
      if (items.length) setActive(items[0]);
    } else if (e.key === 'End') {
      e.preventDefault();
      if (items.length) setActive(items[items.length - 1]);
    // Alt+Arrow reorders by one slot - a locked slot is stepped around, never
    // displaced (see place())
    } else if ((e.key === NEXT_KEY || e.key === PREV_KEY) && e.altKey) {
      e.preventDefault();
      const from = getAllItems().indexOf(active);
      const slot = place(active, from + (e.key === NEXT_KEY ? 1 : -1));
      if (slot >= 0) moved(active, slot);
    // Alt + cross-axis arrow: move the item to the neighbouring connected list,
    // at the same position (clamped to its length)
    } else if ((e.key === NEXT_LIST_KEY || e.key === PREV_LIST_KEY) && e.altKey && list.dataset.group) {
      e.preventDefault();
      const lists = groupLists();
      const other = lists[lists.indexOf(list) + (e.key === NEXT_LIST_KEY ? 1 : -1)] as any;
      if (!other?._receive) return;
      other._receive(active, getAllItems().indexOf(active), list);
    }
  });
  // -- Focus management --
  list.addEventListener('focusin', (e) => {
    const target = e.target as HTMLElement;
    const item = target.closest('.sortable-item');
    if (!item || !list.contains(item)) return;
    // focus on a control INSIDE the item (a move button) marks the item
    // active but must not pull focus off that control
    setActive(item, target === item);
  });
});
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub