Issues
Work tracker issues: one dense row with priority, key, state, labels, estimate, due date and assignee, and a grouped list with a keyboard cursor and selection.
Issue List — Issue rows in collapsible groups (for example by workflow state) for a keyboard-first work tracker: j/k and arrow keys move a roving cursor across groups, Enter opens, x selects, Escape clears. Groups have a sticky header with a disclosure toggle, state icon, count and an add button. Selection binds with wire:model; events issue-open, issue-focus, issue-selection-change and issue-add.
Preview
Use J and K or the arrow keys to move between issues and Enter to open.
- Priority: Urgent ENG-142 Status: In progress Sign-in fails when the session cookie has expired Labels:
- Priority: High ENG-139 Status: In progress Move invoice export to a queued job Labels:
- Priority: Medium ENG-131 Status: In progress Add keyboard shortcuts to the command menu Labels:
- Priority: Low ENG-128 Status: In review Show the billing address on credit notes Labels:
- Priority: High ENG-125 Status: In review Round VAT per rate instead of per line Labels:
- Priority: No priority ENG-150 Status: Todo Write the upgrade guide for version 3 Labels:
- Priority: High ENG-151 Status: Todo Rate limit the public search endpoint Labels:
- Priority: Low ENG-153 Status: Todo Empty state for the reports page Labels:
{{-- Slot mode: groups hold rows. Key every group and row for Livewire. --}}
<div class="w-full max-w-4xl">
<x-ui.issue-list :label="__('Engineering issues')">
<x-ui.issue-list.group id="started" wire:key="issue-group-started" :label="__('In progress')" state="started" :count="3" addable>
<x-ui.issue-row wire:key="issue-ENG-142" id="ENG-142" key="ENG-142" href="#ENG-142" :title="__('Sign-in fails when the session cookie has expired')" state="started" :progress="0.6" priority="urgent" :labels="[['name' => __('Bug'), 'tone' => 'destructive'], ['name' => __('Auth'), 'tone' => 'info']]" estimate="3" :due="now()->subDays(2)->toDateString()" :assignee="['name' => 'Ada Lovelace']" />
<x-ui.issue-row wire:key="issue-ENG-139" id="ENG-139" key="ENG-139" href="#ENG-139" :title="__('Move invoice export to a queued job')" state="started" :progress="0.25" priority="high" :labels="[['name' => __('Performance'), 'tone' => 'warning']]" estimate="5" :due="now()->toDateString()" :assignee="['name' => 'Grace Hopper']" />
<x-ui.issue-row wire:key="issue-ENG-131" id="ENG-131" key="ENG-131" href="#ENG-131" :title="__('Add keyboard shortcuts to the command menu')" state="started" priority="medium" :labels="[['name' => __('Feature'), 'tone' => 'success'], __('Web'), __('Accessibility')]" estimate="2" :due="now()->addDays(6)->toDateString()" />
</x-ui.issue-list.group>
<x-ui.issue-list.group id="review" wire:key="issue-group-review" :label="__('In review')" state="review" :count="2" collapsed addable>
<x-ui.issue-row wire:key="issue-ENG-128" id="ENG-128" key="ENG-128" href="#ENG-128" :title="__('Show the billing address on credit notes')" state="review" priority="low" :labels="[['name' => __('Billing'), 'tone' => 'info']]" estimate="1" :assignee="['name' => 'Alan Turing']" />
<x-ui.issue-row wire:key="issue-ENG-125" id="ENG-125" key="ENG-125" href="#ENG-125" :title="__('Round VAT per rate instead of per line')" state="review" priority="high" :labels="[['name' => __('Bug'), 'tone' => 'destructive']]" estimate="2" :due="now()->addDay()->toDateString()" :assignee="['name' => 'Ada Lovelace']" />
</x-ui.issue-list.group>
<x-ui.issue-list.group id="unstarted" wire:key="issue-group-unstarted" :label="__('Todo')" state="unstarted" :count="3" addable>
<x-ui.issue-row wire:key="issue-ENG-150" id="ENG-150" key="ENG-150" href="#ENG-150" :title="__('Write the upgrade guide for version 3')" state="unstarted" priority="none" :labels="[['name' => __('Docs'), 'tone' => 'neutral']]" :due="now()->addDays(14)->toDateString()" />
<x-ui.issue-row wire:key="issue-ENG-151" id="ENG-151" key="ENG-151" href="#ENG-151" :title="__('Rate limit the public search endpoint')" state="unstarted" priority="high" :labels="[__('Backend'), ['name' => __('Security'), 'tone' => 'warning']]" estimate="3" :due="now()->subDays(5)->toDateString()" :assignee="['name' => 'Grace Hopper']" />
<x-ui.issue-row wire:key="issue-ENG-153" id="ENG-153" key="ENG-153" href="#ENG-153" :title="__('Empty state for the reports page')" state="unstarted" priority="low" :labels="[['name' => __('Design'), 'tone' => 'success']]" estimate="1" :assignee="['name' => 'Alan Turing']" />
</x-ui.issue-list.group>
</x-ui.issue-list>
</div>
Installation
php artisan ui:add issue-list
Note
This component ships an Alpine behavior module at
resources/js/ui/issue-list.js. Import it once from your bundle so it registers on alpine:init:
import './issue-list.js';
Registry contract
php artisan ui:add issue-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.
-
resources/views/components/ui/issue-list.blade.php -
resources/views/components/ui/issue-list/group.blade.php -
resources/js/ui/issue-list.js
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.
# Brok UI: Issue List (`issue-list`)
Issue rows in collapsible groups (for example by workflow state) for a keyboard-first work tracker: j/k and arrow keys move a roving cursor across groups, Enter opens, x selects, Escape clears. Groups have a sticky header with a disclosure toggle, state icon, count and an add button. Selection binds with wire:model; events issue-open, issue-focus, issue-selection-change and issue-add.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add issue-list
```
## Usage
```blade
{{-- Slot mode: groups hold rows. Key every group and row for Livewire. --}}
<div class="w-full max-w-4xl">
<x-ui.issue-list :label="__('Engineering issues')">
<x-ui.issue-list.group id="started" wire:key="issue-group-started" :label="__('In progress')" state="started" :count="3" addable>
<x-ui.issue-row wire:key="issue-ENG-142" id="ENG-142" key="ENG-142" href="#ENG-142" :title="__('Sign-in fails when the session cookie has expired')" state="started" :progress="0.6" priority="urgent" :labels="[['name' => __('Bug'), 'tone' => 'destructive'], ['name' => __('Auth'), 'tone' => 'info']]" estimate="3" :due="now()->subDays(2)->toDateString()" :assignee="['name' => 'Ada Lovelace']" />
<x-ui.issue-row wire:key="issue-ENG-139" id="ENG-139" key="ENG-139" href="#ENG-139" :title="__('Move invoice export to a queued job')" state="started" :progress="0.25" priority="high" :labels="[['name' => __('Performance'), 'tone' => 'warning']]" estimate="5" :due="now()->toDateString()" :assignee="['name' => 'Grace Hopper']" />
<x-ui.issue-row wire:key="issue-ENG-131" id="ENG-131" key="ENG-131" href="#ENG-131" :title="__('Add keyboard shortcuts to the command menu')" state="started" priority="medium" :labels="[['name' => __('Feature'), 'tone' => 'success'], __('Web'), __('Accessibility')]" estimate="2" :due="now()->addDays(6)->toDateString()" />
</x-ui.issue-list.group>
<x-ui.issue-list.group id="review" wire:key="issue-group-review" :label="__('In review')" state="review" :count="2" collapsed addable>
<x-ui.issue-row wire:key="issue-ENG-128" id="ENG-128" key="ENG-128" href="#ENG-128" :title="__('Show the billing address on credit notes')" state="review" priority="low" :labels="[['name' => __('Billing'), 'tone' => 'info']]" estimate="1" :assignee="['name' => 'Alan Turing']" />
<x-ui.issue-row wire:key="issue-ENG-125" id="ENG-125" key="ENG-125" href="#ENG-125" :title="__('Round VAT per rate instead of per line')" state="review" priority="high" :labels="[['name' => __('Bug'), 'tone' => 'destructive']]" estimate="2" :due="now()->addDay()->toDateString()" :assignee="['name' => 'Ada Lovelace']" />
</x-ui.issue-list.group>
<x-ui.issue-list.group id="unstarted" wire:key="issue-group-unstarted" :label="__('Todo')" state="unstarted" :count="3" addable>
<x-ui.issue-row wire:key="issue-ENG-150" id="ENG-150" key="ENG-150" href="#ENG-150" :title="__('Write the upgrade guide for version 3')" state="unstarted" priority="none" :labels="[['name' => __('Docs'), 'tone' => 'neutral']]" :due="now()->addDays(14)->toDateString()" />
<x-ui.issue-row wire:key="issue-ENG-151" id="ENG-151" key="ENG-151" href="#ENG-151" :title="__('Rate limit the public search endpoint')" state="unstarted" priority="high" :labels="[__('Backend'), ['name' => __('Security'), 'tone' => 'warning']]" estimate="3" :due="now()->subDays(5)->toDateString()" :assignee="['name' => 'Grace Hopper']" />
<x-ui.issue-row wire:key="issue-ENG-153" id="ENG-153" key="ENG-153" href="#ENG-153" :title="__('Empty state for the reports page')" state="unstarted" priority="low" :labels="[['name' => __('Design'), 'tone' => 'success']]" estimate="1" :assignee="['name' => 'Alan Turing']" />
</x-ui.issue-list.group>
</x-ui.issue-list>
</div>
```
## Props
- `groups` (array, default `[]`) — Data mode: a list of { id, name, state?, count?, collapsed?, addable?, addLabel?, emptyText?, rows: [issue-row props as an array, plus trailing?: string] }. count defaults to the row count; a row's state defaults to its group's state. Every group and row gets wire:key. Empty uses the default slot. A group may hold groups: [...] (sub-groups with the same keys and their own rows) instead of rows: it renders them as nested groups and its count defaults to the sum of theirs. A sub-group without an id gets <parent id>-<slug of its name>.
- `properties` (list<priority|key|state|labels|estimate|due|assignee>|string|null, default `null`) — The display properties every row shows (see issue-row): rows inherit it through @aware, in data and slot mode, so the columns line up. Null shows them all.
- `pickers` (array<string, string|array{id: string, value?: mixed}>|string|null, default `null`) — The property triggers every row gets (see issue-row `pickers`): a map of property to property-picker id, or a prefix that names "<prefix>-<property>". Rows inherit it through @aware in data and slot mode, so the list sets it once; a row's own `pickers` (a data row's `pickers` key or the row prop) wins.
- `selectable` (bool, default `false`) — Adds the selection checkbox to every row (inherited through @aware) and enables x 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, so a plain form submits the selection without JavaScript.
- `persist` (string|null, default `null`) — A localStorage key (stored as issue-list:<persist>): which groups are collapsed is kept across visits. Null keeps it for the page's life only.
- `label` (string|null, default `null`) — The accessible name of the list (role="group"). Null uses "Issues".
- `emptyText` (string|null, default `null`) — The text when there are no groups and the slot is empty. Null uses "No issues to show". For a richer empty state, put your own markup in the default slot.
- `hint` (string|null, default `null`) — The keyboard help read with the list through aria-describedby. Null uses the translated default about issues (with X and Escape when selectable); pass your own when the rows are not issues, such as notification-row rows in an inbox.
- `stickyOffset` (string|null, default `null`) — Where the sticky group headers stick, as a CSS length or var(--name) (0px by default). In a scroll area with padding pass the negative padding (sticky-offset="-1.5rem") so a header meets the top edge and no rows show above it. Sets --issue-list-sticky-top on the root.
- `id` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `state` (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.
- `collapsed` (bool, default `false`) — Declared by @props in the registry Blade source.
- `addable` (bool, default `false`) — Declared by @props in the registry Blade source.
- `addLabel` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `level` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `nested` (bool, default `false`) — Declared by @props in the registry Blade source.
## Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- Listing issues, tasks or tickets grouped by state, assignee, project or cycle, with a keyboard cursor (j/k), Enter to open and x to select.
- Bulk selection of work items bound to a Livewire array with wire:model, or posted as name[] from a plain form.
- Collapsible groups whose open state should survive re-renders and, with persist, visits.
## Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- Sortable columns, pagination or inline cell editing: use <x-ui.data-table> or <x-ui.livewire-data-table>.
- A board of cards per state with drag between columns: use <x-ui.kanban>.
- A personal checklist: use <x-ui.todo-item> rows.
- Thousands of rows at once: page or window them on the server; every row is real DOM.
## Anti-patterns
- Adding display ornament without informational value
## Rules
- Use the `<brok:issue-list>` tag (or `<x-ui.issue-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/issue-list
- Registry JSON (files, props, contract): https://brokui.dev/r/open/issue-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
{{-- x-model (or wire:model) binds the selected ids. Keys do nothing while typing in the quick-add field. --}}
<div x-data="{ selected: ['WEB-12'] }" class="flex w-full max-w-4xl flex-col gap-4">
<x-ui.issue-list :label="__('Website issues')" selectable x-model="selected">
<x-ui.issue-list.group id="unstarted" :label="__('Todo')" state="unstarted" :count="3">
<x-slot:add>
<x-ui.input size="sm" :aria-label="__('New issue title')" :placeholder="__('New issue title')" class="w-48" />
</x-slot:add>
<x-ui.issue-row id="WEB-12" key="WEB-12" href="#WEB-12" :title="__('Compress hero images')" state="unstarted" priority="medium" estimate="1" />
<x-ui.issue-row id="WEB-14" key="WEB-14" href="#WEB-14" :title="__('Fix the footer links in dark mode')" state="unstarted" priority="low" :assignee="['name' => 'Ada Lovelace']" />
<x-ui.issue-row id="WEB-15" key="WEB-15" href="#WEB-15" :title="__('Add a cookie settings link')" state="unstarted" priority="high" :due="now()->addDays(2)->toDateString()" />
</x-ui.issue-list.group>
</x-ui.issue-list>
<p class="text-sm text-muted-foreground">
{{ __('Selected:') }} <span data-testid="issue-list-selected" x-text="selected.length ? selected.join(', ') : '-'"></span>
</p>
</div>
{{-- An empty list, an empty group, and a custom empty state with a next step. --}}
<div class="flex w-full max-w-4xl flex-col gap-6">
<x-ui.issue-list :label="__('Assigned to me')" />
<x-ui.issue-list :label="__('Current cycle')" :groups="[
['id' => 'started', 'name' => __('In progress'), 'state' => 'started', 'addable' => true, 'rows' => []],
]" />
{{-- Your own empty state goes in the default slot instead of groups. --}}
<x-ui.issue-list :label="__('Project issues')">
<div data-slot="issue-list-empty" class="flex flex-col items-center gap-2 px-6 py-8 text-center text-sm">
<p class="font-medium text-foreground">{{ __('No issues in this project yet') }}</p>
<x-ui.button size="sm">{{ __('Create issue') }}</x-ui.button>
</div>
</x-ui.issue-list>
</div>
Long Content
{{-- Long titles, many labels and a narrow frame: the title truncates, labels fold into +N and narrow rows drop parts. --}}
<div class="flex w-full flex-col gap-6">
@foreach (['max-w-4xl', 'max-w-sm'] as $width)
<div class="w-full {{ $width }}">
<x-ui.issue-list :label="__('Issues with long content')" selectable :groups="[
['id' => 'long', 'name' => __('A group name that keeps going well past the width of a narrow sidebar'), 'state' => 'started', 'count' => 1284, 'addable' => true, 'rows' => [
['id' => 'PLATFORM-10421', 'key' => 'PLATFORM-10421', 'href' => '#PLATFORM-10421', 'title' => __('Investigate why the nightly synchronisation of supplier price lists intermittently times out for accounts with more than twenty thousand products'), 'priority' => 'urgent', 'labels' => [['name' => __('Integrations'), 'tone' => 'info'], ['name' => __('Performance'), 'tone' => 'warning'], ['name' => __('Customer escalation'), 'tone' => 'destructive'], __('Backend'), __('Suppliers')], 'estimate' => '13', 'due' => now()->subDays(12)->toDateString(), 'assignee' => ['name' => 'Maximiliane Konstantinopoulou-Schwarzenberger']],
['id' => 'PLATFORM-10422', 'key' => 'PLATFORM-10422', 'href' => '#PLATFORM-10422', 'title' => 'https://example.com/a/very/long/url/that/has/no/spaces/and/must/not/overflow/the/row/at/any/width', 'state' => 'cancelled', 'priority' => 'none', 'due' => now()->addYear()->toDateString()],
]],
]" />
</div>
@endforeach
</div>
{{-- Property triggers set once: `pickers` on the list reaches every row
through @aware, in data and slot mode. A click, Enter or Space on a
property opens the property-picker with that id beside it
(property-picker-open { id, anchor, value, context: the row id }). --}}
@php
$groups = [
['id' => 'started', 'name' => __('In progress'), 'state' => 'started', 'rows' => [
['id' => 'ENG-142', 'key' => 'ENG-142', 'href' => '#ENG-142', 'title' => __('Sign-in fails when the session cookie has expired'), 'priority' => 'high', 'assignee' => ['id' => 7, 'name' => 'Ada Lovelace']],
['id' => 'ENG-139', 'key' => 'ENG-139', 'href' => '#ENG-139', 'title' => __('Show the export progress in the header'), 'priority' => 'low', 'assignee' => ['id' => 8, 'name' => 'Grace Hopper']],
]],
['id' => 'backlog', 'name' => __('Backlog'), 'state' => 'backlog', 'rows' => [
['id' => 'ENG-120', 'key' => 'ENG-120', 'href' => '#ENG-120', 'title' => __('Archive projects without activity for a year'), 'priority' => 'none'],
]],
];
@endphp
<div class="flex w-full max-w-4xl flex-col gap-2">
<x-ui.issue-list :label="__('Engineering issues')" :groups="$groups" properties="priority,key,state,assignee" pickers="list" />
<x-ui.property-picker id="list-state" detached :label="__('State')">
@foreach ([['backlog', __('Backlog')], ['started', __('In progress')], ['completed', __('Done')]] as [$value, $name])
<x-ui.property-picker.option :value="$value" :label="$name">
<x-slot:icon><x-ui.issue-row.state :state="$value" :label="$name" :announce="false" /></x-slot:icon>
</x-ui.property-picker.option>
@endforeach
</x-ui.property-picker>
<x-ui.property-picker id="list-priority" detached :label="__('Priority')">
@foreach ([['urgent', __('Urgent')], ['high', __('High')], ['low', __('Low')], ['none', __('No priority')]] as [$value, $name])
<x-ui.property-picker.option :value="$value" :label="$name">
<x-slot:icon><x-ui.issue-row.priority :priority="$value" :label="$name" /></x-slot:icon>
</x-ui.property-picker.option>
@endforeach
</x-ui.property-picker>
<x-ui.property-picker id="list-assignee" detached align="end" :label="__('Assignee')" :empty-label="__('Unassigned')">
<x-ui.property-picker.option value="" :label="__('Unassigned')" />
@foreach ([[7, 'Ada Lovelace'], [8, 'Grace Hopper']] as [$id, $person])
<x-ui.property-picker.option :value="$id" :label="$person" />
@endforeach
</x-ui.property-picker>
</div>
Selection
{{-- Data mode with selection: x selects, Escape clears, the checkboxes post as issues[]. --}}
@php
$groups = [
['id' => 'triage', 'name' => __('Triage'), 'state' => 'triage', 'addable' => true, 'rows' => [
['id' => 'OPS-31', 'key' => 'OPS-31', 'href' => '#OPS-31', 'title' => __('Disk usage alert on the reporting database'), 'priority' => 'urgent', 'labels' => [['name' => __('Incident'), 'tone' => 'destructive']], 'trailing' => __('5m ago')],
['id' => 'OPS-30', 'key' => 'OPS-30', 'href' => '#OPS-30', 'title' => __('Customer reports slow exports'), 'priority' => 'none', 'trailing' => __('1h ago')],
]],
['id' => 'started', 'name' => __('In progress'), 'state' => 'started', 'addable' => true, 'rows' => [
['id' => 'OPS-27', 'key' => 'OPS-27', 'href' => '#OPS-27', 'title' => __('Rotate the storage access keys'), 'progress' => 0.75, 'priority' => 'high', 'labels' => [['name' => __('Security'), 'tone' => 'warning']], 'estimate' => '2h', 'due' => now()->subDay()->toDateString(), 'assignee' => ['name' => 'Grace Hopper'], 'trailing' => __('2d ago')],
['id' => 'OPS-24', 'key' => 'OPS-24', 'href' => '#OPS-24', 'title' => __('Upgrade the queue workers'), 'progress' => 0.4, 'priority' => 'medium', 'estimate' => '4h', 'due' => now()->addDays(3)->toDateString(), 'assignee' => ['name' => 'Alan Turing'], 'trailing' => __('3d ago')],
]],
['id' => 'completed', 'name' => __('Done'), 'state' => 'completed', 'collapsed' => true, 'rows' => [
['id' => 'OPS-20', 'key' => 'OPS-20', 'href' => '#OPS-20', 'title' => __('Enable daily database backups'), 'priority' => 'high', 'due' => now()->subDays(4)->toDateString(), 'assignee' => ['name' => 'Ada Lovelace'], 'trailing' => __('1w ago')],
['id' => 'OPS-18', 'key' => 'OPS-18', 'href' => '#OPS-18', 'title' => __('Remove the legacy status page'), 'state' => 'cancelled', 'priority' => 'low', 'trailing' => __('2w ago')],
]],
];
@endphp
<form method="GET" action="#" class="flex w-full max-w-4xl flex-col gap-4">
<x-ui.issue-list :label="__('Operations issues')" :groups="$groups" selectable name="issues" :selected="['OPS-27']" persist="preview-operations" />
<div>
<x-ui.button type="submit" variant="outline" size="sm">{{ __('Submit selection') }}</x-ui.button>
</div>
</form>
Sub Groups
{{-- Two group levels: assignee, then workflow state. Each level collapses on its own; j and k skip hidden rows. --}}
@php
$groups = [
['id' => 'ada', 'name' => 'Ada Lovelace', 'groups' => [
['id' => 'ada-started', 'name' => __('In progress'), 'state' => 'started', 'rows' => [
['id' => 'ENG-142', 'key' => 'ENG-142', 'href' => '#ENG-142', 'title' => __('Sign-in fails when the session cookie has expired'), 'progress' => 0.6, 'priority' => 'urgent', 'labels' => [['name' => __('Bug'), 'tone' => 'destructive']], 'due' => now()->subDays(2)->toDateString()],
['id' => 'ENG-139', 'key' => 'ENG-139', 'href' => '#ENG-139', 'title' => __('Move invoice export to a queued job'), 'progress' => 0.25, 'priority' => 'high', 'labels' => [['name' => __('Performance'), 'tone' => 'warning']], 'due' => now()->toDateString()],
]],
['id' => 'ada-review', 'name' => __('In review'), 'state' => 'review', 'rows' => [
['id' => 'ENG-125', 'key' => 'ENG-125', 'href' => '#ENG-125', 'title' => __('Round VAT per rate instead of per line'), 'priority' => 'high', 'labels' => [['name' => __('Billing'), 'tone' => 'info']], 'due' => now()->addDay()->toDateString()],
]],
]],
['id' => 'grace', 'name' => 'Grace Hopper', 'groups' => [
['id' => 'grace-unstarted', 'name' => __('Todo'), 'state' => 'unstarted', 'rows' => [
['id' => 'ENG-151', 'key' => 'ENG-151', 'href' => '#ENG-151', 'title' => __('Rate limit the public search endpoint'), 'priority' => 'high', 'labels' => [__('Backend')], 'due' => now()->addDays(5)->toDateString()],
['id' => 'ENG-150', 'key' => 'ENG-150', 'href' => '#ENG-150', 'title' => __('Write the upgrade guide for version 3'), 'priority' => 'none', 'labels' => [['name' => __('Docs'), 'tone' => 'neutral']]],
]],
['id' => 'grace-completed', 'name' => __('Done'), 'state' => 'completed', 'collapsed' => true, 'rows' => [
['id' => 'ENG-120', 'key' => 'ENG-120', 'href' => '#ENG-120', 'title' => __('Enable daily database backups'), 'priority' => 'medium'],
]],
]],
];
@endphp
<div class="w-full max-w-4xl">
<x-ui.issue-list :label="__('Issues by assignee')" :groups="$groups" properties="priority,key,state,labels,due" />
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| groups | array | [] | Data mode: a list of { id, name, state?, count?, collapsed?, addable?, addLabel?, emptyText?, rows: [issue-row props as an array, plus trailing?: string] }. count defaults to the row count; a row's state defaults to its group's state. Every group and row gets wire:key. Empty uses the default slot. A group may hold groups: [...] (sub-groups with the same keys and their own rows) instead of rows: it renders them as nested groups and its count defaults to the sum of theirs. A sub-group without an id gets <parent id>-<slug of its name>. |
| properties | list<priority | key | state | labels | estimate | due | assignee> | string | null | null | The display properties every row shows (see issue-row): rows inherit it through @aware, in data and slot mode, so the columns line up. Null shows them all. |
| pickers | array<string, string | array{id: string, value?: mixed}> | string | null | null | The property triggers every row gets (see issue-row `pickers`): a map of property to property-picker id, or a prefix that names "<prefix>-<property>". Rows inherit it through @aware in data and slot mode, so the list sets it once; a row's own `pickers` (a data row's `pickers` key or the row prop) wins. |
| selectable | bool | false | Adds the selection checkbox to every row (inherited through @aware) and enables x 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, so a plain form submits the selection without JavaScript. |
| persist | string | null | null | A localStorage key (stored as issue-list:<persist>): which groups are collapsed is kept across visits. Null keeps it for the page's life only. |
| label | string | null | null | The accessible name of the list (role="group"). Null uses "Issues". |
| emptyText | string | null | null | The text when there are no groups and the slot is empty. Null uses "No issues to show". For a richer empty state, put your own markup in the default slot. |
| hint | string | null | null | The keyboard help read with the list through aria-describedby. Null uses the translated default about issues (with X and Escape when selectable); pass your own when the rows are not issues, such as notification-row rows in an inbox. |
| stickyOffset | string | null | null | Where the sticky group headers stick, as a CSS length or var(--name) (0px by default). In a scroll area with padding pass the negative padding (sticky-offset="-1.5rem") so a header meets the top edge and no rows show above it. Sets --issue-list-sticky-top on the root. |
| id | mixed | null | null | Declared by @props in the registry Blade source. |
| state | mixed | null | null | Declared by @props in the registry Blade source. |
| count | mixed | null | null | Declared by @props in the registry Blade source. |
| collapsed | bool | false | Declared by @props in the registry Blade source. |
| addable | bool | false | Declared by @props in the registry Blade source. |
| addLabel | mixed | null | null | Declared by @props in the registry Blade source. |
| level | mixed | null | null | Declared by @props in the registry Blade source. |
| nested | bool | false | Declared by @props in the registry Blade source. |
Slots
default— Slot mode: issue-list.group children holding issue-row children, or a <ul role="list"> of other rows that follow the list row contract (notification-row does). Ignored when groups is not empty. Any other content (for example your own empty state with a create action) renders as is, in place of the groups.x-ui.issue-list.group— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- <x-ui.issue-list.group id label state count collapsed addable add-label empty-text level nested>: one group. The header is a heading (level 2 to 6, default 3, or 4 when nested) wrapping a disclosure button with aria-expanded and aria-controls, the chevron, the state icon, the label and the count. The add slot (or addable, a "+" button named "Add issue to :group" that dispatches issue-add { group }) sits at the header end. An empty group prints "No issues" (empty-text).
- Sub-groups (a second group level, for example state inside assignee): a group with nested inside another group renders as an <li> of the parent's row list with data-nested, an indented header that sticks below the parent header and a heading one level deeper. Collapsing the parent hides its sub-groups; collapsing a sub-group hides only its rows. j/k, Home and End skip every hidden row. Group ids must be unique across both levels (the collapsed state and persist are keyed by id). One level of nesting is supported.
- Keyboard, only while focus is in the list and not in a text field, menu, listbox or dialog, and without Ctrl, Meta or Alt: j or ArrowDown and k or ArrowUp move the cursor to the next or previous visible row across groups (rows of a collapsed group are skipped; from a group header they go to the first row after it or the last row before it); Home and End go to the first and last visible row; ArrowRight and ArrowLeft move between the controls of the cursor row (checkbox, link, trailing controls), mirrored under dir="rtl"; Enter on the link opens it, and from the checkbox opens the row; x toggles the cursor row's selection when selectable; Shift+J and Shift+K (selectable lists) move the cursor and select every visible row from an anchor row to it, so a second press the other way shrinks the range (Shift+Arrow keys still only move); Escape clears the selection. Handled keys call preventDefault, which the hotkeys registry respects, so page shortcuts on the same keys do not also fire.
- The cursor, keys, selection and morph handling are the shared list-cursor module (createListCursor in list-cursor.js, installed with this item); issue-list adds the collapsible groups and the add button on top.
- Roving tabindex: only the cursor row's link has tabindex=0; the other rows' links, checkboxes and trailing controls have tabindex=-1, so Tab enters the rows once and leaves. Group header buttons stay in the tab order. Nothing is announced on a move: focus itself carries the row (its link is described by the row's parts).
- Events (bubbling CustomEvents on the root): issue-focus { id } when the cursor moves to another row; issue-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 and sends no event); issue-selection-change { ids: string[] } after the user changes the selection; issue-add { group } from an addable group's "+".
- Binding: x-modelable exposes the selected ids, so wire:model="selected" (a public array of string ids) or x-model binds them; setting the outer value updates the checkboxes. Internal Alpine state is prefixed il* so an outer variable named selected is not shadowed.
- Livewire: key every group and row (data mode does, with wire:key="issue-group-<id>" and wire:key="issue-<id>"). After a morph the behaviour re-applies the roving tabindex, collapsed groups and selection from its state; the cursor stays on the same row id, falls to the row now in its place when that row is gone. When the morph removed the focused node (focus fell to the body), focus returns to the same control found again by its id, else its data-anchor-key (so a row's property button keeps focus after a pick that re-rendered the row, whether its picker is a popover or a dialog), else to the cursor row's link. It does not take focus back while a modal makes the list inert, or when focus was last inside a widget in a row that restores focus itself (a menu, listbox, dialog or popover panel).
- Without JavaScript the rows are plain links, groups render in their collapsed prop state and the checkboxes post as name[].
- Other rows join the cursor through a generic row contract: data-list-row and data-row-id on the row, one link or button with data-row-link, and an optional label with data-row-select around the selection checkbox. notification-row follows it, so an inbox gets the same roving tab stop, j/k, arrows into the row's actions, x selection, issue-open and binding. A row link with aria-disabled="true" takes the cursor but opens nothing, and x skips a row whose checkbox is disabled.
- Long lists: put infinite-scroll straight after the list. New rows appended to a group (a Livewire render, or rows the host inserts) join the cursor at once through the MutationObserver, and focus stays where it was.
- 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
Present data for rapid scanning.
Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- Listing issues, tasks or tickets grouped by state, assignee, project or cycle, with a keyboard cursor (j/k), Enter to open and x to select.
- Bulk selection of work items bound to a Livewire array with wire:model, or posted as name[] from a plain form.
- Collapsible groups whose open state should survive re-renders and, with persist, visits.
Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- Sortable columns, pagination or inline cell editing: use <x-ui.data-table> or <x-ui.livewire-data-table>.
- A board of cards per state with drag between columns: use <x-ui.kanban>.
- A personal checklist: use <x-ui.todo-item> rows.
- Thousands of rows at once: page or window them on the server; every row is real DOM.
Use instead
- Table for exact comparison
- Plain text for a single value
Anti-patterns
- Adding display ornament without informational value
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
managed
- The list is a role="group" with an accessible name and a visually hidden description of its keys. Each group's rows are a <ul role="list"> labelled by its header button; a collapsed group's list is hidden, so its rows leave the tab order and the accessibility tree.
- Single-key shortcuts (j, k, x) act only while focus is inside the list (WCAG 2.1.4). The focus ring is the row link's inset ring token; the cursor row also gets an accent ground while the list has focus.
- Selection is carried by each row's named checkbox (checked state), with data-selected for styling. The chevron points down when open and to the inline end when collapsed, mirrored under dir="rtl".
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="issue-list-{{ $record->id }}">
{{-- Slot mode: groups hold rows. Key every group and row for Livewire. --}}
<div class="w-full max-w-4xl">
<x-ui.issue-list :label="__('Engineering issues')">
<x-ui.issue-list.group id="started" wire:key="issue-group-started" :label="__('In progress')" state="started" :count="3" addable>
<x-ui.issue-row wire:key="issue-ENG-142" id="ENG-142" key="ENG-142" href="#ENG-142" :title="__('Sign-in fails when the session cookie has expired')" state="started" :progress="0.6" priority="urgent" :labels="[['name' => __('Bug'), 'tone' => 'destructive'], ['name' => __('Auth'), 'tone' => 'info']]" estimate="3" :due="now()->subDays(2)->toDateString()" :assignee="['name' => 'Ada Lovelace']" />
<x-ui.issue-row wire:key="issue-ENG-139" id="ENG-139" key="ENG-139" href="#ENG-139" :title="__('Move invoice export to a queued job')" state="started" :progress="0.25" priority="high" :labels="[['name' => __('Performance'), 'tone' => 'warning']]" estimate="5" :due="now()->toDateString()" :assignee="['name' => 'Grace Hopper']" />
<x-ui.issue-row wire:key="issue-ENG-131" id="ENG-131" key="ENG-131" href="#ENG-131" :title="__('Add keyboard shortcuts to the command menu')" state="started" priority="medium" :labels="[['name' => __('Feature'), 'tone' => 'success'], __('Web'), __('Accessibility')]" estimate="2" :due="now()->addDays(6)->toDateString()" />
</x-ui.issue-list.group>
<x-ui.issue-list.group id="review" wire:key="issue-group-review" :label="__('In review')" state="review" :count="2" collapsed addable>
<x-ui.issue-row wire:key="issue-ENG-128" id="ENG-128" key="ENG-128" href="#ENG-128" :title="__('Show the billing address on credit notes')" state="review" priority="low" :labels="[['name' => __('Billing'), 'tone' => 'info']]" estimate="1" :assignee="['name' => 'Alan Turing']" />
<x-ui.issue-row wire:key="issue-ENG-125" id="ENG-125" key="ENG-125" href="#ENG-125" :title="__('Round VAT per rate instead of per line')" state="review" priority="high" :labels="[['name' => __('Bug'), 'tone' => 'destructive']]" estimate="2" :due="now()->addDay()->toDateString()" :assignee="['name' => 'Ada Lovelace']" />
</x-ui.issue-list.group>
<x-ui.issue-list.group id="unstarted" wire:key="issue-group-unstarted" :label="__('Todo')" state="unstarted" :count="3" addable>
<x-ui.issue-row wire:key="issue-ENG-150" id="ENG-150" key="ENG-150" href="#ENG-150" :title="__('Write the upgrade guide for version 3')" state="unstarted" priority="none" :labels="[['name' => __('Docs'), 'tone' => 'neutral']]" :due="now()->addDays(14)->toDateString()" />
<x-ui.issue-row wire:key="issue-ENG-151" id="ENG-151" key="ENG-151" href="#ENG-151" :title="__('Rate limit the public search endpoint')" state="unstarted" priority="high" :labels="[__('Backend'), ['name' => __('Security'), 'tone' => 'warning']]" estimate="3" :due="now()->subDays(5)->toDateString()" :assignee="['name' => 'Grace Hopper']" />
<x-ui.issue-row wire:key="issue-ENG-153" id="ENG-153" key="ENG-153" href="#ENG-153" :title="__('Empty state for the reports page')" state="unstarted" priority="low" :labels="[['name' => __('Design'), 'tone' => 'success']]" estimate="1" :assignee="['name' => 'Alan Turing']" />
</x-ui.issue-list.group>
</x-ui.issue-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.
{{--
Issue List: issue rows in collapsible groups with a keyboard cursor.
Two ways to fill it. Data: pass `groups` and the list renders every group
and row (with wire:key on each, for Livewire). Slots: write issue-list
group children that hold issue-row children yourself.
Sub-groups (a second group level): in data mode give a group `groups`
instead of `rows`; in slot mode nest groups with `nested` inside a group.
`properties` reaches every row through @aware, so all rows show the same
display properties and stay aligned. `pickers` reaches every row the
same way, so the list sets the property triggers once.
The rows stay plain links, so the list reads and navigates without
JavaScript. The behaviour adds a roving tabindex (Tab enters the list at
one row and leaves it), j/k and arrow keys, Enter to open, x to select,
Escape to clear, collapsible groups and x-modelable selection.
--}}
@props([
// [{ id, name, state?, count?, collapsed?, addable?, rows: [issue-row props] }]
// or, for sub-groups, [{ id, name, ..., groups: [{ id, name, ..., rows }] }]
'groups' => [],
// The display properties every row shows (see issue-row). Null: all.
'properties' => null,
// Property triggers every row gets (see issue-row `pickers`): a map of
// property => picker id, or a prefix. A row's own `pickers` wins.
'pickers' => null,
'selectable' => false,
// The ids selected at first paint.
'selected' => [],
// Selection checkboxes post as name[] in a plain form.
'name' => null,
// A localStorage key: keeps which groups are collapsed across visits.
'persist' => null,
// The accessible name of the list.
'label' => null,
'emptyText' => null,
// The keyboard help read with the list (aria-describedby). Null: the
// translated default about issues; pass your own for other rows.
'hint' => null,
// Where the sticky group headers stick, as a CSS length ('0px' by
// default). In a scroll area with padding pass the negative padding
// (sticky-offset="-1.5rem") so a header meets the top edge and rows do
// not show above it. Lengths and var(--name) only.
'stickyOffset' => null,
])
@php
$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'));
$label = filled($label) ? (string) $label : __('Issues');
$emptyText = filled($emptyText) ? (string) $emptyText : __('No issues to show');
$hintId = 'issue-list-hint-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(6));
$hint = filled($hint) ? (string) $hint : ($selectable
? __('Use J and K or the arrow keys to move between issues, Enter to open, X to select and Escape to clear the selection.')
: __('Use J and K or the arrow keys to move between issues and Enter to open.'));
$config = [
'selectable' => $selectable,
'selected' => $selectedIds,
'persist' => filled($persist) ? (string) $persist : null,
];
$isEmpty = $groups === [] && $slot->isEmpty();
$stickyTop = filled($stickyOffset) && preg_match('/^(-?\d*\.?\d+(px|rem|em)|0|var\(--[A-Za-z0-9_-]+\))$/', trim((string) $stickyOffset))
? trim((string) $stickyOffset)
: null;
@endphp
<div
data-slot="issue-list"
role="group"
aria-label="{{ $label }}"
aria-describedby="{{ $hintId }}"
@if ($selectable) data-selectable="true" @endif
x-data="uiIssueList({{ \Illuminate\Support\Js::from($config) }})"
x-modelable="ilSelected"
x-on:keydown="ilKeydown($event)"
x-on:click="ilClick($event)"
x-on:change="ilChange($event)"
x-on:focusin="ilFocusin($event)"
x-on:focusout="ilFocusout($event)"
{{ $attributes->merge(array_filter([
'class' => 'min-w-0 overflow-clip rounded-lg border border-border bg-background text-foreground',
'style' => $stickyTop !== null ? '--issue-list-sticky-top: '.$stickyTop.';' : null,
])) }}
>
<p id="{{ $hintId }}" class="sr-only">{{ $hint }}</p>
@if ($isEmpty)
<div data-slot="issue-list-empty" class="px-6 py-8 text-center text-sm text-muted-foreground">{{ $emptyText }}</div>
@elseif ($groups !== [])
@php
// One issue-row's props from a data row; the group's state is the fallback state.
$rowAttributes = function (array $row, array $group, int $index) use ($selectedIds): \Illuminate\View\ComponentAttributeBag {
$rowId = (string) ($row['id'] ?? $row['key'] ?? $index);
return new \Illuminate\View\ComponentAttributeBag([
'wire:key' => 'issue-'.$rowId,
'id' => $rowId,
'key' => $row['key'] ?? null,
'title' => $row['title'] ?? '',
'href' => $row['href'] ?? null,
'state' => $row['state'] ?? ($group['state'] ?? 'backlog'),
'stateLabel' => $row['stateLabel'] ?? null,
'progress' => $row['progress'] ?? null,
'priority' => $row['priority'] ?? 'none',
'priorityLabel' => $row['priorityLabel'] ?? null,
'labels' => $row['labels'] ?? [],
'maxLabels' => $row['maxLabels'] ?? 2,
'estimate' => $row['estimate'] ?? null,
'due' => $row['due'] ?? null,
'assignee' => $row['assignee'] ?? null,
'selected' => in_array($rowId, $selectedIds, true) || ($row['selected'] ?? false),
// Only a row's own pickers: an explicit null would hide the list's (@aware).
] + (isset($row['pickers']) ? ['pickers' => $row['pickers']] : []));
};
$groupRows = fn (array $group): array => array_values(array_filter((array) ($group['rows'] ?? []), 'is_array'));
$groupKey = fn (array $group, string $prefix = ''): string => (string) ($group['id'] ?? $prefix.\Illuminate\Support\Str::slug((string) ($group['name'] ?? $group['label'] ?? '')));
@endphp
@foreach ($groups as $group)
@php
$groupId = $groupKey($group);
$subgroups = array_values(array_filter((array) ($group['groups'] ?? []), 'is_array'));
$rows = $groupRows($group);
$count = $subgroups !== []
? array_sum(array_map(fn (array $subgroup): int => (int) ($subgroup['count'] ?? count($groupRows($subgroup))), $subgroups))
: count($rows);
@endphp
<x-ui.issue-list.group
wire:key="issue-group-{{ $groupId }}"
:id="$groupId"
:label="$group['name'] ?? $group['label'] ?? ''"
:state="$group['state'] ?? null"
:count="$group['count'] ?? $count"
:collapsed="$group['collapsed'] ?? false"
:addable="$group['addable'] ?? false"
:add-label="$group['addLabel'] ?? null"
:empty-text="$group['emptyText'] ?? null"
>
@if ($subgroups !== [])
@foreach ($subgroups as $subgroup)
@php $subgroupId = $groupKey($subgroup, $groupId.'-'); @endphp
<x-ui.issue-list.group
nested
wire:key="issue-group-{{ $subgroupId }}"
:id="$subgroupId"
:label="$subgroup['name'] ?? $subgroup['label'] ?? ''"
:state="$subgroup['state'] ?? null"
:count="$subgroup['count'] ?? count($groupRows($subgroup))"
:collapsed="$subgroup['collapsed'] ?? false"
:addable="$subgroup['addable'] ?? false"
:add-label="$subgroup['addLabel'] ?? null"
:empty-text="$subgroup['emptyText'] ?? null"
>
@foreach ($groupRows($subgroup) as $row)
<x-ui.issue-row :attributes="$rowAttributes($row, $subgroup + ['state' => $group['state'] ?? null], $loop->index)">{{ $row['trailing'] ?? '' }}</x-ui.issue-row>
@endforeach
</x-ui.issue-list.group>
@endforeach
@else
@foreach ($rows as $row)
<x-ui.issue-row :attributes="$rowAttributes($row, $group, $loop->index)">{{ $row['trailing'] ?? '' }}</x-ui.issue-row>
@endforeach
@endif
</x-ui.issue-list.group>
@endforeach
@else
{{ $slot }}
@endif
</div>
{{--
Issue List Group: one collapsible group of an issue list (for example
every issue in one workflow state).
The header is a heading that wraps a disclosure button (aria-expanded,
aria-controls). The rows sit in a <ul>; a collapsed group renders that
list `hidden`, so its rows are out of the keyboard cursor, the tab order
and the accessibility tree. The list's behaviour keeps the open state
across Livewire re-renders; `collapsed` is only the first state.
Sub-groups: put groups with `nested` inside a group (for example state
groups inside an assignee group). A nested group renders as an <li> of
its parent's list, with an indented header that sticks below the parent
header and a heading one level deeper. Collapsing the parent hides its
sub-groups; collapsing a sub-group hides only its rows, and the keyboard
cursor skips every hidden row. One level of nesting is supported.
--}}
@props([
// A stable group id (for example a state key). Used for the persisted
// collapsed state and in the issue-add event.
'id' => null,
// The translated group name. (Not `name`: rows read the list's form
// `name` through @aware, and a group name would shadow it.)
'label' => '',
// A workflow state category for the header icon (see issue-row), or null.
'state' => null,
// The number of issues in the group. Null hides the count.
'count' => null,
'collapsed' => false,
// true renders a "+" button that dispatches issue-add { group }.
'addable' => false,
'addLabel' => null,
'emptyText' => null,
// The heading level of the group name (2 to 6). Null: 3, or 4 when nested.
'level' => null,
// A sub-group inside another group's rows (the second group level).
'nested' => false,
])
@php
$label = (string) $label;
$groupId = (string) ($id ?? \Illuminate\Support\Str::slug($label));
$domId = 'issue-group-'.substr(md5($groupId), 0, 8).'-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(4));
$collapsed = filter_var($collapsed, FILTER_VALIDATE_BOOLEAN);
$nested = filter_var($nested, FILTER_VALIDATE_BOOLEAN);
$level = min(6, max(2, (int) ($level ?? ($nested ? 4 : 3))));
$groupTag = $nested ? 'li' : 'div';
$addLabel = filled($addLabel) ? (string) $addLabel : __('Add issue to :group', ['group' => $label]);
$emptyText = filled($emptyText) ? (string) $emptyText : __('No issues');
$hasAdd = isset($add) && ! $add->isEmpty();
@endphp
<{{ $groupTag }}
data-slot="issue-list-group"
data-group-id="{{ $groupId }}"
@if ($nested) data-nested="true" @endif
data-default-collapsed="{{ $collapsed ? 'true' : 'false' }}"
@if ($collapsed) data-collapsed="true" @endif
{{ $attributes->merge(['class' => 'min-w-0']) }}
>
<div data-slot="issue-list-group-header" @class([
'sticky flex min-h-10 min-w-0 items-center gap-2 border-b border-border pe-2 text-sm',
'top-[var(--issue-list-sticky-top,0px)] z-20 bg-muted' => ! $nested,
'top-[calc(var(--issue-list-sticky-top,0px)+2.5rem)] z-10 bg-background' => $nested,
])>
<h{{ $level }} class="flex min-w-0 flex-1">
<button
type="button"
id="{{ $domId }}-toggle"
data-slot="issue-list-group-toggle"
aria-expanded="{{ $collapsed ? 'false' : 'true' }}"
aria-controls="{{ $domId }}-rows"
@class([
'group/toggle flex min-h-10 min-w-0 flex-1 items-center gap-2 text-start font-medium text-foreground outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-inset focus-visible:ring-ring',
'ps-3' => ! $nested,
'ps-8' => $nested,
])
>
{{-- Down when open; points to the inline end when collapsed. --}}
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" focusable="false" class="size-4 shrink-0 text-muted-foreground group-aria-[expanded=false]/toggle:-rotate-90 rtl:group-aria-[expanded=false]/toggle:rotate-90">
<path d="m4 6 4 4 4-4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
@if (filled($state))
<x-ui.issue-row.state :state="$state" :announce="false" />
@endif
<span data-slot="issue-list-group-name" class="min-w-0 truncate">{{ $label }}</span>
@if ($count !== null)
<span data-slot="issue-list-group-count" class="shrink-0 font-normal text-muted-foreground tabular-nums">
<span aria-hidden="true">{{ $count }}</span>
<span class="sr-only">{{ trans_choice(':count issue|:count issues', (int) $count, ['count' => $count]) }}</span>
</span>
@endif
</button>
</h{{ $level }}>
@if ($hasAdd)
<span data-slot="issue-list-group-add" class="inline-flex shrink-0 items-center">{{ $add }}</span>
@elseif (filter_var($addable, FILTER_VALIDATE_BOOLEAN))
<x-ui.button variant="ghost" size="sm" :icon-label="$addLabel" data-issue-add="{{ $groupId }}">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" focusable="false" class="size-4">
<path d="M8 3.5v9M3.5 8h9" stroke-linecap="round" />
</svg>
</x-ui.button>
@endif
</div>
<ul
role="list"
id="{{ $domId }}-rows"
data-slot="issue-list-group-rows"
aria-labelledby="{{ $domId }}-toggle"
class="divide-y divide-border"
@if ($collapsed) hidden @endif
>
@if ($slot->isEmpty())
<li data-slot="issue-list-group-empty" class="px-3 py-2 text-sm text-muted-foreground">{{ $emptyText }}</li>
@else
{{ $slot }}
@endif
</ul>
</{{ $groupTag }}>
/**
* Issue List behaviour.
*
* The roving cursor, keys, selection and morph handling come from the shared
* list-cursor module (`createListCursor`, see list-cursor.js); this file adds
* the collapsible groups and the "+" of an addable group. Before every sync
* the groups are written from the Alpine state (`ilCollapsed`), so a Livewire
* morph that puts the server's attributes back converges in one more pass.
*
* Rows: issue-row, or any row with the generic contract data-list-row plus
* data-row-id, one [data-row-link] and an optional [data-row-select] label
* around a checkbox (notification-row uses it). A link with
* aria-disabled="true" marks a disabled row: it takes the cursor but opens
* nothing and cannot be selected with x.
*
* The cursor is kept by row id (`data-issue-id` or `data-row-id`), so it survives a morph that
* keeps the row; when the row goes away the cursor takes the row now at the
* same place. If focus was in the list and the morph dropped it (focus fell
* to <body>), focus returns to the element that had it, found again by its
* `id`, else its `data-anchor-key` (the order popover uses), so a property
* button inside a row keeps focus across a re-render; failing that, to the
* cursor row's link. The list does not take focus back while a modal dialog
* makes it inert, or when focus was last inside a widget in a row that owns
* its keys (a menu, a listbox, an open dialog or popover panel): that widget
* restores focus itself. (list-cursor does this for every list.)
*
* Keys act only when focus is inside the list, never while typing in a text
* field or inside a menu, listbox or dialog in a row, and never with Ctrl,
* Meta or Alt held. A handled key calls preventDefault(); the hotkeys
* registry skips a prevented event, so a global `j` or `x` shortcut does not
* fire as well.
*
* Events (bubbling CustomEvents on the list root):
* issue-focus { id } the cursor moved to another row
* issue-open { id, href } cancelable; preventDefault() stops the link
* issue-selection-change { ids } after a user changes the selection
* issue-add { group } the "+" of an addable group
*
* In a selectable list Shift+J and Shift+K also extend the selection from an
* anchor row (keys that did nothing before); Shift+Arrow still only moves.
*
* Internal state is prefixed (`il*`): 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';
// Any row can join the cursor: issue-row by its slots, other rows (such as
// notification-row) through the generic contract data-list-row with
// data-row-id, one [data-row-link] and an optional [data-row-select] label.
const ROW = '[data-slot="issue-row"], [data-list-row]';
const LINK = '[data-slot="issue-row-link"], [data-row-link]';
const SELECT = '[data-slot="issue-row-select"] input[type="checkbox"], [data-row-select] input[type="checkbox"]';
const ID = ['data-issue-id', 'data-row-id'];
const GROUP = '[data-slot="issue-list-group"]';
const GROUP_ROWS = '[data-slot="issue-list-group-rows"]';
const TOGGLE = '[data-slot="issue-list-group-toggle"]';
function readStore(key) {
try {
const value = JSON.parse(window.localStorage.getItem(key) || 'null');
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
} catch {
return {};
}
}
function writeStore(key, value) {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// Private mode or a full quota: the state simply is not kept.
}
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiIssueList', (config = {}) => {
// Kept outside the reactive object: DOM nodes and the cursor
// controller must not be wrapped in Alpine proxies.
let root = null;
let cursor = null;
const storageKey = config.persist ? `issue-list:${config.persist}` : null;
const stored = storageKey ? readStore(storageKey) : {};
return {
ilSelected: Array.isArray(config.selected) ? config.selected.map(String) : [],
ilCollapsed: {},
init() {
root = this.$el;
if (this.ilSelected.length === 0 && config.selectable) {
this.ilSelected = Array.from(root.querySelectorAll(ROW))
.filter((row) => row.dataset.selected === 'true' && row.closest('[data-slot="issue-list"]') === root)
.map((row) => row.dataset.issueId ?? row.dataset.rowId)
.filter(Boolean);
}
cursor = createListCursor(root, {
owner: '[data-slot="issue-list"]',
row: ROW,
link: LINK,
id: ID,
select: SELECT,
group: GROUP,
selectable: Boolean(config.selectable),
// Shift+J/K extend the selection (keys that did nothing
// before); Shift+Arrow keeps moving the cursor only.
rangeSelect: config.rangeSelect !== false,
shiftArrows: false,
eventPrefix: 'issue',
// The root's x-on handlers call the controller.
listen: false,
observeAttributes: ['aria-expanded', 'data-collapsed'],
getSelected: () => this.ilSelected,
setSelected: (ids) => {
this.ilSelected = ids;
},
beforeSync: () => this.ilSyncGroups(),
});
this.$watch('ilSelected', () => cursor?.schedule());
},
destroy() {
cursor?.destroy();
cursor = null;
root = null;
},
ilIsCollapsed(group) {
const id = group.dataset.groupId ?? '';
if (!(id in this.ilCollapsed)) {
this.ilCollapsed[id] = id in stored ? Boolean(stored[id]) : group.dataset.defaultCollapsed === 'true';
}
return this.ilCollapsed[id];
},
ilSyncGroups() {
for (const group of root.querySelectorAll(GROUP)) {
const collapsed = this.ilIsCollapsed(group);
setAttr(group, 'data-collapsed', collapsed ? 'true' : null);
const list = group.querySelector(GROUP_ROWS);
if (list && list.hidden !== collapsed) list.hidden = collapsed;
setAttr(group.querySelector(TOGGLE), 'aria-expanded', collapsed ? 'false' : 'true');
}
},
ilToggleGroup(group) {
const id = group.dataset.groupId ?? '';
this.ilCollapsed[id] = !this.ilIsCollapsed(group);
if (storageKey) {
stored[id] = this.ilCollapsed[id];
writeStore(storageKey, stored);
}
cursor?.sync();
},
ilKeydown(event) {
cursor?.keydown(event);
},
ilClick(event) {
const target = event.target instanceof Element ? event.target : null;
if (!target) return;
const toggle = target.closest(TOGGLE);
if (toggle && root.contains(toggle)) {
const group = toggle.closest(GROUP);
if (group) this.ilToggleGroup(group);
return;
}
const add = target.closest('[data-issue-add]');
if (add && root.contains(add)) {
root.dispatchEvent(new CustomEvent('issue-add', { detail: { group: add.dataset.issueAdd }, bubbles: true }));
return;
}
cursor?.click(event);
},
ilChange(event) {
cursor?.change(event);
},
ilFocusin(event) {
cursor?.focusin(event);
},
ilFocusout(event) {
cursor?.focusout(event);
},
};
});
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.4.2 - 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