Skip to content
Bladewell

<x-widget.kanban>

Kanban Blade component for Laravel and Livewire

A board of columns with cards people move between them: an order pipeline, support tickets, tasks. Each card is your own markup. Cards move by a Move menu on each card, by picking one up and placing it with the arrow keys or a tap, or by dragging with a mouse or pen; every move is read out to screen readers and can be undone. A move fires kanban:move, and with move-url is sent to your app, which keeps it or sends it back with a reason. Readonly boards and columns, locked cards, and a button per column when they don't fit side by side, as on a phone. Works inside Livewire components, with wire:kanban-move.

php artisan bladewell:add kanban
Also adds
Button, Dropdown

Usage

Move route

The URL in move-url answers POST with the move as JSON: card, from, to, position (counted from 0 in the new column) and after (the card it now follows, or null at the top). Any 2xx keeps the move. Anything else puts the card back, with your answer's message: a failed validation's first error, or abort(403, '…'). Check the person may move this order, as for any change.

PHP
Route::post('/orders/move', function (Request $request) {
    $move = $request->validate([
        'card' => ['required', 'integer', 'exists:orders,id'],
        'to' => ['required', Rule::in(['new', 'packed', 'shipped'])],
        'position' => ['required', 'integer', 'min:0'],
    ], [
        'to.in' => 'Orders reach Delivered when the courier confirms them.',
    ]);
    $order = Order::findOrFail($move['card']);
    Gate::authorize('update', $order);

    DB::transaction(function () use ($order, $move) {
        // Close the gap it leaves, then make room where it lands.
        Order::where('stage', $order->stage)->where('sort', '>', $order->sort)->decrement('sort');
        Order::where('stage', $move['to'])->where('sort', '>=', $move['position'])->increment('sort');
        $order->update(['stage' => $move['to'], 'sort' => $move['position']]);
    });

    return response()->noContent();
})->middleware('throttle:60,1')->name('orders.move');

Livewire

In a Livewire component, leave out move-url and listen for each move with wire:kanban-move: public function move(string $card, string $to, int $position) saves it (ids arrive as strings), and the render that follows draws the board as it now is. To refuse a move, don't make it: that render puts the card back, and the board says so. Each card brings its own wire:key. A card being picked up or dragged stays in hand through a render, and Undo works across one.

Blade
<div>
    <x-widget.kanban label="Orders" wire:kanban-move="move($event.detail.card, $event.detail.to, $event.detail.position)">
        @foreach ($stages as $stage)
            <x-widget.kanban.column :id="$stage->value" :title="$stage->label()" :readonly="$stage === Stage::Delivered">
                @foreach ($orders->where('stage', $stage)->sortBy('sort') as $order)
                    <x-widget.kanban.card :id="$order->id" :label="'Order #'.$order->number">
                        <p class="font-medium">Order #{{ $order->number }}</p>
                        <p class="text-muted mt-1">{{ $order->customer_name }}</p>
                    </x-widget.kanban.card>
                @endforeach
            </x-widget.kanban.column>
        @endforeach
    </x-widget.kanban>
</div>

In scripts

window.kanban is available once the kanban script has loaded. Moves a card, by its id or element, to a column by its id, at a place counted from 0 (left out, the end). It's announced, fires kanban:move and is sent to move-url, like a move made by hand. False when it can't move there.

JavaScript
kanban.move('1042', 'shipped');
kanban.move('1042', 'packed', 0);

// Every move, by hand or by script, fires kanban:move on the board (and kanban-move, for wire:kanban-move). after is
// the id of the card it now follows, or null at the top; ids arrive as strings.
document.addEventListener('kanban:move', (event) => {
    const { card, from, to, position, after } = event.detail;
    console.log(`${card}: ${from} → ${to}, place ${position}, after ${after ?? 'none'}`);
});

Examples

Order pipeline

Orders by stage. Move one with its menu: the board shows it at once and sends it to your route, which keeps it or sends it back with a reason. Delivered is readonly: only the courier's confirmation puts an order there.

Pass $stages and $orders from your controller.

New

  1. Order #1042

    Amara Okafor $84.00

  2. Order #1043

    Lukas Weber $129.50

  3. Order #1044

    Priya Nair $46.20

No orders

Packed

  1. Order #1039

    Sofia Rossi $212.00

No orders

Shipped

  1. Order #1036

    Kenji Watanabe $58.75

No orders

Delivered

  1. Order #1031

    Chloé Martin $99.00

No orders

Show code
Blade
<x-widget.kanban label="Orders" :move-url="route('orders.move')">
    @foreach ($stages as $stage => $title)
        <x-widget.kanban.column :id="$stage" :title="$title" empty="No orders" :readonly="$stage === 'delivered'">
            @foreach ($orders[$stage] ?? [] as $order)
                <x-widget.kanban.card :id="$order->id" :label="'Order #'.$order->id">
                    <p class="font-medium">Order #{{ $order->id }}</p>
                    <p class="text-muted mt-1 flex justify-between gap-2">
                        <span class="truncate">{{ $order->customer }}</span>
                        <span class="tabular-nums">{{ $order->total }}</span>
                    </p>
                </x-widget.kanban.card>
            @endforeach
        </x-widget.kanban.column>
    @endforeach
</x-widget.kanban>

Tasks

Without move-url, a move only fires kanban:move, for your own script or a Livewire component to save. A locked card can't be moved, by the menu, the keys or a drag: here, a release that's already scheduled.

To do

  1. Write the release notes
  2. Check the date pickers in Safari

Nothing here

Doing

  1. Tag version 2.0

    Scheduled for Friday

Nothing here

Done

    Nothing finished yet

    Show code
    Blade
    <x-widget.kanban label="Sprint tasks">
        <x-widget.kanban.column id="todo" title="To do">
            <x-widget.kanban.card id="T-18" label="Write the release notes">Write the release notes</x-widget.kanban.card>
            <x-widget.kanban.card id="T-21" label="Check the date pickers in Safari">Check the date pickers in Safari</x-widget.kanban.card>
        </x-widget.kanban.column>
        <x-widget.kanban.column id="doing" title="Doing">
            <x-widget.kanban.card id="T-15" label="Tag version 2.0" locked>
                <p>Tag version 2.0</p>
                <p class="text-muted mt-1">Scheduled for Friday</p>
            </x-widget.kanban.card>
        </x-widget.kanban.column>
        <x-widget.kanban.column id="done" title="Done" empty="Nothing finished yet" />
    </x-widget.kanban>
    

    Readonly

    readonly shows the board as it is, for people who may look but not move anything: no move buttons, nothing to drag.

    Applied

    1. Noor Haddad · Designer
    2. Tomás Silva · Engineer

    Nothing here

    Interview

    1. Mei Chen · Engineer

    Nothing here

    Offer

      No offers out

      Show code
      Blade
      <x-widget.kanban label="Hiring pipeline" readonly>
          <x-widget.kanban.column id="applied" title="Applied">
              <x-widget.kanban.card id="c-31" label="Noor Haddad">Noor Haddad · Designer</x-widget.kanban.card>
              <x-widget.kanban.card id="c-34" label="Tomás Silva">Tomás Silva · Engineer</x-widget.kanban.card>
          </x-widget.kanban.column>
          <x-widget.kanban.column id="interview" title="Interview">
              <x-widget.kanban.card id="c-27" label="Mei Chen">Mei Chen · Engineer</x-widget.kanban.card>
          </x-widget.kanban.column>
          <x-widget.kanban.column id="offer" title="Offer" empty="No offers out" />
      </x-widget.kanban>
      

      Props

      Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.

      <x-widget.kanban>

      Prop Default Description
      label null Required: names the board for screen readers ("Orders") and in what's announced as cards move.
      id null The board's id; scripts can reach it by it, so it must be unique. Made up when left out.
      move-url null A route each move is sent to as JSON (POST: card, from, to, position, after), with the CSRF token. An answer that isn't 2xx puts the card back and shows the answer's message. Without it, the move only fires kanban:move.
      readonly false Shown, but nothing on it moves.

      <x-widget.kanban.card>

      Prop Default Description
      id null Required: what a move sends as card, such as the order's id. Unique within the board.
      label null Required: the card's name in its move button ("Move Order #1042") and in what's announced.
      locked false Can't be moved, by anyone, however it's asked.

      <x-widget.kanban.column>

      Prop Default Description
      id null Required: what a move sends as from and to, such as the stage's key or id. Unique within the board.
      title null Required: the column's heading, also used in the "Move to …" menu and in what's announced.
      empty 'Nothing here' Shown while the column has no cards.
      readonly false Shown, but no card moves into or out of it: a stage only your app sets, such as Delivered.

      Accessibility

      All 3 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.

      Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.

      Source

      What bladewell:add kanban writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from the components it also adds , and the theme and base CSS.

      resources/views/components/widget/kanban/card.blade.php Show
      card.blade.php
      @props([
          // Required: what a move sends as card, such as the order's id. Unique within the board.
          'id' => null,
          // Required: the card's name in its move button ("Move Order #1042") and in what's announced.
          'label' => null,
          // Can't be moved, by anyone, however it's asked.
          'locked' => false,
      ])
      
      @php
          if (! is_scalar($id) || trim((string) $id) === '') {
              throw new \InvalidArgumentException('<x-widget.kanban.card> needs an id, such as the order\'s id: a move sends it as card.');
          }
          if (! is_string($label) || trim($label) === '') {
              throw new \InvalidArgumentException("<x-widget.kanban.card id=\"{$id}\"> needs a label, e.g. label=\"Order #1042\": it names the card's move button and what's announced.");
          }
      @endphp
      
      {{-- wire:key: a Livewire render matches cards by id, so a moved card isn't swapped for its neighbour. --}}
      <li
          data-kanban-card="{{ $id }}"
          data-label="{{ $label }}"
          @if ($locked) data-locked @endif
          wire:key="kanban-card-{{ $id }}"
          {{ $attributes->class([
              'group/card border-line bg-surface relative rounded-xl border p-3 text-sm shadow-xs transition-opacity aria-busy:opacity-60 motion-reduce:transition-none',
              // Picked up to be placed with the keys or a tap.
              'data-kanban-picked:ring-primary data-kanban-picked:shadow-lg data-kanban-picked:ring-2',
              // Dragged: a copy follows the pointer, and this waits, faded, where letting go would put it.
              'data-kanban-dragging:border-primary data-kanban-dragging:border-dashed data-kanban-dragging:opacity-40',
              // Let go: hidden for the moment the copy takes to glide into its place.
              'data-kanban-landing:opacity-0',
              // A mouse can drag it once the script is ready, except from a readonly column.
              'group-data-[kanban-ready]/kanban:cursor-grab group-data-[readonly]/column:cursor-auto' => ! $locked,
          ]) }}
      >
          <div class="min-w-0 pe-9">{{ $slot }}</div>
          @unless ($locked)
              {{-- Hidden until the script is ready, and in a readonly column: there it couldn't move the card. The menu is
                   empty here; the script fills it as it opens, from the columns as they are then. --}}
              <div class="absolute end-1.5 top-1.5 hidden group-data-[kanban-ready]/kanban:block group-data-[readonly]/column:hidden">
                  <x-widget.dropdown :label="'Move '.$label" icon="grip-vertical" size="sm" align="end" data-kanban-move />
              </div>
          @endunless
      </li><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
      
      resources/views/components/widget/kanban/column.blade.php Show
      column.blade.php
      @props([
          // Required: what a move sends as from and to, such as the stage's key or id. Unique within the board.
          'id' => null,
          // Required: the column's heading, also used in the "Move to …" menu and in what's announced.
          'title' => null,
          // Shown while the column has no cards.
          'empty' => 'Nothing here',
          // Shown, but no card moves into or out of it: a stage only your app sets, such as Delivered.
          'readonly' => false,
      ])
      
      @php
          if (! is_scalar($id) || trim((string) $id) === '') {
              throw new \InvalidArgumentException('<x-widget.kanban.column> needs an id, such as the stage\'s key or id: a move sends it as from and to.');
          }
          if (! is_string($title) || trim($title) === '') {
              throw new \InvalidArgumentException("<x-widget.kanban.column id=\"{$id}\"> needs a title: it heads the column and names it in the move menu.");
          }
          // A derived id only: the column's own id is your data and may hold anything; it goes in data-kanban-column instead.
          $domId = app(\App\View\Widget\ElementIds::class)->claim('kanban-column');
          // Counted from the cards drawn, so the number is there before the script runs; the script keeps it in step.
          $count = substr_count((string) $slot, 'data-kanban-card=');
      @endphp
      
      <section
          data-kanban-column="{{ $id }}"
          data-title="{{ $title }}"
          aria-labelledby="{{ $domId }}-title"
          @if ($readonly) data-readonly @endif
          {{ $attributes->class(['group/column bg-field flex w-72 max-w-[85vw] shrink-0 snap-start flex-col rounded-2xl p-3']) }}
      >
          <header class="flex items-center justify-between gap-2 px-1 pb-3">
              <h3 id="{{ $domId }}-title" class="truncate text-sm font-semibold">{{ $title }}</h3>
              {{-- The list says how many cards it holds; this is for the eye. --}}
              <span data-kanban-count aria-hidden="true" class="text-muted shrink-0 text-xs tabular-nums">{{ $count }}</span>
          </header>
          <ol data-kanban-cards aria-labelledby="{{ $domId }}-title" class="flex min-h-12 flex-col gap-2">{{ $slot }}</ol>
          <p data-kanban-empty class="text-muted px-1 py-3 text-center text-sm group-has-[[data-kanban-card]]/column:hidden">{{ $empty }}</p>
      </section><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
      
      resources/views/components/widget/kanban/index.blade.php Show
      index.blade.php
      @props([
          // Required: names the board for screen readers ("Orders") and in what's announced as cards move.
          'label' => null,
          // The board's id; scripts can reach it by it, so it must be unique. Made up when left out.
          'id' => null,
          // A route each move is sent to as JSON (POST: card, from, to, position, after), with the CSRF token. An answer
          // that isn't 2xx puts the card back and shows the answer's message. Without it, the move only fires kanban:move.
          'moveUrl' => null,
          // Shown, but nothing on it moves.
          'readonly' => false,
      ])
      
      @php
          if (! is_string($label) || trim($label) === '') {
              throw new \InvalidArgumentException('<x-widget.kanban> needs a label, e.g. label="Orders": it names the board for screen readers and in what\'s announced as cards move.');
          }
          $id = app(\App\View\Widget\ElementIds::class)->claim($id ?? 'kanban', explicit: $id !== null);
      @endphp
      
      {{--
          Columns of cards that people move: a "Move…" menu on each card lists the other columns and the moves within its
          own. resources/js/widget/kanban builds that menu as it opens, moves the card, announces it and sends it to
          move-url. The script marks the board ready (data-kanban-ready), which shows the move buttons: without it a card
          can't be moved, so they stay hidden. wire:ignore.self keeps that mark through a Livewire render; the columns and
          cards inside still update.
      --}}
      <section
          id="{{ $id }}"
          aria-label="{{ $label }}"
          data-kanban
          @if ($moveUrl) data-move-url="{{ $moveUrl }}" data-csrf="{{ csrf_token() }}" @endif
          @if ($readonly) data-readonly @endif
          wire:ignore.self
          {{ $attributes->class(['group/kanban flex min-w-0 flex-col gap-3']) }}
      >
          {{-- The script's: what it said, and the buttons it shows. wire:ignore keeps a render from emptying the one and hiding
               the others, such as the Undo for the move that render answered. --}}
          <div wire:ignore class="flex min-h-0 flex-wrap items-center gap-x-3 gap-y-2">
              {{-- What a move did, or why it didn't. In the accessibility tree while empty, so the first message is read out. --}}
              <p data-kanban-status role="status" class="text-foreground min-w-0 flex-1 text-sm empty:sr-only"></p>
              {{-- While a card is picked up: puts it back. --}}
              <x-widget.button size="sm" variant="secondary" data-kanban-cancel hidden>Cancel</x-widget.button>
              {{-- After a move: makes the opposite one. --}}
              <x-widget.button size="sm" variant="secondary" data-kanban-undo hidden>Undo</x-widget.button>
          </div>
      
          {{-- When the columns don't all fit, a button for each, to scroll it into view: on a phone, one column at a time.
               The script fills it from the columns; wire:ignore keeps a render from emptying it, and the script fills it again
               when the columns change. --}}
          <div data-kanban-jump wire:ignore hidden class="-mx-1 flex gap-1.5 overflow-x-auto px-1 pb-1"></div>
      
          {{-- Each column is as tall as its cards (items-start); a drag counts the space below a short one as its lane.
               Snapping to a column is turned off while a card is dragged: it would pull back against the scrolling at the
               edges. While the columns scroll, the script makes the row focusable, so the keyboard can scroll it too;
               wire:ignore.self keeps that through a render. --}}
          <div data-kanban-columns wire:ignore.self class="group-data-[kanban-moving]/kanban:snap-none focus-visible:outline-primary flex items-start snap-x snap-mandatory gap-4 overflow-x-auto overscroll-x-contain pb-2 outline-none focus-visible:outline-2 focus-visible:-outline-offset-2">
              {{ $slot }}
          </div>
      
          {{-- The menu items the script builds the "Move…" menu from, so they look like any dropdown's. --}}
          <template data-kanban-templates>
              <x-widget.dropdown.item data-kanban-item>Move</x-widget.dropdown.item>
              <x-widget.dropdown.divider />
          </template>
      </section><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
      
      resources/js/widget/kanban/index.js Show
      index.js
      // Drives <x-widget.kanban>. Each card's "Move…" button opens a menu, built here as it opens from the board as it is
      // then: the other columns (to the end of one), and up, down, to the top or to the bottom within its own. A move
      // happens at once on the page, is announced, fires kanban:move (and kanban-move, for wire:kanban-move) on the board
      // with detail { card, from, to, position, after }, and with move-url is sent there as JSON. An answer that isn't
      // 2xx, or no answer, puts the card back where it was and says why; the card is busy until then. Readonly boards,
      // readonly columns and locked cards never move.
      // From JS: kanban.move(card, column, position), by element or id; also on window.kanban.
      // Delegated from `document`, so boards added later (a Livewire render, fetched HTML) work without setting up.
      
      const BOARD = '[data-kanban]';
      const COLUMN = '[data-kanban-column]';
      const CARD = '[data-kanban-card]';
      // Long enough for a slow connection, short enough that a card isn't left busy for good.
      const TIMEOUT = 20000;
      
      const boardOf = (element) => element.closest(BOARD);
      // Only this board's own columns and cards: a board inside a card would be another board.
      const columnsOf = (board) => [...board.querySelectorAll(COLUMN)].filter((column) => boardOf(column) === board);
      const listOf = (column) => column.querySelector('[data-kanban-cards]');
      const cardsIn = (column) => [...listOf(column).children].filter((card) => card.matches(CARD));
      const columnOf = (card) => card.closest(COLUMN);
      const idOf = (element) => element.dataset.kanbanCard ?? element.dataset.kanbanColumn;
      const titleOf = (column) => column.dataset.title;
      const isReadonly = (element) => element.hasAttribute('data-readonly');
      const columnById = (board, id) => columnsOf(board).find((column) => idOf(column) === String(id)) ?? null;
      const cardById = (board, id) => columnsOf(board).flatMap(cardsIn).find((card) => idOf(card) === String(id)) ?? null;
      // The card as it is on the page now. A Livewire render whose order differs from the page's doesn't move a card: it
      // takes it out of one column and draws a new one in the other. That one has the same id, so follow it by its id.
      const current = (board, card) => (card.isConnected ? card : cardById(board, idOf(card)));
      // Per board, the ids of the cards whose move is being saved. By id, not element, and not read from aria-busy, which a
      // render takes away.
      const saving = new WeakMap();
      const savingIn = (board) => saving.get(board) ?? saving.set(board, new Set()).get(board);
      const isBusy = (card) => savingIn(boardOf(card)).has(idOf(card));
      const numbers = () => new Intl.NumberFormat(document.documentElement.lang || undefined);
      
      // Whether this card may move now, and if not, why: said out loud, since its button may still have been reached.
      function blocked(card) {
          const board = boardOf(card);
          if (isReadonly(board) || card.hasAttribute('data-locked') || isReadonly(columnOf(card))) {
              return `${card.dataset.label} can't be moved.`;
          }
      
          return isBusy(card) ? `${card.dataset.label} is still being saved.` : null;
      }
      
      function say(board, message) {
          const status = board.querySelector('[data-kanban-status]');
          // Emptied first, so the same message twice is read out again.
          status.textContent = '';
          requestAnimationFrame(() => {
              status.textContent = message;
          });
      }
      
      // The column's count, and its jump button's.
      function count(column) {
          const text = numbers().format(cardsIn(column).length);
          column.querySelector('[data-kanban-count]').textContent = text;
          const jump = [...boardOf(column).querySelectorAll('[data-kanban-jump] button')].find((button) => button.dataset.kanbanJumpTo === idOf(column));
          jump?.querySelector('[data-kanban-jump-count]')?.replaceChildren(text);
      }
      
      // --- Motion ------------------------------------------------------------------------------------------------------
      
      // Cards glide to a new place, lift as they're dragged, and settle into the slot they're let go over; none of it for
      // people who ask for less motion, whose cards go straight there.
      const moving = () => !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      const SLIDE = 160;
      const LIFT = 150;
      const LAND = 180;
      
      // Where an element is laid out, leaving out the translate a slide is moving it by: what a slide measures from, and what
      // a drag works out its place by, so a card part way through a slide doesn't make the place flicker.
      function laidOut(element) {
          const box = element.getBoundingClientRect();
          const [x = 0, y = 0] = getComputedStyle(element).translate.split(' ').map((part) => Number.parseFloat(part) || 0);
      
          return { left: box.left - x, top: box.top - y, width: box.width, height: box.height };
      }
      
      // Runs a change to the board's cards, then glides each card that it moved from where it was on screen to its new
      // place. From where it is on screen, not where it was laid out: a card already gliding carries on from there.
      function slide(board, change) {
          if (!moving()) {
              change();
      
              return;
          }
          const cards = columnsOf(board).flatMap(cardsIn);
          // Where each card is on screen (part way through a glide, perhaps), and where it's laid out.
          const was = new Map(cards.map((card) => [card, { shown: card.getBoundingClientRect(), placed: laidOut(card) }]));
          change();
          for (const [card, { shown, placed }] of was) {
              if (!card.isConnected) {
                  continue;
              }
              const now = laidOut(card);
              // Not moved by this change: a glide it's in carries on untouched. Starting it again on every pointer move
              // would make it judder.
              if (Math.abs(now.left - placed.left) < 1 && Math.abs(now.top - placed.top) < 1) {
                  continue;
              }
              card.getAnimations().filter((animation) => animation.id === 'kanban-slide').forEach((animation) => animation.cancel());
              const [dx, dy] = [shown.left - now.left, shown.top - now.top];
              if (Math.abs(dx) >= 1 || Math.abs(dy) >= 1) {
                  card.animate([{ translate: `${dx}px ${dy}px` }, { translate: '0 0' }], { id: 'kanban-slide', duration: SLIDE, easing: 'ease-out' });
              }
          }
      }
      
      // Puts the card at index in column (counted without the card itself), and keeps focus on it if it had it: taking an
      // element out of the page drops its focus. Scrolled into view, except while dragged: the pointer is already there.
      function place(card, column, index, { scroll = true } = {}) {
          const focused = card.contains(document.activeElement) ? document.activeElement : null;
          const from = columnOf(card);
          const others = cardsIn(column).filter((other) => other !== card);
          slide(boardOf(column), () => listOf(column).insertBefore(card, others[index] ?? null));
          count(column);
          if (from !== column) {
              count(from);
          }
          if (focused) {
              focused.focus({ preventScroll: true });
          }
          if (scroll) {
              card.scrollIntoView({ block: 'nearest', inline: 'nearest' });
          }
      }
      
      // Where a card sits, by its neighbour rather than its index: other cards may move before it's put back.
      function spot(card) {
          const column = columnOf(card);
          const cards = cardsIn(column);
          const index = cards.indexOf(card);
      
          return { column, index, after: index > 0 ? idOf(cards[index - 1]) : null };
      }
      
      function putBack(card, before, options = {}) {
          const board = boardOf(card);
          const column = columnById(board, idOf(before.column)) ?? before.column;
          const cards = cardsIn(column).filter((other) => other !== card);
          const after = before.after === null ? -1 : cards.findIndex((other) => idOf(other) === before.after);
          // The neighbour it followed, if still there; otherwise where it was; the top if it led.
          const index = before.after === null ? 0 : after >= 0 ? after + 1 : Math.min(before.index, cards.length);
          place(card, column, index, options);
      }
      
      async function save(board, moved, detail, before) {
          savingIn(board).add(idOf(moved));
          moved.setAttribute('aria-busy', 'true');
          let problem = null;
          try {
              const response = await fetch(board.dataset.moveUrl, {
                  method: 'POST',
                  headers: {
                      'Content-Type': 'application/json',
                      Accept: 'application/json',
                      'X-Requested-With': 'XMLHttpRequest',
                      'X-CSRF-TOKEN': board.dataset.csrf ?? '',
                  },
                  credentials: 'same-origin',
                  body: JSON.stringify(detail),
                  signal: AbortSignal.timeout(TIMEOUT),
              });
              // A redirect is the sign-in page or somewhere else that isn't an answer to the move.
              if (response.redirected) {
                  problem = 'You may have been signed out. Reload the page and try again.';
              } else if (response.status === 419) {
                  problem = 'This page has expired. Reload it and try again.';
              } else if (!response.ok) {
                  const answer = await response.json().catch(() => null);
                  problem = typeof answer?.message === 'string' && answer.message !== '' ? answer.message : 'It couldn\'t be saved.';
              }
          } catch {
              problem = 'The server couldn\'t be reached.';
          }
          savingIn(board).delete(idOf(moved));
          // Gone from the page meanwhile (a render, a script): nothing to put back.
          const card = current(board, moved);
          if (!card) {
              return;
          }
          card.removeAttribute('aria-busy');
          if (problem) {
              // Already undone, by the server: nothing left to offer.
              if (undoable.get(board) && idOf(undoable.get(board).card) === idOf(card)) {
                  offerUndo(board, null);
              }
              putBack(card, before);
              say(board, `Couldn't move ${card.dataset.label}: ${problem} It's back in ${titleOf(before.column)}.`);
          }
      }
      
      const where = (card) => {
          const column = columnOf(card);
      
          return `${titleOf(column)}, ${numbers().format(cardsIn(column).indexOf(card) + 1)} of ${numbers().format(cardsIn(column).length)}`;
      };
      
      // Per board, the last move, while it can still be undone: { card, before, after }.
      const undoable = new WeakMap();
      
      function offerUndo(board, move) {
          if (move) {
              undoable.set(board, move);
          } else {
              undoable.delete(board);
          }
          board.querySelector('[data-kanban-undo]').hidden = !move;
      }
      
      // The card is where it now sits, having been at before: announced as a move, sent out, and saved, and offered for
      // undoing unless it's an undo itself. False when it ended up where it started, which isn't a move.
      function settle(card, before, { canUndo = true } = {}) {
          const board = boardOf(card);
          const now = spot(card);
          if (idOf(now.column) === idOf(before.column) && now.index === before.index) {
              offerUndo(board, null);
              say(board, `${card.dataset.label} stays in ${titleOf(now.column)}.`);
      
              return false;
          }
          const detail = { card: idOf(card), from: idOf(before.column), to: idOf(now.column), position: now.index, after: now.after };
          for (const name of ['kanban:move', 'kanban-move']) {
              board.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
          }
          offerUndo(board, canUndo ? { card, before, after: now } : null);
          say(board, `Moved ${card.dataset.label} to ${where(card)}.`);
          if (board.dataset.moveUrl) {
              save(board, card, detail, before);
          }
      
          return true;
      }
      
      // Whether the card may go to this column, and if not, says why.
      function allowed(card, column) {
          const board = boardOf(card);
          const problem = blocked(card) ?? (boardOf(column) !== board || isReadonly(column) ? `${card.dataset.label} can't go to ${titleOf(column)}.` : null);
          if (problem) {
              say(board, problem);
          }
      
          return problem === null;
      }
      
      // Moves the card to index in column; false when it can't. Index past the end means the end.
      function moveCard(card, column, index) {
          if (!allowed(card, column)) {
              return false;
          }
          const before = spot(card);
          place(card, column, Math.max(0, Math.min(index, cardsIn(column).filter((other) => other !== card).length)));
      
          return settle(card, before);
      }
      
      // --- The "Move…" menu --------------------------------------------------------------------------------------
      
      function menuItem(board, text, data) {
          const item = board.querySelector('template[data-kanban-templates]').content.querySelector('[data-kanban-item]').cloneNode(true);
          item.querySelector('span').textContent = text;
          Object.assign(item.dataset, data);
      
          return item;
      }
      
      function divider(board) {
          return board.querySelector('template[data-kanban-templates]').content.querySelector('[role="separator"]').cloneNode(true);
      }
      
      // Built as the menu opens, after the dropdown's own handler, so it lists the columns and the card's place as they
      // are now. A choice is handled on click below.
      document.addEventListener('beforetoggle', (event) => {
          const menu = event.target;
          const card = event.newState === 'open' && menu.matches?.('[data-dropdown-menu]') ? menu.closest(CARD) : null;
          const board = card && boardOf(card);
          if (!board || !menu.closest('[data-kanban-move]')) {
              return;
          }
          const here = columnOf(card);
          const index = cardsIn(here).indexOf(card);
          const last = cardsIn(here).length - 1;
          const away = columnsOf(board)
              .filter((column) => column !== here && !isReadonly(column))
              .map((column) => menuItem(board, `Move to ${titleOf(column)}`, { kanbanTo: idOf(column) }));
          const steps = [
              index > 0 && menuItem(board, 'Move up', { kanbanIndex: String(index - 1) }),
              index < last && menuItem(board, 'Move down', { kanbanIndex: String(index + 1) }),
          ].filter(Boolean);
          // To the top or bottom only when that's more than one step away: one step is Move up or Move down. Grouped apart
          // from them, so a slip of the arrow key or the finger doesn't send a card to the far end.
          const jumps = [
              index > 1 && menuItem(board, 'Move to top', { kanbanIndex: '0' }),
              index < last - 1 && menuItem(board, 'Move to bottom', { kanbanIndex: String(last) }),
          ].filter(Boolean);
          // Picking up comes first when there's anywhere to go: it reaches every place, the others are shortcuts.
          const pick = away.length || steps.length ? [menuItem(board, 'Pick up and place', { kanbanPick: '' })] : [];
          const items = [pick, away, steps, jumps].filter((group) => group.length).flatMap((group, i) => (i ? [divider(board), ...group] : group));
          if (items.length === 0) {
              const none = menuItem(board, 'Nowhere to move it', {});
              none.setAttribute('aria-disabled', 'true');
              items.push(none);
          }
          menu.replaceChildren(...items);
      }, true);
      
      document.addEventListener('click', (event) => {
          const item = event.target.closest?.('[data-kanban-item]');
          const card = item?.closest(CARD);
          if (!card || item.getAttribute('aria-disabled') === 'true') {
              return;
          }
          if (item.dataset.kanbanPick !== undefined) {
              pickUp(card);
          } else if (item.dataset.kanbanTo !== undefined) {
              const column = columnById(boardOf(card), item.dataset.kanbanTo);
              if (column) {
                  moveCard(card, column, Infinity);
              }
          } else if (item.dataset.kanbanIndex !== undefined) {
              moveCard(card, columnOf(card), Number(item.dataset.kanbanIndex));
          }
      });
      
      // --- Picking up and placing ------------------------------------------------------------------------------------
      
      // One card at a time, on the whole page: { card, board, before }. The card moves as it's placed, and only counts as
      // moved when it's put down, from where it was picked up: one kanban:move, one save.
      let picked = null;
      // Enter or Space put the card down on keydown; their keyup would then press the move button and open its menu.
      let swallowKeyUp = null;
      
      // The "Move here" targets, for a finger or a pointer. Keys move the card itself, so these are hidden from screen
      // readers, and out of the Tab order.
      const SLOT = 'border-primary/60 text-primary hover:bg-primary/10 focus-visible:ring-primary flex h-9 w-full items-center justify-center rounded-xl border-2 border-dashed text-xs font-medium outline-none focus-visible:ring-2';
      
      function drawSlots() {
          const { card } = picked;
          const board = boardOf(card);
          // The targets push the cards apart: they glide apart, rather than jump.
          slide(board, () => {
              board.querySelectorAll('[data-kanban-slot]').forEach((slot) => slot.remove());
              for (const column of columnsOf(board).filter((other) => !isReadonly(other))) {
                  const others = cardsIn(column).filter((other) => other !== card);
                  const here = column === columnOf(card) ? cardsIn(column).indexOf(card) : -1;
                  for (let index = 0; index <= others.length; index++) {
                      // Right beside the card would leave it where it is.
                      if (index === here) {
                          continue;
                      }
                      const slot = Object.assign(document.createElement('li'), { className: 'list-none' });
                      slot.setAttribute('data-kanban-slot', '');
                      slot.setAttribute('aria-hidden', 'true');
                      const button = Object.assign(document.createElement('button'), { type: 'button', tabIndex: -1, className: SLOT, textContent: 'Move here' });
                      Object.assign(button.dataset, { kanbanSlotColumn: idOf(column), kanbanSlotIndex: String(index) });
                      slot.append(button);
                      listOf(column).insertBefore(slot, others[index] ?? null);
                  }
              }
          });
      }
      
      function setPlacing(board, on) {
          board.toggleAttribute('data-kanban-placing', on);
          holdHeight(board, on);
          board.querySelector('[data-kanban-cancel]').hidden = !on;
      }
      
      function pickUp(card) {
          if (picked) {
              putDown(false);
          }
          const board = boardOf(card);
          const problem = blocked(card);
          if (problem) {
              say(board, problem);
      
              return false;
          }
          offerUndo(board, null);
          picked = { card, board, before: spot(card) };
          card.setAttribute('data-kanban-picked', '');
          setPlacing(board, true);
          drawSlots();
          say(board, `Picked up ${card.dataset.label}, ${where(card)}. Choose where it goes: the arrow keys and Enter, or Move here. Escape or Cancel puts it back.`);
      
          return true;
      }
      
      // Ends placing: puts the card down where it is now (keep), or back where it was picked up.
      function putDown(keep) {
          if (!picked) {
              return;
          }
          const { card, board, before } = picked;
          picked = null;
          card.removeAttribute('data-kanban-picked');
          slide(board, () => board.querySelectorAll('[data-kanban-slot]').forEach((slot) => slot.remove()));
          setPlacing(board, false);
          // Gone from the page while being placed (a Livewire render, a script): nothing to put down.
          if (!card.isConnected) {
              return;
          }
          if (keep) {
              settle(card, before);
          } else {
              putBack(card, before);
              say(board, `${card.dataset.label} is back in ${where(card)}.`);
          }
      }
      
      // Moves the picked card one step and says where it is; the board's edges and readonly columns stop it.
      function step(column, index) {
          const { card } = picked;
          if (!column || isReadonly(column)) {
              return;
          }
          place(card, column, Math.max(0, Math.min(index, cardsIn(column).filter((other) => other !== card).length)));
          drawSlots();
          say(boardOf(card), `${where(card)}.`);
      }
      
      // The next column that takes cards, towards the end of the board (+1) or the start (-1).
      function nextColumn(card, direction) {
          const columns = columnsOf(boardOf(card));
          const from = columns.indexOf(columnOf(card));
          const ahead = direction > 0 ? columns.slice(from + 1) : columns.slice(0, from).reverse();
      
          return ahead.find((column) => !isReadonly(column)) ?? null;
      }
      
      // Capture phase, before the dropdown's own keys: while placing, the arrows move the card, not focus or a menu.
      document.addEventListener('keydown', (event) => {
          if (!picked || !boardOf(picked.card)?.contains(event.target)) {
              return;
          }
          const { card } = picked;
          const column = columnOf(card);
          const index = cardsIn(column).indexOf(card);
          // Left and Right follow the page: in a right-to-left one, the next column is to the left.
          const forward = getComputedStyle(boardOf(card)).direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
          const keys = {
              ArrowUp: () => step(column, index - 1),
              ArrowDown: () => step(column, index + 1),
              Home: () => step(column, 0),
              End: () => step(column, Infinity),
              ArrowLeft: () => step(nextColumn(card, forward === 'ArrowLeft' ? 1 : -1), index),
              ArrowRight: () => step(nextColumn(card, forward === 'ArrowRight' ? 1 : -1), index),
              Enter: () => putDown(true),
              ' ': () => putDown(true),
              Escape: () => putDown(false),
          };
          if (event.key === 'Tab') {
              putDown(false);
      
              return;
          }
          if (!keys[event.key]) {
              return;
          }
          event.preventDefault();
          event.stopPropagation();
          if (event.key === 'Enter' || event.key === ' ') {
              swallowKeyUp = event.key;
          }
          keys[event.key]();
      }, true);
      
      document.addEventListener('keyup', (event) => {
          if (swallowKeyUp === event.key) {
              swallowKeyUp = null;
              event.preventDefault();
              event.stopPropagation();
          }
      }, true);
      
      // Capture phase: while placing, a click inside the board places the card or cancels, and does nothing else, so a
      // stray tap can't follow a card's link or open a menu. A click outside the board puts the card back.
      document.addEventListener('click', (event) => {
          if (!picked) {
              return;
          }
          const board = boardOf(picked.card);
          if (!board?.contains(event.target)) {
              putDown(false);
      
              return;
          }
          event.preventDefault();
          event.stopPropagation();
          const slot = event.target.closest('[data-kanban-slot] button');
          if (slot) {
              place(picked.card, columnById(board, slot.dataset.kanbanSlotColumn), Number(slot.dataset.kanbanSlotIndex));
              picked.card.querySelector('[data-dropdown-trigger]')?.focus({ preventScroll: true });
              putDown(true);
          } else if (event.target.closest('[data-kanban-cancel]') || picked.card.contains(event.target)) {
              picked.card.querySelector('[data-dropdown-trigger]')?.focus({ preventScroll: true });
              putDown(false);
          }
      }, true);
      
      // --- Dragging, with a mouse or a pen ---------------------------------------------------------------------------
      
      // A press on a card that can move, then a few pixels' travel, drags it: a copy follows the pointer, and the card itself
      // waits, faded, where it would land. Letting go puts it there. Escape, a cancelled pointer, or letting go off the board
      // or over a readonly column puts it back. Touch scrolls instead, and picks up from the move menu.
      const THRESHOLD = 5;
      // How near an edge the pointer has to be for the board or the page to scroll, and how fast.
      const EDGE = 48;
      const SPEED = 14;
      // A press on these inside a card is theirs: a link, a button, a field, text someone means to select.
      const OWN_PRESS = 'a, button, input, select, textarea, label, summary, [contenteditable], [tabindex]:not([tabindex="-1"])';
      // Grabbing, wherever the pointer is until it lets go: important, to win over the grab and pointer cursors of the
      // cards and buttons it passes over.
      const GRABBING = ['cursor-grabbing!', '**:cursor-grabbing!'];
      // The copy that follows the pointer, positioned by transform from the window's top left.
      const GHOST = 'pointer-events-none fixed top-0 left-0 z-50 m-0 list-none rotate-2 shadow-xl ring-2 ring-primary';
      
      let press = null;
      let drag = null;
      // The click a mouse sends after letting go of a drag would otherwise follow a link or open a menu.
      let swallowClick = false;
      
      // The column under the pointer. Columns are only as tall as their cards, so below a short one, still in the row and
      // within its width, counts as that column too: its lane, where letting go puts the card at its end.
      const columnAt = (board, x, y) => {
          const column = document.elementFromPoint(x, y)?.closest(COLUMN);
          if (column) {
              return boardOf(column) === board ? column : null;
          }
          const row = scrollerOf(board).getBoundingClientRect();
          if (y < row.top || y > row.bottom || x < row.left || x > row.right) {
              return null;
          }
      
          return columnsOf(board).find((lane) => {
              const box = lane.getBoundingClientRect();
      
              return x >= box.left && x <= box.right && y >= box.top;
          }) ?? null;
      };
      
      // Moves the ghost to the pointer, and the card to where letting go would put it.
      function follow() {
          const { card, board, ghost, x, y, dx, dy } = drag;
          // Only where it is: its tilt and lift are its rotate and scale, so they animate apart from this.
          ghost.style.transform = `translate(${x - dx}px, ${y - dy}px)`;
          const column = columnAt(board, x, y);
          if (!column || isReadonly(column)) {
              // Over a readonly column or off the board, letting go puts it back; in the gap between columns it keeps its
              // last place.
              drag.refused = column !== null || !board.contains(document.elementFromPoint(x, y));
              // Letting go now would put it back where it started: show that, rather than the column it last passed over.
              // Once: it's then home, and every later pointer move would only send it there again.
              if (drag.refused && !stillThere(card, drag.before)) {
                  putBack(card, drag.before, { scroll: false });
              }
      
              return;
          }
          drag.refused = false;
          // Its place among the others: after every card whose middle is above the pointer.
          const index = cardsIn(column).filter((other) => {
              const box = laidOut(other);
      
              return other !== card && box.top + box.height / 2 < y;
          }).length;
          if (columnOf(card) !== column || cardsIn(column).indexOf(card) !== index) {
              place(card, column, index, { scroll: false });
          }
      }
      
      // Scrolls the board sideways and the page up or down while the pointer is near their edges.
      function scrollNearEdges() {
          if (!drag) {
              return;
          }
          const { board, x, y } = drag;
          const columns = board.querySelector('[data-kanban-columns]');
          const box = columns.getBoundingClientRect();
          const sideways = x < box.left + EDGE ? -SPEED : x > box.right - EDGE ? SPEED : 0;
          const down = y < EDGE ? -SPEED : y > window.innerHeight - EDGE ? SPEED : 0;
          if (sideways) {
              columns.scrollBy({ left: sideways });
          }
          if (down) {
              window.scrollBy({ top: down });
          }
          if (sideways || down) {
              follow();
          }
          drag.frame = requestAnimationFrame(scrollNearEdges);
      }
      
      function startDrag(event) {
          // Where the card was pressed, not where the pointer is now: the copy keeps that same point under the pointer.
          const { card, x: pressX, y: pressY } = press;
          press = null;
          const box = card.getBoundingClientRect();
          const ghost = card.cloneNode(true);
          // Not a card, not focusable, not read out: a picture of one.
          for (const element of [ghost, ...ghost.querySelectorAll('[id]')]) {
              element.removeAttribute('id');
          }
          ghost.removeAttribute('data-kanban-card');
          ghost.removeAttribute('wire:key');
          ghost.setAttribute('aria-hidden', 'true');
          ghost.inert = true;
          ghost.className = `${ghost.className} ${GHOST}`;
          // The card is relative (its move button sits in its corner), and that would win over fixed, leaving the copy at
          // the foot of the page instead of under the pointer.
          ghost.classList.remove('relative');
          ghost.style.width = `${box.width}px`;
          document.body.append(ghost);
          // Lifted off the board: it tilts and grows a little as it comes up.
          if (moving()) {
              ghost.animate([{ rotate: '0deg', scale: '1' }, { rotate: '2deg', scale: '1.03' }], { duration: LIFT, easing: 'ease-out', fill: 'forwards' });
          }
          drag = { card, board: boardOf(card), before: spot(card), ghost, pointerId: event.pointerId, dx: pressX - box.left, dy: pressY - box.top, x: event.clientX, y: event.clientY, refused: false, frame: 0 };
          card.setAttribute('data-kanban-dragging', '');
          drag.board.setAttribute('data-kanban-moving', '');
          holdHeight(drag.board, true);
          offerUndo(drag.board, null);
          document.documentElement.classList.add(...GRABBING);
          window.getSelection()?.removeAllRanges();
          // So the pointer's moves and its letting go still reach here off the card, or off the window. A pointer the
          // browser doesn't know (a script's) can't be captured, and doesn't need to be.
          try {
              card.setPointerCapture(event.pointerId);
          } catch {
              // Nothing to capture.
          }
          follow();
          drag.frame = requestAnimationFrame(scrollNearEdges);
      }
      
      function endDrag(keep) {
          if (!drag) {
              return;
          }
          const { card, board, before, ghost, pointerId, frame } = drag;
          drag = null;
          cancelAnimationFrame(frame);
          card.removeAttribute('data-kanban-dragging');
          board.removeAttribute('data-kanban-moving');
          document.documentElement.classList.remove(...GRABBING);
          if (card.hasPointerCapture?.(pointerId)) {
              card.releasePointerCapture(pointerId);
          }
          if (!card.isConnected) {
              ghost.remove();
              holdHeight(board, false);
      
              return;
          }
          if (keep) {
              settle(card, before);
          } else {
              putBack(card, before);
              say(board, `${card.dataset.label} is back in ${where(card)}.`);
          }
          land(ghost, card, () => holdHeight(board, false));
      }
      
      // The copy settles into the card's place, wherever that now is (the slot it was let go over, or back where it
      // started), and the card shows again as it lands. Straight away for people who ask for less motion.
      function land(ghost, card, done) {
          if (!moving()) {
              ghost.remove();
              done();
      
              return;
          }
          card.setAttribute('data-kanban-landing', '');
          const to = laidOut(card);
          const glide = ghost.animate([
              { transform: ghost.style.transform, rotate: '2deg', scale: '1.03' },
              { transform: `translate(${to.left}px, ${to.top}px)`, rotate: '0deg', scale: '1' },
          ], { duration: LAND, easing: 'ease-in-out' });
          glide.finished.catch(() => {}).finally(() => {
              ghost.remove();
              card.removeAttribute('data-kanban-landing');
              done();
          });
      }
      
      // The columns stretch to the tallest. A card taken out of the tallest one would shrink the row, and with it the page
      // below, then grow it again as the card comes back: the row keeps the height it had while a card is in hand, and only
      // takes its new one once the card is down.
      function holdHeight(board, hold) {
          // Not let go while another card is in hand on this board: a landing that ends during the next drag, say.
          if (!hold && (drag?.board === board || picked?.board === board)) {
              return;
          }
          const row = scrollerOf(board);
          row.style.minHeight = hold ? `${row.offsetHeight}px` : '';
      }
      
      document.addEventListener('pointerdown', (event) => {
          if (event.button !== 0 || event.pointerType === 'touch' || picked || drag) {
              return;
          }
          const card = event.target.closest?.(CARD);
          const own = event.target.closest?.(OWN_PRESS);
          // Only on a board the script has set up, and never one that can't move: a press is silent, so is its refusal.
          if (!card || !boardOf(card)?.hasAttribute('data-kanban-ready') || (own && card.contains(own)) || blocked(card)) {
              return;
          }
          press = { card, pointerId: event.pointerId, x: event.clientX, y: event.clientY };
      });
      
      document.addEventListener('pointermove', (event) => {
          if (press && event.pointerId === press.pointerId && Math.hypot(event.clientX - press.x, event.clientY - press.y) >= THRESHOLD) {
              startDrag(event);
          }
          if (drag && event.pointerId === drag.pointerId) {
              drag.x = event.clientX;
              drag.y = event.clientY;
              follow();
          }
      });
      
      document.addEventListener('pointerup', (event) => {
          if (press?.pointerId === event.pointerId) {
              press = null;
          }
          if (drag?.pointerId === event.pointerId) {
              endDrag(!drag.refused);
              swallowClick = true;
              // The click comes in the same task as the pointerup; anything later isn't it.
              setTimeout(() => {
                  swallowClick = false;
              });
          }
      });
      
      document.addEventListener('pointercancel', (event) => {
          if (press?.pointerId === event.pointerId) {
              press = null;
          }
          if (drag?.pointerId === event.pointerId) {
              endDrag(false);
          }
      });
      
      document.addEventListener('keydown', (event) => {
          if (drag && event.key === 'Escape') {
              event.preventDefault();
              event.stopPropagation();
              endDrag(false);
          }
      }, true);
      
      document.addEventListener('click', (event) => {
          if (swallowClick) {
              swallowClick = false;
              event.preventDefault();
              event.stopPropagation();
          }
      }, true);
      
      // Dragging over text would otherwise select it.
      document.addEventListener('selectstart', (event) => {
          if (drag) {
              event.preventDefault();
          }
      });
      
      // --- Undo ----------------------------------------------------------------------------------------------------
      
      // Where the last move left its card, by column id and place: the elements may have been drawn again since.
      const stillThere = (card, after) => {
          const now = spot(card);
      
          return idOf(now.column) === idOf(after.column) && now.index === after.index;
      };
      
      // Makes the opposite of the last move, as a move of its own: announced, sent and saved like any other. Not if the card
      // has moved since by some other way (a render, a script), which Undo would then get wrong.
      document.addEventListener('click', (event) => {
          const button = event.target.closest?.('[data-kanban-undo]');
          const board = button && boardOf(button);
          const last = board && undoable.get(board);
          if (!last) {
              return;
          }
          const card = current(board, last.card);
          if (!card || !stillThere(card, last.after)) {
              offerUndo(board, null);
              say(board, `${last.card.dataset.label} has moved since, so it stays where it is.`);
      
              return;
          }
          const { before } = last;
          const now = spot(card);
          // Still being saved, say: the offer stands for when it's done.
          const problem = blocked(card);
          if (problem) {
              say(board, problem);
      
              return;
          }
          offerUndo(board, null);
          // The Undo button hides, so focus goes to the card it moved.
          card.querySelector('[data-dropdown-trigger]')?.focus({ preventScroll: true });
          putBack(card, before);
          settle(card, now, { canUndo: false });
      });
      
      // --- Jumping to a column -------------------------------------------------------------------------------------
      
      // The look of the buttons the script draws, and of the one for the column in view.
      const JUMP = 'border-line text-foreground hover:bg-field focus-visible:ring-primary aria-[current=true]:border-primary/40 aria-[current=true]:bg-primary/10 aria-[current=true]:text-primary inline-flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium whitespace-nowrap outline-none focus-visible:ring-2';
      
      // The ring around a column just chosen from its button. Inset, so the scroller's edge doesn't clip it.
      const JUMPED = ['ring-primary', 'ring-2', 'ring-inset'];
      
      const scrollerOf = (board) => board.querySelector('[data-kanban-columns]');
      
      // Per board, the column last chosen from its jump buttons.
      const chosen = new WeakMap();
      // Per column, the timer that takes its ring away again.
      const ringing = new WeakMap();
      
      // Marks the button of the column most in view: on a phone, the one being looked at. When several are wholly in view,
      // as once scrolled to the end, the one last chosen, or else the first of them: scrolled as far as it goes, choosing
      // Delivered can't bring it any further in, but it's still the one asked for.
      function markCurrent(board) {
          const view = scrollerOf(board).getBoundingClientRect();
          const columns = columnsOf(board);
          const shown = columns.map((column) => {
              const box = column.getBoundingClientRect();
      
              return box.width ? Math.max(0, Math.min(box.right, view.right) - Math.max(box.left, view.left)) / box.width : 0;
          });
          // Within a pixel or so of wholly in view counts as wholly in view.
          const most = Math.max(...shown) - 0.01;
          const candidates = columns.filter((column, i) => shown[i] >= most);
          const current = candidates.includes(chosen.get(board)) ? chosen.get(board) : candidates[0];
          board.querySelectorAll('[data-kanban-jump] button').forEach((button) => {
              button.setAttribute('aria-current', String(button.dataset.kanbanJumpTo === (current && idOf(current))));
          });
      }
      
      // One button per column, shown only while the columns don't all fit side by side.
      function drawJumps(board) {
          const bar = board.querySelector('[data-kanban-jump]');
          const scroller = scrollerOf(board);
          const overflows = scroller.scrollWidth > scroller.clientWidth + 1;
          bar.hidden = !overflows;
          // Columns that scroll sideways can only be scrolled by a mouse or a finger unless the row can take focus: it does,
          // named, while it scrolls, so a board that fits gets no needless tab stop. A readonly board has nothing else
          // inside to focus, and the arrow keys would have no way to reach its other columns.
          if (overflows) {
              scroller.tabIndex = 0;
              scroller.setAttribute('role', 'region');
              scroller.setAttribute('aria-label', `Columns of ${board.getAttribute('aria-label')}`);
          } else {
              scroller.removeAttribute('tabindex');
              scroller.removeAttribute('role');
              scroller.removeAttribute('aria-label');
          }
          if (bar.hidden) {
              return;
          }
          bar.setAttribute('role', 'group');
          bar.setAttribute('aria-label', `Columns of ${board.getAttribute('aria-label')}`);
          bar.replaceChildren(...columnsOf(board).map((column) => {
              const button = Object.assign(document.createElement('button'), { type: 'button', className: JUMP });
              button.dataset.kanbanJumpTo = idOf(column);
              // Set apart by weight, not by fading it: faded, it fell below AA on the tinted button of the column in view.
              const count = Object.assign(document.createElement('span'), { className: 'font-normal tabular-nums' });
              count.setAttribute('data-kanban-jump-count', '');
              count.textContent = numbers().format(cardsIn(column).length);
              // A space between, for the name a screen reader gives the button ("New 3"); the gap already shows one.
              button.append(titleOf(column), ' ', count);
      
              return button;
          }));
          markCurrent(board);
      }
      
      document.addEventListener('click', (event) => {
          const button = event.target.closest?.('[data-kanban-jump] button');
          const board = button && boardOf(button);
          const column = board && columnById(board, button.dataset.kanbanJumpTo);
          if (!column) {
              return;
          }
          chosen.set(board, column);
          const smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
          column.scrollIntoView({ inline: 'start', block: 'nearest', behavior: smooth ? 'smooth' : 'auto' });
          markCurrent(board);
          // A moment's ring around it, so the choice shows even when there was nothing to scroll.
          column.classList.add(...JUMPED);
          clearTimeout(ringing.get(column));
          ringing.set(column, setTimeout(() => column.classList.remove(...JUMPED), 1200));
      });
      
      // Scroll events don't bubble, so listen in the capture phase; once a frame is enough.
      let marking = 0;
      document.addEventListener('scroll', (event) => {
          const board = event.target.matches?.('[data-kanban-columns]') ? boardOf(event.target) : null;
          if (board && !marking) {
              marking = requestAnimationFrame(() => {
                  marking = 0;
                  markCurrent(board);
              });
          }
      }, true);
      
      // Whether the columns fit changes with the window, the page's layout, and the columns themselves.
      const resized = new ResizeObserver((entries) => entries.forEach((entry) => drawJumps(boardOf(entry.target))));
      
      // --- Setting up ----------------------------------------------------------------------------------------------
      
      // Set up once per element. Not a data attribute for that: Livewire's morph removes attributes the server didn't render.
      const ready = new WeakSet();
      
      function initBoard(board) {
          if (ready.has(board)) {
              return;
          }
          ready.add(board);
          // Shows the move buttons. The board keeps it through a Livewire render (wire:ignore.self on it).
          if (!isReadonly(board)) {
              board.setAttribute('data-kanban-ready', '');
          }
          const seen = new Set();
          for (const card of columnsOf(board).flatMap(cardsIn)) {
              if (seen.has(idOf(card))) {
                  console.warn(`Bladewell kanban: two cards on one board share the id "${idOf(card)}"; a move would send the wrong one.`, card);
              }
              seen.add(idOf(card));
          }
          columnsOf(board).forEach(count);
          drawJumps(board);
          resized.observe(scrollerOf(board));
      }
      
      export function initKanbans(scope = document) {
          scope.querySelectorAll(BOARD).forEach(initBoard);
      }
      
      initKanbans();
      
      // After the page has changed under the script (a Livewire render, a script of yours): the cards in play may have been
      // drawn again, without the marks the script set on them, and somewhere else. Follow them by their id, put the marks
      // back (and the Move here targets with them), and drop an Undo the change has made wrong, saying where the card is.
      function catchUp() {
          if (picked) {
              const card = current(picked.board, picked.card);
              if (!card) {
                  putDown(false);
              } else {
                  const redrawn = card !== picked.card;
                  picked.card = card;
                  if (!card.hasAttribute('data-kanban-picked')) {
                      card.setAttribute('data-kanban-picked', '');
                      drawSlots();
                  }
                  // Focus went with the old card; the keys that place it need it back. And it's drawn where the server has
                  // it, which may not be where it was being placed: say where.
                  if (redrawn) {
                      if (!picked.board.contains(document.activeElement)) {
                          card.querySelector('[data-dropdown-trigger]')?.focus({ preventScroll: true });
                      }
                      say(picked.board, `${where(card)}.`);
                  }
              }
          }
          if (drag) {
              const card = current(drag.board, drag.card);
              if (!card) {
                  endDrag(false);
              } else {
                  drag.card = card;
                  card.setAttribute('data-kanban-dragging', '');
              }
          }
          for (const board of document.querySelectorAll(BOARD)) {
              for (const id of savingIn(board)) {
                  cardById(board, id)?.setAttribute('aria-busy', 'true');
              }
              const last = undoable.get(board);
              const card = last && current(board, last.card);
              if (last && !card) {
                  offerUndo(board, null);
              } else if (last && !stillThere(card, last.after)) {
                  // A Livewire component that refused the move has drawn the card where it was.
                  offerUndo(board, null);
                  const back = idOf(columnOf(card)) === idOf(last.before.column);
                  say(board, `${card.dataset.label} is ${back ? 'back' : 'now'} in ${where(card)}.`);
              } else if (last) {
                  last.card = card;
              }
          }
      }
      
      // Boards added to the page later (a Livewire render or wire:navigate, fetched HTML) set themselves up. A card taken off
      // the page while it was picked up or dragged ends that.
      new MutationObserver((records) => {
          catchUp();
          // Columns added or taken away (a render, a script): the jump buttons follow.
          for (const scroller of new Set(records.map((record) => record.target).filter((target) => target.matches?.('[data-kanban-columns]')))) {
              drawJumps(boardOf(scroller));
          }
          for (const node of records.flatMap((record) => [...record.addedNodes])) {
              if (node instanceof Element) {
                  (node.matches(BOARD) ? [node] : node.querySelectorAll(BOARD)).forEach(initBoard);
              }
          }
      }).observe(document.documentElement, { childList: true, subtree: true });
      
      // --- From scripts --------------------------------------------------------------------------------------------
      
      const find = (target, selector) => (typeof target === 'string' || typeof target === 'number'
          ? document.querySelector(`${selector.slice(0, -1)}="${CSS.escape(String(target))}"]`)
          : target);
      
      // kanban.move(card, column, position): card by element or id, column by element or id on the card's board, position
      // counted from 0 (left out, the end). Returns whether it moved.
      function move(target, toColumn, position = Infinity) {
          const card = find(target, CARD);
          const column = card && (toColumn instanceof Element ? toColumn : columnById(boardOf(card), toColumn));
          if (!card || !column) {
              console.warn('Bladewell kanban: no such card or column on this page for', target, toColumn);
      
              return false;
          }
      
          return moveCard(card, column, position);
      }
      
      export const kanban = { move };
      
      window.kanban = kanban;
      
      app/View/Widget/ElementIds.php Show
      ElementIds.php
      <?php
      
      declare(strict_types=1);
      
      namespace App\View\Widget;
      
      use Illuminate\Container\Attributes\Scoped;
      use LogicException;
      use WeakReference;
      
      /**
       * Keeps element ids unique within one response, so labels, aria-describedby and #fragments
       * always point at the right element. Scoped: a fresh set per request (and per Octane/queue cycle).
       */
      #[Scoped]
      final class ElementIds
      {
          /** @var array<string, string|null> id => the Livewire component that claimed it, null for the page around them */
          private array $used = [];
      
          /** @var array<string, WeakReference<object>> Livewire component id => the instance whose render claimed its ids */
          private array $renders = [];
      
          /**
           * Reserves an id for this response.
           *
           * A derived id (built from a field name) gets a -2, -3 … suffix when already taken. An explicit
           * id is one the caller chose and may reference from JS or CSS, so silently renaming it would
           * break that reference; a duplicate throws instead, which surfaces in development and tests.
           */
          public function claim(string $id, bool $explicit = false): string
          {
              $owner = $this->owner();
              if (! array_key_exists($id, $this->used)) {
                  return $this->reserve($id, $owner);
              }
      
              if ($explicit) {
                  throw new LogicException("Duplicate element id [{$id}] on this page. Give one of the widgets a different id or name.");
              }
      
              $suffix = 2;
              while (array_key_exists("{$id}-{$suffix}", $this->used)) {
                  $suffix++;
              }
      
              return $this->reserve("{$id}-{$suffix}", $owner);
          }
      
          /**
           * The Livewire component rendering now (Livewire shares it with every view as $__livewire), or null outside one.
           *
           * A component rendering again in the same request, as Livewire::test() does when an action is called after it
           * mounted, replaces what it drew before: the ids it claimed then are free again, or its own explicit ids would
           * count as duplicates. Ids of the rest of the page still count.
           */
          private function owner(): ?string
          {
              $component = app('view')->getShared()['__livewire'] ?? null;
              if (! is_object($component) || ! method_exists($component, 'getId')) {
                  return null;
              }
              $key = (string) $component->getId();
              $seen = $this->renders[$key] ?? null;
              if ($seen?->get() !== $component) {
                  if ($seen !== null) {
                      $this->used = array_filter($this->used, static fn (?string $owner): bool => $owner !== $key);
                  }
                  $this->renders[$key] = WeakReference::create($component);
              }
      
              return $key;
          }
      
          private function reserve(string $id, ?string $owner): string
          {
              $this->used[$id] = $owner;
      
              return $id;
          }
      }