Skip to content
Brok UI

Loading…

No results

Queue

Open source

A keyboard triage queue for any record type: one generic row with a start slot, title, meta line and end slot, and a grouped list with a keyboard cursor, selection and optimistic hide and restore.

Version
v1.2.0
Stability
stable
License
MIT
Related
Queue Row
List Cursor
Issue List
Hotkeys
Row Actions
Toast

Queue List — A keyboard triage queue for any record type (inbox, review, moderation): queue rows in optional groups with sticky headers and counts, j/k and arrows, Enter, x, Shift+J/K range selection and Escape through the shared list-cursor module, and a triage API (hide, restore, nextAfter, focusEmpty) that removes rows optimistically, moves the cursor to the next row and announces it. Empty state, dataset boundary footer, wire:model selection and back/forward cursor restore.

Preview

Use J and K or the arrow keys to move between items and Enter to open.

Today

3 items

Earlier

3 items

Showing 6 of 42

previews.components.queue-list.default.blade.php Blade
{{-- Grouped data mode with sticky headers and counts, the current (open) row, a dataset boundary footer and persist (the cursor row survives back and forward). --}}
<div class="w-full max-w-2xl">
    <x-ui.queue-list
        :label="__('Inbox')"
        persist="preview-inbox"
        :total="42"
        :groups="[
            ['id' => 'today', 'name' => __('Today'), 'rows' => [
                ['id' => 'msg-1', 'href' => '#msg-1', 'title' => __('Refund request for order 10482'), 'meta' => __('Ada Lovelace: The parcel arrived damaged, photos attached.'), 'avatar' => ['name' => 'Ada Lovelace'], 'badges' => [['label' => __('Billing'), 'tone' => 'warning']], 'time' => '09:41', 'unread' => true, 'current' => true],
                ['id' => 'msg-2', 'href' => '#msg-2', 'title' => __('New comment on the Q3 roadmap'), 'meta' => __('Grace Hopper replied in Planning'), 'avatar' => ['name' => 'Grace Hopper'], 'time' => '08:15', 'unread' => true],
                ['id' => 'msg-3', 'href' => '#msg-3', 'title' => __('Access request for the finance workspace'), 'meta' => __('Alan Turing asks for editor access'), 'avatar' => ['name' => 'Alan Turing'], 'badges' => [__('Access')], 'time' => '07:02'],
            ]],
            ['id' => 'earlier', 'name' => __('Earlier'), 'rows' => [
                ['id' => 'msg-4', 'href' => '#msg-4', 'title' => __('Flagged post in Community'), 'meta' => __('Reported twice for spam'), 'badges' => [['label' => __('Spam'), 'tone' => 'destructive']], 'time' => __('Mon')],
                ['id' => 'msg-5', 'href' => '#msg-5', 'title' => __('Invoice 2026-118 is ready for approval'), 'meta' => __('Northwind Traders'), 'avatar' => ['name' => 'Northwind Traders'], 'time' => __('Sun')],
                ['id' => 'msg-6', 'href' => '#msg-6', 'title' => __('Weekly digest'), 'meta' => __('12 updates in your projects'), 'time' => __('Sat')],
            ]],
        ]"
    >
        <x-slot:footer>
            <x-ui.button variant="ghost" size="sm">{{ __('Load more') }}</x-ui.button>
        </x-slot:footer>
    </x-ui.queue-list>
</div>

Installation

terminal
php artisan ui:add queue-list

Note

This component ships an Alpine behavior module at resources/js/ui/queue-list.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './queue-list.js';

Registry contract

php artisan ui:add queue-list writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/queue-list.blade.php
  • blade resources/views/components/ui/queue-list/group.blade.php
  • js resources/js/ui/queue-list.js
Registry dependencies
queue-row list-cursor
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

queue-list.md
# Brok UI: Queue List (`queue-list`)

A keyboard triage queue for any record type (inbox, review, moderation): queue rows in optional groups with sticky headers and counts, j/k and arrows, Enter, x, Shift+J/K range selection and Escape through the shared list-cursor module, and a triage API (hide, restore, nextAfter, focusEmpty) that removes rows optimistically, moves the cursor to the next row and announces it. Empty state, dataset boundary footer, wire:model selection and back/forward cursor restore.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add queue-list
```

## Usage

```blade
{{-- Grouped data mode with sticky headers and counts, the current (open) row, a dataset boundary footer and persist (the cursor row survives back and forward). --}}
<div class="w-full max-w-2xl">
    <x-ui.queue-list
        :label="__('Inbox')"
        persist="preview-inbox"
        :total="42"
        :groups="[
            ['id' => 'today', 'name' => __('Today'), 'rows' => [
                ['id' => 'msg-1', 'href' => '#msg-1', 'title' => __('Refund request for order 10482'), 'meta' => __('Ada Lovelace: The parcel arrived damaged, photos attached.'), 'avatar' => ['name' => 'Ada Lovelace'], 'badges' => [['label' => __('Billing'), 'tone' => 'warning']], 'time' => '09:41', 'unread' => true, 'current' => true],
                ['id' => 'msg-2', 'href' => '#msg-2', 'title' => __('New comment on the Q3 roadmap'), 'meta' => __('Grace Hopper replied in Planning'), 'avatar' => ['name' => 'Grace Hopper'], 'time' => '08:15', 'unread' => true],
                ['id' => 'msg-3', 'href' => '#msg-3', 'title' => __('Access request for the finance workspace'), 'meta' => __('Alan Turing asks for editor access'), 'avatar' => ['name' => 'Alan Turing'], 'badges' => [__('Access')], 'time' => '07:02'],
            ]],
            ['id' => 'earlier', 'name' => __('Earlier'), 'rows' => [
                ['id' => 'msg-4', 'href' => '#msg-4', 'title' => __('Flagged post in Community'), 'meta' => __('Reported twice for spam'), 'badges' => [['label' => __('Spam'), 'tone' => 'destructive']], 'time' => __('Mon')],
                ['id' => 'msg-5', 'href' => '#msg-5', 'title' => __('Invoice 2026-118 is ready for approval'), 'meta' => __('Northwind Traders'), 'avatar' => ['name' => 'Northwind Traders'], 'time' => __('Sun')],
                ['id' => 'msg-6', 'href' => '#msg-6', 'title' => __('Weekly digest'), 'meta' => __('12 updates in your projects'), 'time' => __('Sat')],
            ]],
        ]"
    >
        <x-slot:footer>
            <x-ui.button variant="ghost" size="sm">{{ __('Load more') }}</x-ui.button>
        </x-slot:footer>
    </x-ui.queue-list>
</div>
```

## Props

- `groups` (array, default `[]`) — Data mode: a list of { id, name, count?, emptyText?, rows: [queue-row props as arrays (id, title, href, meta, avatar, badges, time, datetime, media, mediaMax, mediaTotal, mediaIndicator, attachments, current, unread, hidden, selected), plus end?: string] }. count defaults to the row count; a group without a name renders only its rows. Every group and row gets wire:key.
- `rows` (array, default `[]`) — Data mode without groups: one list of queue-row props as arrays. Ignored when groups is set.
- `selectable` (bool, default `false`) — Adds the selection checkbox to every row (through @aware) and enables x, Shift+J/K, Shift+Arrow and Escape.
- `selected` (array, default `[]`) — The ids selected at first paint (strings; numbers are cast). With x-model or wire:model the bound value wins.
- `name` (string|null, default `null`) — Each row checkbox posts as name[] with the row id as its value.
- `persist` (string|null, default `null`) — A sessionStorage key (stored as queue-list:<persist>): the cursor row and the scroll position survive back and forward navigation (IC-011); focus returns only when the user left the page from inside the queue. Opt-in.
- `label` (string|null, default `null`) — The accessible name of the queue (role="group"). Null uses "Queue".
- `emptyText` (string|null, default `null`) — The empty state title. Null uses "Nothing left in the queue". The empty slot replaces the whole empty state.
- `emptyDescription` (string|null, default `null`) — A second line under the empty state title.
- `shown` (int|null, default `null`) — The rows on this page, for the footer. Null uses the data-mode row count (or total).
- `total` (int|null, default `null`) — The size of the whole queue. Set, the footer shows "Showing :shown of :total" (IC-016); hiding rows counts both down.
- `id` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `count` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `level` (int, default `3`) — Declared by @props in the registry Blade source.

## Use when

- Use to summarize, sequence, or present data so users can scan it quickly.
- A queue of records that someone works through one by one with the keyboard: an inbox, a review or approval queue, a moderation queue, a support queue.
- Optimistic triage: hide the row the moment the user acts (archive, approve, dismiss), move the cursor to the next row, and restore it on undo or a failed request.
- Bulk selection bound to a Livewire array with wire:model, or posted as name[] from a plain form.

## Avoid when

- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- Work tracker issues grouped by workflow state with collapsible sub-groups: use <x-ui.issue-list>.
- Sortable columns or inline cell editing: use <x-ui.data-table> or <x-ui.livewire-data-table>.
- More than 500 rows at once: page the queue on the server and pass shown and total (the component throws above 500 data rows).

## Anti-patterns

- Adding display ornament without informational value

## Rules

- Use the `<brok:queue-list>` tag (or `<x-ui.queue-list>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/queue-list
- Registry JSON (files, props, contract): https://brokui.dev/r/open/queue-list.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

binding.blade.php Blade
{{-- x-model (or wire:model) binds the selected ids; the outer variable may be named selected. Keys do nothing while typing in the filter. --}}
<div x-data="{ selected: ['rep-2'] }" class="flex w-full max-w-2xl flex-col gap-4">
    <x-ui.input size="sm" :aria-label="__('Filter reports')" :placeholder="__('Filter reports')" class="max-w-xs" />
    <x-ui.queue-list :label="__('Moderation queue')" selectable x-model="selected" :rows="[
        ['id' => 'rep-1', 'href' => '#rep-1', 'title' => __('Comment reported as harassment'), 'meta' => __('3 reports'), 'badges' => [['label' => __('High'), 'tone' => 'destructive']], 'time' => '12m'],
        ['id' => 'rep-2', 'href' => '#rep-2', 'title' => __('Profile photo reported'), 'meta' => __('1 report'), 'time' => '40m'],
        ['id' => 'rep-3', 'href' => '#rep-3', 'title' => __('Listing reported as a scam'), 'meta' => __('5 reports'), 'badges' => [['label' => __('High'), 'tone' => 'destructive']], 'time' => '1h'],
    ]" />
    <p class="text-sm text-muted-foreground">
        {{ __('Selected:') }} <span data-testid="queue-list-bound" x-text="selected.length ? selected.join(', ') : '-'"></span>
    </p>
</div>
empty.blade.php Blade
{{-- An empty queue, and your own empty state with a next step in the empty slot. --}}
<div class="flex w-full max-w-2xl flex-col gap-6">
    <x-ui.queue-list :label="__('Inbox')" :empty-description="__('New messages show up here.')" />

    <x-ui.queue-list :label="__('Review queue')">
        <x-slot:empty>
            <p class="font-medium text-foreground">{{ __('Everything is reviewed') }}</p>
            <x-ui.button size="sm" variant="outline">{{ __('Open the archive') }}</x-ui.button>
        </x-slot:empty>
    </x-ui.queue-list>
</div>
long-content.blade.php Blade
{{-- Long titles and meta lines truncate; a right-to-left row keeps its own direction; the group header stays on one line. On this narrow queue the thumbnail strip hides instead of squeezing the title or the live time. --}}
@php
    $thumbnail = 'data:image/svg+xml;utf8,'.rawurlencode('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" fill="hsl(220 14% 84%)"/></svg>');
@endphp
<div class="w-full max-w-sm">
    <x-ui.queue-list :label="__('Support queue')" :total="1280" :groups="[
        ['id' => 'waiting', 'name' => __('Waiting for a first reply from the support team for more than a day'), 'rows' => [
            ['id' => 'tk-1', 'href' => '#tk-1', 'title' => __('A deliberately long ticket subject that verifies truncation, overflow and content expansion without pushing the time out of the row'), 'meta' => 'customer-with-a-very-long-address@subdomain.example.com', 'avatar' => ['name' => 'Barbara Liskov'], 'badges' => [__('Enterprise'), __('Escalated')], 'time' => __('Yesterday'), 'unread' => true],
            ['id' => 'tk-2', 'href' => '#tk-2', 'title' => 'لا أستطيع تسجيل الدخول إلى حسابي منذ الأمس', 'meta' => 'https://example.com/a/very/long/unbroken/path/that/never/wraps', 'time' => '09:41'],
            ['id' => 'tk-3', 'href' => '#tk-3', 'title' => __('Screenshots of the failed payment attached to a long ticket subject'), 'meta' => __('8 attachments'), 'media' => array_fill(0, 3, $thumbnail), 'mediaTotal' => 8, 'badges' => [__('Billing')], 'datetime' => now()->subHours(3)->toIso8601String(), 'current' => true],
        ]],
    ]" />
</div>
selection.blade.php Blade
{{-- selectable: x toggles, Shift+J/K or Shift+Arrow extend, Escape clears. hide() and restore() give optimistic archive with undo. Previous and Next move the cursor through the queue-list-move window event and Open marks the cursor row current with setCurrent(), without moving focus into the list. --}}
<div x-data="{ picked: ['rev-2'], undo: [] }" class="flex w-full max-w-2xl flex-col gap-4">
    <div class="flex flex-wrap items-center gap-2">
        <x-ui.button size="sm" variant="outline" data-testid="queue-archive" x-on:click="undo = [...picked]; document.getElementById('review-queue').queueList.hide(undo)">{{ __('Archive selected') }}</x-ui.button>
        <x-ui.button size="sm" variant="ghost" data-testid="queue-undo" x-on:click="document.getElementById('review-queue').queueList.restore(undo); undo = []">{{ __('Undo') }}</x-ui.button>
        <span class="ms-auto inline-flex items-center gap-2">
            <x-ui.button size="sm" variant="ghost" data-testid="queue-previous" x-on:click="$dispatch('queue-list-move', { list: 'review-queue', delta: -1 })">{{ __('Previous') }}</x-ui.button>
            <x-ui.button size="sm" variant="ghost" data-testid="queue-next" x-on:click="$dispatch('queue-list-move', { list: 'review-queue', delta: 1 })">{{ __('Next') }}</x-ui.button>
            <x-ui.button size="sm" variant="outline" data-testid="queue-open-current" x-on:click="const queue = document.getElementById('review-queue').queueList; queue.setCurrent(queue.cursor())">{{ __('Open') }}</x-ui.button>
        </span>
    </div>
    <x-ui.queue-list id="review-queue" :label="__('Review queue')" selectable name="items" x-model="picked">
        <x-ui.queue-list.group id="pending" :label="__('Waiting for review')" :count="4">
            <x-ui.queue-row wire:key="queue-rev-1" id="rev-1" href="#rev-1" :title="__('Pricing page copy update')" :meta="__('Submitted by Ada Lovelace')" time="2h" />
            <x-ui.queue-row wire:key="queue-rev-2" id="rev-2" href="#rev-2" :title="__('New onboarding email')" :meta="__('Submitted by Grace Hopper')" time="3h" selected />
            <x-ui.queue-row wire:key="queue-rev-3" id="rev-3" href="#rev-3" :title="__('Holiday opening hours banner')" :meta="__('Submitted by Alan Turing')" time="5h" />
            <x-ui.queue-row wire:key="queue-rev-4" id="rev-4" href="#rev-4" :title="__('Terms of service wording')" :meta="__('Submitted by Barbara Liskov')" :badges="[['label' => __('Legal'), 'tone' => 'info']]" time="1d" />
        </x-ui.queue-list.group>
        <x-ui.queue-list.group id="changes" :label="__('Changes requested')" :count="1">
            <x-ui.queue-row wire:key="queue-rev-5" id="rev-5" href="#rev-5" :title="__('Careers page photos')" :meta="__('Submitted by Katherine Johnson')" time="2d" />
        </x-ui.queue-list.group>
    </x-ui.queue-list>
    <p class="text-sm text-muted-foreground">
        {{ __('Selected:') }} <span data-testid="queue-list-selected" x-text="picked.length ? picked.join(', ') : '-'"></span>
    </p>
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
groups array [] Data mode: a list of { id, name, count?, emptyText?, rows: [queue-row props as arrays (id, title, href, meta, avatar, badges, time, datetime, media, mediaMax, mediaTotal, mediaIndicator, attachments, current, unread, hidden, selected), plus end?: string] }. count defaults to the row count; a group without a name renders only its rows. Every group and row gets wire:key.
rows array [] Data mode without groups: one list of queue-row props as arrays. Ignored when groups is set.
selectable bool false Adds the selection checkbox to every row (through @aware) and enables x, Shift+J/K, Shift+Arrow and Escape.
selected array [] The ids selected at first paint (strings; numbers are cast). With x-model or wire:model the bound value wins.
name string | null null Each row checkbox posts as name[] with the row id as its value.
persist string | null null A sessionStorage key (stored as queue-list:<persist>): the cursor row and the scroll position survive back and forward navigation (IC-011); focus returns only when the user left the page from inside the queue. Opt-in.
label string | null null The accessible name of the queue (role="group"). Null uses "Queue".
emptyText string | null null The empty state title. Null uses "Nothing left in the queue". The empty slot replaces the whole empty state.
emptyDescription string | null null A second line under the empty state title.
shown int | null null The rows on this page, for the footer. Null uses the data-mode row count (or total).
total int | null null The size of the whole queue. Set, the footer shows "Showing :shown of :total" (IC-016); hiding rows counts both down.
id mixed | null null Declared by @props in the registry Blade source.
count mixed | null null Declared by @props in the registry Blade source.
level int 3 Declared by @props in the registry Blade source.

Slots

  • default — Slot mode: queue-list.group children holding queue-row children (a group without a label is a plain row list). Ignored when groups or rows is set.
  • empty — Your own empty state (for example a message and a next step). It is focusable (tabindex=-1) and shown when the queue has no rows or after the last row is hidden.
  • footer — Footer actions, for example a Load more button or pagination, at the logical end of the dataset boundary footer.
  • x-ui.queue-list.group — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

queue-list queue-list-count queue-list-empty queue-list-footer queue-list-footer-actions queue-list-group queue-list-group-count queue-list-group-count-label queue-list-group-count-value queue-list-group-empty queue-list-group-header queue-list-group-name queue-list-group-rows queue-list-status

Behavior

  • <x-ui.queue-list.group id label count empty-text level>: one group. With a label, a sticky header (top-0, above the rows) holds a heading (level 2 to 6, default 3) and the count; the rows are a <ul role="list"> labelled by the heading. An empty group prints "No items" (empty-text).
  • Keyboard (the shared list-cursor module), only while focus is in the list and not in a text field or a menu, listbox, dialog, combobox or tablist, and without Ctrl, Meta or Alt: j or ArrowDown and k or ArrowUp move the cursor across groups; Home and End go to the first and last visible row; ArrowLeft and ArrowRight move between the controls of the cursor row, mirrored under dir="rtl"; Enter opens the row (from the checkbox too); x toggles the row's selection; Shift+J, Shift+K, Shift+ArrowDown and Shift+ArrowUp extend the selection from an anchor row (press back to shrink it); Escape clears the selection. Handled keys call preventDefault (IC-007).
  • Roving tabindex: only the cursor row's link has tabindex=0, so Tab enters the queue once and leaves it.
  • Triage API, on the Alpine scope and on the element as el.queueList: hide(ids) hides the rows at once (hidden, data-hidden), drops them from the selection (queue-selection-change), moves the cursor to the next visible row (else the previous), moves focus there when the queue had it, counts the group and footer down, hides a group with no rows left, and announces ":count items removed"; it returns the new cursor id. restore(ids) shows the rows again, puts the cursor on the first of them (focus follows when the queue had it) and announces ":count items restored" (undo, or a failed request, IC-009). nextAfter(ids) returns the id hide(ids) would move the cursor to. focusEmpty() reveals and focuses the empty state. Hidden ids survive a Livewire morph until restore().
  • When the last visible row is hidden, queue-empty {} fires, the empty state shows, and focus moves to it when the queue had focus.
  • Cursor API, on the Alpine scope and on el.queueList (the shared list-cursor focusId and step): focusId(id, { focus? }) puts the cursor on the visible row with that id and move(delta, { focus? }) moves it delta visible rows from the cursor row, clamped at the first and last row; el.queueList.cursor() returns the cursor id. Both moves emit queue-focus when the cursor changed and return the cursor id (null when no visible row matches). Focus follows when the queue holds focus, or always with { focus: true }; otherwise the row scrolls into view and focus stays where it is (for example in a preview pane), so a page shortcut never forwards key presses to the list.
  • setCurrent(id, token?) marks the record that is open: aria-current ("true", or page, step, location, date or time) on its link and data-current on the row (queue-row's current style); null clears it. Before the first call the server-rendered current row prop stands; after it the queue owns the mark and puts it back after a Livewire morph.
  • Selection API (a selectable queue), on the Alpine scope and on el.queueList, each returning the selected ids: toggleSelection(id?, selected?) selects (true), clears (false) or flips (null, the default) one row, the cursor row when id is null, like x (a row whose checkbox is disabled stays out, a hidden row is ignored); extendSelection(delta, { focus? }) is Shift+J/K without a key press: the cursor moves delta visible rows (clamped) and every row from the anchor to it is selected, and repeated calls grow or shrink the same range until another cursor move starts a new one; clearSelection() clears it, like Escape. Each change emits queue-selection-change { ids } and is announced. Focus follows extendSelection as for focusId; a non-selectable queue ignores all three.
  • Window events, for a Livewire $this->dispatch(...) or a shortcut elsewhere on the page: queue-list-focus { id, focus? }, queue-list-move { delta, focus? }, queue-list-current { id, token? }, queue-list-toggle { id?, selected? }, queue-list-extend { delta, focus? } and queue-list-clear {}. With list in the detail only the queue whose element id equals it acts (give the queue an id); without list every queue on the page acts. The listeners are removed on destroy.
  • Events (bubbling CustomEvents on the root): queue-focus { id } when the cursor moves to another row; queue-open { id, href } (cancelable) on a plain click or Enter on a row link, where preventDefault() stops the navigation (a modified or middle click keeps the browser's new-tab behaviour); queue-selection-change { ids }; queue-empty {}.
  • Binding: x-modelable exposes the selected ids (qlSelected), so wire:model="selected" or x-model binds them; setting the outer value updates the checkboxes.
  • Announcements (IC-014) go through window.UI.announce(message, { politeness: 'polite' }) when the runtime provides it, else through the list's own visually hidden role="status" region: hides, restores and selection counts. There is no motion; row colour transitions are removed under prefers-reduced-motion and scrolling is instant.
  • persist (IC-011): the cursor row, the window scroll and whether the queue had focus are saved in sessionStorage on every cursor move and on pagehide. On a back or forward visit the list restores the cursor row and the scroll, and puts focus on the row when the user left the page from inside the queue; on a reload or a first visit only the cursor row. A Livewire SPA visit (wire:navigate) is never treated as a back or forward visit. A back-forward-cache restore puts focus back on the cursor row only when the queue had it.
  • Initial focus: the queue never takes focus on page load, so the first Tab still reaches the skip link and Tab enters the queue on the cursor row. Do not focus a row from your own init code (an autofocus on load skips the skip link and paints a focus ring nobody asked for); bind page shortcuts instead, for example j and k through hotkeys calling move(delta, { focus: true }), so focus moves after the user's first key press. The same holds for a search field: give it x-hotkey="/" rather than focusing it on load.
  • Dataset boundary (IC-016): one page renders at most 500 data rows (more throws an InvalidArgumentException); page larger queues on the server and pass shown and total for the "Showing :shown of :total" footer, with a footer slot for Load more.
  • Visual pattern: the list follows VC-005 (table and list pattern: labelled list, sticky group headers, empty state, dataset boundary). Components cannot declare visualContractIds, so the contract is recorded here.
  • Without JavaScript the rows are plain links, the checkboxes post as name[], and the empty state and footer are server-rendered (IC-013).
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, behaviorTest, authoredStateFixtures, responsive, rtl, darkMode, localized.

Guidance

Scannable data display

Present data for rapid scanning.

Use when

  • Use to summarize, sequence, or present data so users can scan it quickly.
  • A queue of records that someone works through one by one with the keyboard: an inbox, a review or approval queue, a moderation queue, a support queue.
  • Optimistic triage: hide the row the moment the user acts (archive, approve, dismiss), move the cursor to the next row, and restore it on undo or a failed request.
  • Bulk selection bound to a Livewire array with wire:model, or posted as name[] from a plain form.

Avoid when

  • Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
  • Work tracker issues grouped by workflow state with collapsible sub-groups: use <x-ui.issue-list>.
  • Sortable columns or inline cell editing: use <x-ui.data-table> or <x-ui.livewire-data-table>.
  • More than 500 rows at once: page the queue on the server and pass shown and total (the component throws above 500 data rows).

Use instead

  • Table for exact comparison
  • Plain text for a single value

Anti-patterns

  • Adding display ornament without informational value
Anatomy
queue-list queue-list-status queue-list-group queue-list-group-header queue-list-group-name queue-list-group-count queue-list-group-rows queue-list-group-empty queue-list-empty queue-list-footer queue-list-count queue-list-footer-actions queue-row
Theming hooks
queue-row queue-list

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
managed
  • The queue is a role="group" with an accessible name and a visually hidden description of its keys. Groups are headings with a <ul role="list"> labelled by them; counts have a spoken form.
  • Single-key shortcuts (j, k, x) act only while focus is inside the queue (WCAG 2.1.4). Focus never drops to the page after a hide: it goes to the next row or to the empty state.
  • Selection is carried by each row's named checkbox, with data-selected for styling; changes are announced politely.
  • The open record is exposed with aria-current on its link, not only by colour; moving the cursor from code never steals focus from a preview pane unless { focus: true } asks for it.
  • Nothing takes focus on page load: the skip link stays the first Tab stop, and the row focus ring shows only for :focus-visible.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="queue-list-{{ $record->id }}">
    {{-- Grouped data mode with sticky headers and counts, the current (open) row, a dataset boundary footer and persist (the cursor row survives back and forward). --}}
    <div class="w-full max-w-2xl">
        <x-ui.queue-list
            :label="__('Inbox')"
            persist="preview-inbox"
            :total="42"
            :groups="[
                ['id' => 'today', 'name' => __('Today'), 'rows' => [
                    ['id' => 'msg-1', 'href' => '#msg-1', 'title' => __('Refund request for order 10482'), 'meta' => __('Ada Lovelace: The parcel arrived damaged, photos attached.'), 'avatar' => ['name' => 'Ada Lovelace'], 'badges' => [['label' => __('Billing'), 'tone' => 'warning']], 'time' => '09:41', 'unread' => true, 'current' => true],
                    ['id' => 'msg-2', 'href' => '#msg-2', 'title' => __('New comment on the Q3 roadmap'), 'meta' => __('Grace Hopper replied in Planning'), 'avatar' => ['name' => 'Grace Hopper'], 'time' => '08:15', 'unread' => true],
                    ['id' => 'msg-3', 'href' => '#msg-3', 'title' => __('Access request for the finance workspace'), 'meta' => __('Alan Turing asks for editor access'), 'avatar' => ['name' => 'Alan Turing'], 'badges' => [__('Access')], 'time' => '07:02'],
                ]],
                ['id' => 'earlier', 'name' => __('Earlier'), 'rows' => [
                    ['id' => 'msg-4', 'href' => '#msg-4', 'title' => __('Flagged post in Community'), 'meta' => __('Reported twice for spam'), 'badges' => [['label' => __('Spam'), 'tone' => 'destructive']], 'time' => __('Mon')],
                    ['id' => 'msg-5', 'href' => '#msg-5', 'title' => __('Invoice 2026-118 is ready for approval'), 'meta' => __('Northwind Traders'), 'avatar' => ['name' => 'Northwind Traders'], 'time' => __('Sun')],
                    ['id' => 'msg-6', 'href' => '#msg-6', 'title' => __('Weekly digest'), 'meta' => __('12 updates in your projects'), 'time' => __('Sat')],
                ]],
            ]"
        >
            <x-slot:footer>
                <x-ui.button variant="ghost" size="sm">{{ __('Load more') }}</x-ui.button>
            </x-slot:footer>
        </x-ui.queue-list>
    </div>
</div>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/queue-list.blade.php Blade
{{--
    Queue List: a keyboard triage queue for any record type (an inbox, a
    review queue, a moderation queue).

    Three ways to fill it. Data: pass `groups` (each with rows) or `rows`
    (one ungrouped list); the list renders every group and row with wire:key.
    Slots: write queue-list.group children that hold queue-row children
    (a group without a label is a plain row list).

    The rows stay plain links, so the queue reads and navigates without
    JavaScript. The behaviour (the shared list-cursor module) adds a roving
    tabindex, j/k and arrow keys, Enter to open, x to select, Shift+J/K to
    extend the selection, Escape to clear it, and the triage API: hide(ids)
    removes rows optimistically and moves the cursor to the next row,
    restore(ids) brings them back (undo, or a failed request), nextAfter(ids)
    and focusEmpty(). Code moves the cursor with focusId(id) and move(delta)
    and marks the open record with setCurrent(id), or through the window
    events queue-list-focus, queue-list-move and queue-list-current. The
    selection has toggleSelection(id?), extendSelection(delta) and
    clearSelection() (events queue-list-toggle, -extend and -clear).
    Nothing takes focus on page load, so the first Tab reaches a skip link.
--}}
@props([
    // [{ id, name, count?, rows: [queue-row props as an array] }]
    'groups' => [],
    // One ungrouped list of rows (queue-row props as arrays). Ignored when groups is set.
    'rows' => [],
    'selectable' => false,
    // The ids selected at first paint.
    'selected' => [],
    // Selection checkboxes post as name[] in a plain form.
    'name' => null,
    // A sessionStorage key: keeps the cursor row and the scroll position across back and forward navigation.
    'persist' => null,
    // The accessible name of the queue.
    'label' => null,
    'emptyText' => null,
    'emptyDescription' => null,
    // Dataset boundary: the rows on this page and the size of the whole queue.
    'shown' => null,
    'total' => null,
])

@php
    // The largest queue rendered in one page. Page larger queues on the server.
    $maxRows = 500;
    $selectable = filter_var($selectable, FILTER_VALIDATE_BOOLEAN);
    $selectedIds = array_values(array_map('strval', array_filter((array) $selected, 'is_scalar')));
    $groups = array_values(array_filter((array) $groups, 'is_array'));
    if ($groups === [] && array_filter((array) $rows, 'is_array') !== []) {
        $groups = [['id' => null, 'name' => null, 'rows' => $rows]];
    }
    $groupRows = fn (array $group): array => array_values(array_filter((array) ($group['rows'] ?? []), 'is_array'));
    $rowCount = array_sum(array_map(fn (array $group): int => count($groupRows($group)), $groups));
    if ($rowCount > $maxRows) {
        throw new \InvalidArgumentException("queue-list renders at most {$maxRows} rows; got {$rowCount}. Page the queue on the server and pass shown and total.");
    }
    $label = filled($label) ? (string) $label : __('Queue');
    $emptyText = filled($emptyText) ? (string) $emptyText : __('Nothing left in the queue');
    $hintId = 'queue-list-hint-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(6));
    $hint = $selectable
        ? __('Use J and K or the arrow keys to move between items, Enter to open, X to select, Shift with J or K to extend the selection and Escape to clear it.')
        : __('Use J and K or the arrow keys to move between items and Enter to open.');
    $isEmpty = $groups === [] && $slot->isEmpty();
    $hasEmptySlot = isset($empty) && ! $empty->isEmpty();
    $hasFooter = isset($footer) && ! $footer->isEmpty();
    $shown = $shown !== null ? (int) $shown : ($groups !== [] ? $rowCount : null);
    $total = $total !== null ? (int) $total : null;
    $config = [
        'selectable' => $selectable,
        'selected' => $selectedIds,
        'persist' => filled($persist) ? (string) $persist : null,
        'messages' => [
            'hiddenOne' => __('1 item removed'),
            'hiddenMany' => __(':count items removed'),
            'restoredOne' => __('1 item restored'),
            'restoredMany' => __(':count items restored'),
            'selectedOne' => __('1 item selected'),
            'selectedMany' => __(':count items selected'),
            'cleared' => __('Selection cleared'),
            'countOne' => __(':count item'),
            'countMany' => __(':count items'),
            'showing' => __('Showing :shown of :total'),
            'empty' => $emptyText,
        ],
    ];
    $rowAttributes = function (array $row, int $index) use ($selectedIds): \Illuminate\View\ComponentAttributeBag {
        $rowId = (string) ($row['id'] ?? $index);

        return new \Illuminate\View\ComponentAttributeBag([
            'wire:key' => 'queue-'.$rowId,
            'id' => $rowId,
            'title' => $row['title'] ?? '',
            'href' => $row['href'] ?? null,
            'meta' => $row['meta'] ?? null,
            'avatar' => $row['avatar'] ?? null,
            'badges' => $row['badges'] ?? [],
            'time' => $row['time'] ?? null,
            'datetime' => $row['datetime'] ?? null,
            'media' => $row['media'] ?? [],
            'mediaMax' => $row['mediaMax'] ?? 3,
            'mediaTotal' => $row['mediaTotal'] ?? null,
            'mediaIndicator' => $row['mediaIndicator'] ?? false,
            'attachments' => $row['attachments'] ?? null,
            'current' => $row['current'] ?? false,
            'unread' => $row['unread'] ?? false,
            'hidden' => $row['hidden'] ?? false,
            'selected' => in_array($rowId, $selectedIds, true) || ($row['selected'] ?? false),
        ]);
    };
@endphp

<div
    data-slot="queue-list"
    role="group"
    aria-label="{{ $label }}"
    aria-describedby="{{ $hintId }}"
    @if ($selectable) data-selectable="true" @endif
    x-data="uiQueueList({{ \Illuminate\Support\Js::from($config) }})"
    x-modelable="qlSelected"
    {{ $attributes->merge(['class' => 'min-w-0 overflow-clip rounded-lg border border-border bg-background text-foreground']) }}
>
    <p id="{{ $hintId }}" class="sr-only">{{ $hint }}</p>
    {{-- Used only when the page has no UI.announce runtime. --}}
    <p data-slot="queue-list-status" role="status" aria-live="polite" aria-atomic="true" class="sr-only"></p>

    @if ($groups !== [])
        @foreach ($groups as $group)
            @php
                $rows = $groupRows($group);
                $groupName = $group['name'] ?? $group['label'] ?? null;
                $groupId = (string) ($group['id'] ?? (filled($groupName) ? \Illuminate\Support\Str::slug((string) $groupName) : 'group-'.$loop->index));
            @endphp
            <x-ui.queue-list.group
                wire:key="queue-group-{{ $groupId }}"
                :id="$groupId"
                :label="$groupName"
                :count="$group['count'] ?? (filled($groupName) ? count($rows) : null)"
                :empty-text="$group['emptyText'] ?? null"
            >
                @foreach ($rows as $row)
                    <x-ui.queue-row :attributes="$rowAttributes($row, $loop->index)">
                        @if (filled($row['end'] ?? null))
                            <x-slot:end>{{ $row['end'] }}</x-slot:end>
                        @endif
                    </x-ui.queue-row>
                @endforeach
            </x-ui.queue-list.group>
        @endforeach
    @else
        {{ $slot }}
    @endif

    {{-- Rendered always, so the behaviour can reveal it when the last row is handled. --}}
    <div
        data-slot="queue-list-empty"
        tabindex="-1"
        class="flex flex-col items-center gap-2 px-6 py-8 text-center text-sm outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-inset focus-visible:ring-ring"
        @if (! $isEmpty) hidden @endif
    >
        @if ($hasEmptySlot)
            {{ $empty }}
        @else
            <p class="font-medium text-foreground">{{ $emptyText }}</p>
            @if (filled($emptyDescription))
                <p class="text-muted-foreground">{{ $emptyDescription }}</p>
            @endif
        @endif
    </div>

    @if ($total !== null || $hasFooter)
        <div data-slot="queue-list-footer" class="flex min-h-11 min-w-0 flex-wrap items-center justify-between gap-2 border-t border-border px-4 py-2 text-xs text-muted-foreground">
            @if ($total !== null)
                <p data-slot="queue-list-count" data-shown="{{ $shown ?? $total }}" data-total="{{ $total }}" class="tabular-nums">{{ __('Showing :shown of :total', ['shown' => $shown ?? $total, 'total' => $total]) }}</p>
            @endif
            @if ($hasFooter)
                <div data-slot="queue-list-footer-actions" class="ms-auto inline-flex items-center gap-2">{{ $footer }}</div>
            @endif
        </div>
    @endif
</div>
resources/views/components/ui/queue-list/group.blade.php Blade
{{--
    Queue List Group: one group of a queue list (for example "Today",
    "Earlier" or "Needs review"), with a sticky header that shows its name
    and count. Without a label it renders only the row list, for a queue
    that is not grouped.

    The count is the number the header shows (for example a server total);
    when the behaviour hides rows it counts them down, and it hides a group
    whose rows are all hidden.
--}}
@props([
    'id' => null,
    'label' => null,
    // The number of items in the group. Null hides the count.
    'count' => null,
    'emptyText' => null,
    // The heading level of the group name (2 to 6).
    'level' => 3,
])

@php
    $label = filled($label) ? (string) $label : null;
    $groupId = (string) ($id ?? ($label !== null ? \Illuminate\Support\Str::slug($label) : 'queue'));
    $domId = 'queue-group-'.substr(md5($groupId), 0, 8).'-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(4));
    $level = min(6, max(2, (int) $level));
    $emptyText = filled($emptyText) ? (string) $emptyText : __('No items');
@endphp

<div
    data-slot="queue-list-group"
    data-group-id="{{ $groupId }}"
    @if ($count !== null) data-count="{{ (int) $count }}" @endif
    {{ $attributes->merge(['class' => 'min-w-0']) }}
>
    @if ($label !== null)
        <div data-slot="queue-list-group-header" class="sticky top-0 z-20 flex min-h-10 min-w-0 items-center gap-2 border-b border-border bg-muted px-4 text-sm">
            <h{{ $level }} id="{{ $domId }}-label" data-slot="queue-list-group-name" class="min-w-0 truncate font-medium text-foreground">{{ $label }}</h{{ $level }}>
            @if ($count !== null)
                <span data-slot="queue-list-group-count" class="shrink-0 text-muted-foreground tabular-nums">
                    <span aria-hidden="true" data-slot="queue-list-group-count-value">{{ (int) $count }}</span>
                    <span class="sr-only" data-slot="queue-list-group-count-label">{{ trans_choice(':count item|:count items', (int) $count, ['count' => (int) $count]) }}</span>
                </span>
            @endif
        </div>
    @endif
    <ul
        role="list"
        data-slot="queue-list-group-rows"
        @if ($label !== null) aria-labelledby="{{ $domId }}-label" @endif
        class="divide-y divide-border"
    >
        @if ($slot->isEmpty())
            <li data-slot="queue-list-group-empty" class="px-4 py-2 text-sm text-muted-foreground">{{ $emptyText }}</li>
        @else
            {{ $slot }}
        @endif
    </ul>
</div>
resources/js/ui/queue-list.js JS
/**
 * Queue List behaviour: a keyboard triage queue.
 *
 * The roving cursor, keys, selection and morph handling are the shared
 * list-cursor module (`createListCursor`). This file adds the triage API:
 *
 *   hide(ids)       optimistic removal: the rows get `hidden`, leave the
 *                   selection, the cursor moves to the next row (focus
 *                   follows when the list had it), group counts go down,
 *                   and the change is announced. Returns the new cursor id.
 *   restore(ids)    undo, or a failed request (IC-009): the rows come back,
 *                   the cursor goes to the first of them, and it is announced.
 *   nextAfter(ids)  the id the cursor would go to if those rows were hidden.
 *   focusEmpty()    reveals and focuses the empty state when no row is
 *                   visible (returns false otherwise).
 *   focusId(id)     moves the cursor to that row; focus follows when the list
 *                   holds focus (or with { focus: true }), else the row
 *                   scrolls into view. Returns the cursor id or null.
 *   move(delta)     moves the cursor delta rows (clamped), like j/k.
 *   setCurrent(id)  marks the open record (aria-current on its link,
 *                   data-current on the row); null clears it. From the first
 *                   call the list owns the mark, so a morph keeps it.
 *
 * Selection (a selectable queue), each returning the selected ids:
 *   toggleSelection(id?, selected?)  selects, clears or flips one row (the
 *                   cursor row by default), like x; a disabled checkbox
 *                   keeps its row out.
 *   extendSelection(delta, { focus? })  Shift+J/K without a key press: the
 *                   cursor moves delta rows and every row from the anchor to
 *                   it is selected; repeated calls grow or shrink the range.
 *   clearSelection() clears the selection, like Escape.
 *
 * The same are window events, for a Livewire dispatch or a shortcut
 * elsewhere on the page: queue-list-focus { id, focus? }, queue-list-move
 * { delta, focus? }, queue-list-current { id }, queue-list-toggle { id?,
 * selected? }, queue-list-extend { delta, focus? } and queue-list-clear {}.
 * With `list` in the detail only the queue whose element id matches acts;
 * without it every queue does.
 *
 * Nothing moves focus on page load, so the first Tab still reaches a skip
 * link: `persist` restores the cursor row on every visit and focus only on a
 * back or forward visit to a page the user left from inside the queue.
 *
 * They are methods of the Alpine scope and of the element
 * (`el.queueList.hide([...])`), so a Livewire listener or a script can reach
 * them. Hidden ids are kept, so a morph that renders the row again before the
 * server dropped it keeps it hidden until restore().
 *
 * Events (bubbling CustomEvents on the root): queue-focus { id },
 * queue-open { id, href } (cancelable), queue-selection-change { ids } and
 * queue-empty {} when the last visible row is hidden.
 *
 * Announcements use the runtime's UI.announce when present, else the item's
 * own visually hidden status region. `persist` keeps the cursor row and the
 * scroll position in sessionStorage, so back and forward navigation returns
 * to the same row (IC-011).
 *
 * Internal state is prefixed (`ql*`): an x-model on the root resolves in this
 * scope first, so a plain name would shadow the consumer's property.
 */
import { createListCursor, setAttr } from './list-cursor.js';

const ROW = '[data-slot="queue-row"]';
const GROUP = '[data-slot="queue-list-group"]';

function toIds(ids) {
    return (Array.isArray(ids) ? ids : [ids])
        .filter((id) => id !== null && id !== undefined)
        .map(String);
}

function fill(template, values) {
    return String(template ?? '').replace(/:(\w+)/g, (match, key) =>
        key in values ? String(values[key]) : match,
    );
}

// Window events a queue listens to (removed again on destroy).
const WINDOW_EVENTS = [
    'queue-list-focus',
    'queue-list-move',
    'queue-list-current',
    'queue-list-toggle',
    'queue-list-extend',
    'queue-list-clear',
];

// How the browser reached this document (navigate, reload, back_forward).
// A Livewire SPA visit (wire:navigate) keeps the document, so from the first
// one on the type no longer describes the visit and nothing is restored as a
// back or forward visit.
let documentVisit =
    typeof performance !== 'undefined'
        ? (performance.getEntriesByType?.('navigation')?.[0]?.type ?? null)
        : null;
document.addEventListener('livewire:navigate', () => {
    documentVisit = null;
});

function readSession(key) {
    try {
        const value = JSON.parse(window.sessionStorage.getItem(key) || 'null');

        return value && typeof value === 'object' ? value : null;
    } catch {
        return null;
    }
}

function writeSession(key, value) {
    try {
        window.sessionStorage.setItem(key, JSON.stringify(value));
    } catch {
        // Private mode or a full quota: the position simply is not kept.
    }
}

document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiQueueList', (config = {}) => {
        // Kept outside the reactive object: DOM nodes, the cursor controller
        // and listeners must not be wrapped in Alpine proxies.
        let root = null;
        let cursor = null;
        let hadRows = false;
        let wasEmpty = false;
        let onPageHide = null;
        let onPageShow = null;
        let onWindowCommand = null;
        // Whether focus was in the queue when the page was hidden (pagehide).
        let focusedAtHide = false;
        // undefined: the server-rendered current mark stands; set by setCurrent().
        let currentId;
        let currentToken = 'true';
        const hiddenIds = new Set();
        const messages = config.messages ?? {};
        const storageKey = config.persist ? `queue-list:${config.persist}` : null;

        return {
            qlSelected: Array.isArray(config.selected) ? config.selected.map(String) : [],

            init() {
                root = this.$el;
                if (this.qlSelected.length === 0 && config.selectable) {
                    this.qlSelected = Array.from(
                        root.querySelectorAll(`${ROW}[data-selected="true"]`),
                    )
                        .filter((row) => row.closest('[data-slot="queue-list"]') === root)
                        .map((row) => row.dataset.queueId);
                }
                hadRows = Boolean(root.querySelector(`${ROW}:not([hidden])`));
                const stored = storageKey ? readSession(storageKey) : null;

                cursor = createListCursor(root, {
                    owner: '[data-slot="queue-list"]',
                    row: ROW,
                    link: '[data-slot="queue-row-link"]',
                    id: 'data-queue-id',
                    select: '[data-slot="queue-row-select"] input[type="checkbox"]',
                    group: GROUP,
                    selectable: Boolean(config.selectable),
                    rangeSelect: true,
                    shiftArrows: true,
                    getSelected: () => this.qlSelected,
                    setSelected: (ids) => {
                        this.qlSelected = ids;
                    },
                    emit: (type, detail, cancelable) =>
                        this.qlEmit(`queue-${type}`, detail, cancelable),
                    observeAttributes: ['data-current'],
                    beforeSync: () => {
                        this.qlApplyHidden();
                        this.qlApplyCurrent();
                    },
                    afterSync: (state) => this.qlAfterSync(state),
                });
                this.$watch('qlSelected', () => cursor?.schedule());

                root.queueList = {
                    hide: (ids) => this.hide(ids),
                    restore: (ids) => this.restore(ids),
                    nextAfter: (ids) => this.nextAfter(ids),
                    focusEmpty: () => this.focusEmpty(),
                    focusId: (id, options) => this.focusId(id, options),
                    move: (delta, options) => this.move(delta, options),
                    setCurrent: (id, token) => this.setCurrent(id, token),
                    toggleSelection: (id, selected) => this.toggleSelection(id, selected),
                    extendSelection: (delta, options) => this.extendSelection(delta, options),
                    clearSelection: () => this.clearSelection(),
                    selected: () => [...this.qlSelected],
                    cursor: () => cursor?.cursor ?? null,
                };

                onWindowCommand = (event) => {
                    const detail =
                        event.detail && typeof event.detail === 'object'
                            ? event.detail
                            : { id: event.detail };
                    if (detail.list != null && String(detail.list) !== root?.id) return;
                    const options = detail.focus == null ? {} : { focus: Boolean(detail.focus) };
                    if (event.type === 'queue-list-focus') this.focusId(detail.id, options);
                    else if (event.type === 'queue-list-move') this.move(detail.delta, options);
                    else if (event.type === 'queue-list-toggle')
                        this.toggleSelection(detail.id ?? null, detail.selected ?? null);
                    else if (event.type === 'queue-list-extend')
                        this.extendSelection(detail.delta ?? 1, options);
                    else if (event.type === 'queue-list-clear') this.clearSelection();
                    else this.setCurrent(detail.id ?? null, detail.token);
                };
                for (const type of WINDOW_EVENTS) window.addEventListener(type, onWindowCommand);

                if (storageKey) {
                    this.qlRestorePosition(stored);
                    onPageHide = () => {
                        focusedAtHide = this.qlHasFocus();
                        this.qlSavePosition();
                    };
                    onPageShow = (event) => {
                        // A back-forward-cache restore: focus returns only to a queue that had it.
                        if (event.persisted && focusedAtHide) this.qlFocusCursor();
                    };
                    window.addEventListener('pagehide', onPageHide);
                    window.addEventListener('pageshow', onPageShow);
                }
            },

            destroy() {
                if (onPageHide) window.removeEventListener('pagehide', onPageHide);
                if (onPageShow) window.removeEventListener('pageshow', onPageShow);
                onPageHide = null;
                onPageShow = null;
                if (onWindowCommand) {
                    for (const type of WINDOW_EVENTS)
                        window.removeEventListener(type, onWindowCommand);
                    onWindowCommand = null;
                }
                cursor?.destroy();
                cursor = null;
                if (root) delete root.queueList;
                root = null;
            },

            qlEmit(name, detail, cancelable = false) {
                if (!root) return true;
                const result = root.dispatchEvent(
                    new CustomEvent(name, { detail, bubbles: true, cancelable }),
                );
                if (name === 'queue-focus') this.qlSavePosition();
                if (name === 'queue-selection-change') {
                    const count = detail.ids.length;
                    this.qlAnnounce(
                        count === 0
                            ? messages.cleared
                            : fill(count === 1 ? messages.selectedOne : messages.selectedMany, {
                                  count,
                              }),
                    );
                }

                return result;
            },

            qlAnnounce(message) {
                if (!message || !root) return;
                if (typeof window.UI?.announce === 'function') {
                    window.UI.announce(message, { politeness: 'polite' });

                    return;
                }
                const region = root.querySelector('[data-slot="queue-list-status"]');
                if (!region) return;
                // Clear first so the same message twice is spoken twice.
                region.textContent = '';
                window.setTimeout(() => {
                    if (region.isConnected) region.textContent = message;
                }, 50);
            },

            qlRows() {
                return cursor ? cursor.rows() : [];
            },

            /** Writes `hidden` on every row whose id was hidden (a morph may have put it back). */
            qlApplyHidden() {
                if (!root) return;
                for (const row of root.querySelectorAll(ROW)) {
                    if (row.closest('[data-slot="queue-list"]') !== root) continue;
                    if (!hiddenIds.has(row.dataset.queueId)) continue;
                    if (!row.hidden) row.hidden = true;
                    setAttr(row, 'data-hidden', 'true');
                }
            },

            /** Writes the client-owned current mark (after setCurrent) on every row. */
            qlApplyCurrent() {
                if (!root || currentId === undefined) return;
                for (const row of root.querySelectorAll(ROW)) {
                    if (row.closest('[data-slot="queue-list"]') !== root) continue;
                    const on = currentId !== null && row.dataset.queueId === currentId;
                    setAttr(row, 'data-current', on ? 'true' : null);
                    setAttr(
                        row.querySelector('[data-slot="queue-row-link"]'),
                        'aria-current',
                        on ? currentToken : null,
                    );
                }
            },

            /** Moves the cursor to the row with this id; returns the cursor id or null. */
            focusId(id, options = {}) {
                return cursor ? cursor.focusId(id, options) : null;
            },

            /** Moves the cursor `delta` visible rows (clamped); returns the cursor id or null. */
            move(delta, options = {}) {
                return cursor ? cursor.step(delta, options) : null;
            },

            /**
             * Selects (true), clears (false) or flips (null) one row: the
             * cursor row when id is null. Only in a selectable queue, and
             * never a row whose checkbox is disabled. Returns the selected ids.
             */
            toggleSelection(id = null, selected = null) {
                if (!cursor || !config.selectable) return [...this.qlSelected];
                const target = id ?? cursor.cursor;
                const row =
                    target === null || target === undefined ? null : cursor.rowById(String(target));
                if (!row || !cursor.isVisible(row)) return [...this.qlSelected];
                const box = row.querySelector(
                    '[data-slot="queue-row-select"] input[type="checkbox"]',
                );
                if (box?.disabled) return [...this.qlSelected];
                cursor.toggle(
                    String(target),
                    selected === null || selected === undefined ? null : Boolean(selected),
                );

                return [...this.qlSelected];
            },

            /** Shift+J/K without a key press: extends the selection delta rows from the cursor. Returns the selected ids. */
            extendSelection(delta = 1, options = {}) {
                return cursor ? cursor.extendBy(delta, options) : [...this.qlSelected];
            },

            /** Clears the selection, like Escape. Returns the selected ids (an empty list). */
            clearSelection() {
                cursor?.clearSelection();

                return [...this.qlSelected];
            },

            /** Marks the open record (null clears it); token is the aria-current value. */
            setCurrent(id, token = 'true') {
                currentId = id === null || id === undefined ? null : String(id);
                currentToken = ['page', 'step', 'location', 'date', 'time'].includes(token)
                    ? token
                    : 'true';
                this.qlApplyCurrent();

                return currentId;
            },

            qlAfterSync({ rows, visible }) {
                if (!root) return;
                for (const group of root.querySelectorAll(GROUP)) {
                    const groupRows = rows.filter((row) => row.closest(GROUP) === group);
                    const hiddenCount = groupRows.filter((row) =>
                        hiddenIds.has(row.dataset.queueId),
                    ).length;
                    const shownCount = groupRows.length - hiddenCount;
                    const handled = hiddenCount > 0 && shownCount === 0;
                    if (group.hidden !== handled) group.hidden = handled;
                    if (group.dataset.count !== undefined) {
                        const count = Math.max(0, Number(group.dataset.count) - hiddenCount);
                        const value = group.querySelector(
                            '[data-slot="queue-list-group-count-value"]',
                        );
                        const label = group.querySelector(
                            '[data-slot="queue-list-group-count-label"]',
                        );
                        if (value && value.textContent !== String(count))
                            value.textContent = String(count);
                        const spoken = fill(count === 1 ? messages.countOne : messages.countMany, {
                            count,
                        });
                        if (label && messages.countOne && label.textContent !== spoken)
                            label.textContent = spoken;
                    }
                }

                const counter = root.querySelector('[data-slot="queue-list-count"]');
                if (counter && messages.showing) {
                    const gone = hiddenIds.size;
                    const text = fill(messages.showing, {
                        shown: Math.max(0, Number(counter.dataset.shown) - gone),
                        total: Math.max(0, Number(counter.dataset.total) - gone),
                    });
                    if (counter.textContent !== text) counter.textContent = text;
                }

                if (visible.length > 0) hadRows = true;
                const empty = root.querySelector('[data-slot="queue-list-empty"]');
                const isEmpty = visible.length === 0 && hadRows;
                if (empty && isEmpty && empty.hidden) empty.hidden = false;
                if (empty && visible.length > 0 && !empty.hidden) empty.hidden = true;
                if (isEmpty && !wasEmpty) {
                    wasEmpty = true;
                    root.dispatchEvent(
                        new CustomEvent('queue-empty', { detail: {}, bubbles: true }),
                    );
                    // The morph (or a hide) dropped the focused row: focus the empty state.
                    const focused = document.activeElement;
                    if (cursor?.holdsFocus && (!focused || focused === document.body))
                        this.focusEmpty();
                } else if (!isEmpty) {
                    wasEmpty = false;
                }
            },

            /** The id the cursor would go to if `ids` were hidden (the cursor itself when it stays). */
            nextAfter(ids) {
                if (!cursor) return null;
                const gone = new Set(toIds(ids));
                const visible = cursor.visibleRows();
                const current = cursor.cursor;
                const index = visible.findIndex((row) => row.dataset.queueId === current);
                if (index === -1)
                    return (
                        visible.find((row) => !gone.has(row.dataset.queueId))?.dataset.queueId ??
                        null
                    );
                if (!gone.has(current)) return current;
                const after = visible
                    .slice(index + 1)
                    .find((row) => !gone.has(row.dataset.queueId));
                const before = visible
                    .slice(0, index)
                    .reverse()
                    .find((row) => !gone.has(row.dataset.queueId));

                return (after ?? before)?.dataset.queueId ?? null;
            },

            hide(ids) {
                if (!cursor || !root) return null;
                const known = new Set(this.qlRows().map((row) => row.dataset.queueId));
                const targets = toIds(ids).filter((id) => known.has(id) && !hiddenIds.has(id));
                if (targets.length === 0) return cursor.cursor;
                const hadFocus = root.contains(document.activeElement) || cursor.holdsFocus;
                const next = this.nextAfter(targets);
                for (const id of targets) hiddenIds.add(id);

                const selected = this.qlSelected.filter((id) => !targets.includes(id));
                if (selected.length !== this.qlSelected.length) {
                    this.qlSelected = selected;
                    root.dispatchEvent(
                        new CustomEvent('queue-selection-change', {
                            detail: { ids: [...selected] },
                            bubbles: true,
                        }),
                    );
                }

                if (next && next !== cursor.cursor) cursor.setCursor(next);
                cursor.sync();
                if (hadFocus) {
                    const row = next ? cursor.rowById(next) : null;
                    if (row) cursor.linkOf(row)?.focus({ preventScroll: false });
                    else this.focusEmpty();
                }
                this.qlAnnounce(
                    fill(targets.length === 1 ? messages.hiddenOne : messages.hiddenMany, {
                        count: targets.length,
                    }),
                );

                return next;
            },

            restore(ids) {
                if (!cursor || !root) return;
                const targets = toIds(ids).filter((id) => hiddenIds.has(id));
                if (targets.length === 0) return;
                const hadFocus = root.contains(document.activeElement) || cursor.holdsFocus;
                for (const id of targets) {
                    hiddenIds.delete(id);
                    const row = cursor.rowById(id);
                    if (row) {
                        row.hidden = false;
                        setAttr(row, 'data-hidden', null);
                    }
                }
                const first = cursor.rows().find((row) => targets.includes(row.dataset.queueId));
                if (first) cursor.setCursor(first);
                cursor.sync();
                if (hadFocus && first) cursor.linkOf(first)?.focus();
                this.qlAnnounce(
                    fill(targets.length === 1 ? messages.restoredOne : messages.restoredMany, {
                        count: targets.length,
                    }),
                );
            },

            /** Reveals and focuses the empty state; false while visible rows remain. */
            focusEmpty() {
                const empty = root?.querySelector('[data-slot="queue-list-empty"]');
                if (!empty || (cursor && cursor.visibleRows().length > 0)) return false;
                empty.hidden = false;
                empty.focus({ preventScroll: true });

                return document.activeElement === empty;
            },

            qlFocusCursor() {
                const row = cursor?.cursorRow();
                if (row) cursor.linkOf(row)?.focus({ preventScroll: true });
            },

            qlSavePosition() {
                if (!storageKey || !cursor) return;
                writeSession(storageKey, {
                    cursor: cursor.cursor,
                    scroll: Math.round(window.scrollY),
                    focused: this.qlHasFocus(),
                });
            },

            /** True while focus is in the queue (also after a morph dropped it). */
            qlHasFocus() {
                return Boolean(
                    root && cursor && (cursor.holdsFocus || root.contains(document.activeElement)),
                );
            },

            /**
             * Back or forward (or a reload) to this page: the same cursor row;
             * after a back or forward visit also the scroll, and focus when the
             * user left the page from inside the queue. Any other visit leaves
             * focus alone, so the first Tab still reaches a skip link.
             */
            qlRestorePosition(stored) {
                if (!stored || !cursor) return;
                const row = stored.cursor ? cursor.rowById(stored.cursor) : null;
                if (!row || !cursor.isVisible(row)) return;
                cursor.setCursor(row, { silent: true });
                cursor.sync();
                if (documentVisit !== 'back_forward') return;
                window.requestAnimationFrame(() => {
                    if (!root) return;
                    if (Number.isFinite(stored.scroll))
                        window.scrollTo({ top: stored.scroll, behavior: 'instant' });
                    // Entries saved before 1.2.0 have no focused flag: they keep the old behaviour.
                    if (stored.focused !== false) this.qlFocusCursor();
                });
            },
        };
    });
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.2.0
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0