Queue
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.
Queue Row — One row of a triage queue for any record type (an inbox message, a review item, a moderation report): an optional selection checkbox, a start slot or avatar, the title as the row's stretched link with a meta line, and an end with badges, a time and a slot. Stays a working link without JavaScript; queue-list adds the keyboard cursor and triage API.
Preview
- Unread Refund request for order 10482 Ada Lovelace: The parcel arrived damaged, photos attached.
- New comment on the Q3 roadmap Grace Hopper replied in Planning
- Flagged post in Community Reported twice for spam
- Damage photos for claim 2291 Katherine Johnson attached 5 photos
{{-- A start avatar or icon, the title as the row link, a meta line, end badges and a time. Unread and selected states; a thumbnail strip with a "+n" chip, a live relative time (datetime without time) and the current (open) row. --}}
@php
$thumbnail = fn (int $lightness): string => '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% '.$lightness.'%)"/><circle cx="22" cy="22" r="8" fill="hsl(220 10% '.($lightness - 20).'%)"/><path d="M0 64 24 38l14 14 10-10 16 22z" fill="hsl(220 10% '.($lightness - 30).'%)"/></svg>');
@endphp
<div class="w-full max-w-2xl">
<ul role="list" class="divide-y divide-border rounded-lg border border-border bg-background">
<x-ui.queue-row 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="2h" :datetime="now()->subHours(2)->toIso8601String()" unread />
<x-ui.queue-row id="msg-2" href="#msg-2" :title="__('New comment on the Q3 roadmap')" :meta="__('Grace Hopper replied in Planning')" :avatar="['name' => 'Grace Hopper']" time="5h" selectable selected />
<x-ui.queue-row id="msg-3" href="#msg-3" :title="__('Flagged post in Community')" :meta="__('Reported twice for spam')" :badges="[['label' => __('Spam'), 'tone' => 'destructive'], __('Community')]" time="Sep 28" datetime="2026-09-28">
<x-slot:start>
<span class="inline-flex size-8 items-center justify-center rounded-full bg-muted text-muted-foreground">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" focusable="false" class="size-4"><path d="M3.5 13.5v-11m0 1h8l-1.5 3 1.5 3h-8" stroke-linecap="round" stroke-linejoin="round" /></svg>
</span>
</x-slot:start>
</x-ui.queue-row>
<x-ui.queue-row id="msg-4" href="#msg-4" :title="__('Damage photos for claim 2291')" :meta="__('Katherine Johnson attached 5 photos')" :avatar="['name' => 'Katherine Johnson']" :media="[
['src' => $thumbnail(88), 'alt' => __('Front of the parcel')],
['src' => $thumbnail(80), 'alt' => __('Torn corner')],
['src' => $thumbnail(72), 'alt' => __('Broken item')],
$thumbnail(84),
$thumbnail(76),
]" :datetime="now()->subMinutes(12)->toIso8601String()" current />
</ul>
</div>
Installation
php artisan ui:add queue-row
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: Queue Row (`queue-row`)
One row of a triage queue for any record type (an inbox message, a review item, a moderation report): an optional selection checkbox, a start slot or avatar, the title as the row's stretched link with a meta line, and an end with badges, a time and a slot. Stays a working link without JavaScript; queue-list adds the keyboard cursor and triage API.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add queue-row
```
## Usage
```blade
{{-- A start avatar or icon, the title as the row link, a meta line, end badges and a time. Unread and selected states; a thumbnail strip with a "+n" chip, a live relative time (datetime without time) and the current (open) row. --}}
@php
$thumbnail = fn (int $lightness): string => '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% '.$lightness.'%)"/><circle cx="22" cy="22" r="8" fill="hsl(220 10% '.($lightness - 20).'%)"/><path d="M0 64 24 38l14 14 10-10 16 22z" fill="hsl(220 10% '.($lightness - 30).'%)"/></svg>');
@endphp
<div class="w-full max-w-2xl">
<ul role="list" class="divide-y divide-border rounded-lg border border-border bg-background">
<x-ui.queue-row 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="2h" :datetime="now()->subHours(2)->toIso8601String()" unread />
<x-ui.queue-row id="msg-2" href="#msg-2" :title="__('New comment on the Q3 roadmap')" :meta="__('Grace Hopper replied in Planning')" :avatar="['name' => 'Grace Hopper']" time="5h" selectable selected />
<x-ui.queue-row id="msg-3" href="#msg-3" :title="__('Flagged post in Community')" :meta="__('Reported twice for spam')" :badges="[['label' => __('Spam'), 'tone' => 'destructive'], __('Community')]" time="Sep 28" datetime="2026-09-28">
<x-slot:start>
<span class="inline-flex size-8 items-center justify-center rounded-full bg-muted text-muted-foreground">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" focusable="false" class="size-4"><path d="M3.5 13.5v-11m0 1h8l-1.5 3 1.5 3h-8" stroke-linecap="round" stroke-linejoin="round" /></svg>
</span>
</x-slot:start>
</x-ui.queue-row>
<x-ui.queue-row id="msg-4" href="#msg-4" :title="__('Damage photos for claim 2291')" :meta="__('Katherine Johnson attached 5 photos')" :avatar="['name' => 'Katherine Johnson']" :media="[
['src' => $thumbnail(88), 'alt' => __('Front of the parcel')],
['src' => $thumbnail(80), 'alt' => __('Torn corner')],
['src' => $thumbnail(72), 'alt' => __('Broken item')],
$thumbnail(84),
$thumbnail(76),
]" :datetime="now()->subMinutes(12)->toIso8601String()" current />
</ul>
</div>
```
## Props
- `id` (string|int|null, default `null`) — The row id (data-queue-id), used by the cursor, the selection and the events. Null derives one from the title.
- `title` (string|null, default `null`) — The row title, rendered as the link (or as a named title slot).
- `href` (string|null, default `null`) — Where the row opens. Null renders the title as a button.
- `meta` (string|null, default `null`) — A secondary line under the title (sender, excerpt, source). A meta slot wins; links in it stay clickable above the row link.
- `avatar` (string|array|null, default `null`) — A start avatar when the start slot is empty: a name, or { name, src, initials }. Decorative (aria-hidden).
- `badges` (array, default `[]`) — End badges: strings, or { label, tone } with tone success, warning, destructive, info or neutral (a dot). Hidden below the sm breakpoint.
- `time` (string|null, default `null`) — End time text, for example "2h" or "Sep 30", rendered as a static <time>. Leave it null and set datetime for a live relative time.
- `datetime` (string|null, default `null`) — The machine-readable datetime of the <time> element. Without time, the row renders it as a live relative time (<x-ui.relative-time>: "5m ago", refreshed every minute, the absolute date as its title).
- `media` (array, default `[]`) — End thumbnails: image URLs, or { src, alt }. Fixed 32px squares with width and height, loading="lazy" and decoding="async". Without alt they are decorative (the strip is aria-hidden); with alt the strip describes the link. Hidden on a row narrower than 28rem.
- `mediaMax` (int, default `3`) — The most thumbnails on a row of 36rem or wider; narrower rows show at most 2. A "+n" chip counts the rest for each width ("n more" for screen readers).
- `mediaTotal` (int|null, default `null`) — The full number of media items when media holds only the first few (the chip counts from it). Null uses the length of media.
- `mediaIndicator` (bool, default `false`) — On a row narrower than 28rem, where the media strip is hidden (a phone), a compact image icon with the full media count (mediaTotal) takes its place; it hides again from 28rem. It describes the link (":count media items"). Only with media. Opt-in.
- `attachments` (int|null, default `null`) — A count of other attachments (files that are not thumbnails): a paperclip icon with the count at every width, describing the link (":count attachments"). 0 or null renders nothing.
- `current` (bool|page|step|location|date|time, default `false`) — Marks the record that is open, for example in a preview pane beside the list: aria-current on the link (true, or the given token), data-current on the row and a primary bar at the logical start, apart from the cursor and selection backgrounds.
- `unread` (bool, default `false`) — A dot and a semibold title, with "Unread" in the link's description; data-unread for styling.
- `selectable` (bool, default `false`) — Adds the selection checkbox. Inherited from queue-list through @aware.
- `selected` (bool, default `false`) — Checked at first paint; data-selected for styling.
- `focused` (bool, default `false`) — data-focused at first paint: the cursor highlight (the list behaviour owns it afterwards and keeps it only while the list has focus). It is not the open record; mark that with current.
- `hidden` (bool, default `false`) — Renders the row hidden (data-hidden and the hidden attribute), for example a record already handled.
- `name` (string|null, default `null`) — The checkbox posts as name[] with the row id as its value. Inherited from queue-list through @aware.
- `selectLabel` (string|null, default `null`) — The checkbox's accessible name. Null uses "Select <title>".
- `as` (li|div, default `li`) — The row element; use div outside a list.
## Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- Showing one record that waits for a decision (a message, a request, a report, a submission) as a dense row with a leading icon or avatar, a title, a meta line and trailing badges or a time.
- Rows of a <x-ui.queue-list>, or of your own list under <x-ui.list-cursor>.
## Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- Work tracker issues with key, state, priority and assignee: use <x-ui.issue-row>.
- A notification in a panel or a toast: use <x-ui.notification-card> or toast.
- A table row with sortable columns: use <x-ui.table> or <x-ui.data-table>.
## Anti-patterns
- Adding display ornament without informational value
## Rules
- Use the `<brok:queue-row>` tag (or `<x-ui.queue-row>`) 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-row
- Registry JSON (files, props, contract): https://brokui.dev/r/open/queue-row.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- A phone-width queue: under 28rem the thumbnail strip and the badges are hidden, so media-indicator shows an image icon with the media count in their place, and attachments adds a paperclip with a count at every width. --}}
@php
$thumbnail = fn (int $lightness): string => '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% '.$lightness.'%)"/></svg>');
@endphp
<div class="w-full max-w-xs">
<ul role="list" class="divide-y divide-border rounded-lg border border-border bg-background">
<x-ui.queue-row id="cmp-1" href="#cmp-1" :title="__('Damage photos for claim 2291')" :meta="__('Katherine Johnson')" :media="[$thumbnail(88), $thumbnail(80), $thumbnail(72)]" :media-total="5" media-indicator :badges="[__('Claims')]" time="12m" unread />
<x-ui.queue-row id="cmp-2" href="#cmp-2" :title="__('Signed contract and invoice')" :meta="__('Legal')" :attachments="2" time="1h" />
<x-ui.queue-row id="cmp-3" href="#cmp-3" :title="__('Site visit notes')" :meta="__('Field team')" :media="[$thumbnail(84)]" media-indicator :attachments="1" time="3h" />
<x-ui.queue-row id="cmp-4" href="#cmp-4" :title="__('Question about the delivery date')" :meta="__('Ada Lovelace')" time="5h" />
</ul>
</div>
Long Content
{{-- Long titles, unbroken meta text and a right-to-left title truncate inside the row; the end stays visible. On this narrow row the thumbnail strip hides instead of squeezing the title or the 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">
<ul role="list" class="divide-y divide-border rounded-lg border border-border bg-background">
<x-ui.queue-row id="long-1" href="#long-1" :title="__('A deliberately long queue item title that verifies truncation, overflow and content expansion without pushing the time out of the row')" :meta="__('supplier-invoices-and-credit-notes@accounts-payable.example.com')" :avatar="['name' => 'Barbara Liskov']" :badges="[__('Accounts payable'), __('Needs a second approval')]" time="Yesterday" unread />
<x-ui.queue-row id="long-2" href="#long-2" title="مراجعة طلب الاسترداد للعميل رقم ١٠٤٨٢" meta="https://example.com/a/very/long/unbroken/path/that/never/wraps/on/its/own" time="09:41" />
<x-ui.queue-row id="long-3" href="#long-3" :title="__('A long attachment title that must keep its space next to the thumbnails and the time')" :meta="__('12 attachments')" :media="array_fill(0, 4, $thumbnail)" :media-total="12" :badges="[__('Attachments')]" time="10:02" />
</ul>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | int | null | null | The row id (data-queue-id), used by the cursor, the selection and the events. Null derives one from the title. |
| title | string | null | null | The row title, rendered as the link (or as a named title slot). |
| href | string | null | null | Where the row opens. Null renders the title as a button. |
| meta | string | null | null | A secondary line under the title (sender, excerpt, source). A meta slot wins; links in it stay clickable above the row link. |
| avatar | string | array | null | null | A start avatar when the start slot is empty: a name, or { name, src, initials }. Decorative (aria-hidden). |
| badges | array | [] | End badges: strings, or { label, tone } with tone success, warning, destructive, info or neutral (a dot). Hidden below the sm breakpoint. |
| time | string | null | null | End time text, for example "2h" or "Sep 30", rendered as a static <time>. Leave it null and set datetime for a live relative time. |
| datetime | string | null | null | The machine-readable datetime of the <time> element. Without time, the row renders it as a live relative time (<x-ui.relative-time>: "5m ago", refreshed every minute, the absolute date as its title). |
| media | array | [] | End thumbnails: image URLs, or { src, alt }. Fixed 32px squares with width and height, loading="lazy" and decoding="async". Without alt they are decorative (the strip is aria-hidden); with alt the strip describes the link. Hidden on a row narrower than 28rem. |
| mediaMax | int | 3 | The most thumbnails on a row of 36rem or wider; narrower rows show at most 2. A "+n" chip counts the rest for each width ("n more" for screen readers). |
| mediaTotal | int | null | null | The full number of media items when media holds only the first few (the chip counts from it). Null uses the length of media. |
| mediaIndicator | bool | false | On a row narrower than 28rem, where the media strip is hidden (a phone), a compact image icon with the full media count (mediaTotal) takes its place; it hides again from 28rem. It describes the link (":count media items"). Only with media. Opt-in. |
| attachments | int | null | null | A count of other attachments (files that are not thumbnails): a paperclip icon with the count at every width, describing the link (":count attachments"). 0 or null renders nothing. |
| current | bool | page | step | location | date | time | false | Marks the record that is open, for example in a preview pane beside the list: aria-current on the link (true, or the given token), data-current on the row and a primary bar at the logical start, apart from the cursor and selection backgrounds. |
| unread | bool | false | A dot and a semibold title, with "Unread" in the link's description; data-unread for styling. |
| selectable | bool | false | Adds the selection checkbox. Inherited from queue-list through @aware. |
| selected | bool | false | Checked at first paint; data-selected for styling. |
| focused | bool | false | data-focused at first paint: the cursor highlight (the list behaviour owns it afterwards and keeps it only while the list has focus). It is not the open record; mark that with current. |
| hidden | bool | false | Renders the row hidden (data-hidden and the hidden attribute), for example a record already handled. |
| name | string | null | null | The checkbox posts as name[] with the row id as its value. Inherited from queue-list through @aware. |
| selectLabel | string | null | null | The checkbox's accessible name. Null uses "Select <title>". |
| as | li | div | li | The row element; use div outside a list. |
Slots
start— An icon, avatar or logo at the logical start. Links and buttons in it sit above the row link.title— Rich title content instead of the title prop (keep it phrasing content; it renders inside the link).meta— Rich meta line content instead of the meta prop.end— Trailing content after the badges and the time, for example a row-actions menu. Controls in it sit above the row link.
Renders as li, div.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- The title is the only link. Its ::after covers the whole row (a stretched link), so a click anywhere opens the record and the row is a plain link without JavaScript (IC-013). The checkbox and any a, button, input or select in the start, meta and end parts sit above it and stay separate controls; nothing interactive is nested inside the <a>.
- The link is described (aria-describedby) by the unread mark, the meta line, the badges and the time, so a screen reader hears the row's parts after its name.
- Static Blade. Inside <x-ui.queue-list> the list behaviour sets data-focused, data-selected, hidden and the roving tabindex.
- Opt-in end parts, all inside the end (max half the row), which a row with media makes a size container (@container/queue-row): the media strip shows from a 28rem row with up to 2 thumbnails and from 36rem with up to mediaMax, and the badges shrink first, so the title, meta line and time are never pushed out. Without media, time or datetime the output is unchanged.
- Compact indicators for narrow rows: with media-indicator, a row under 28rem (where the strip and, below the sm breakpoint, the badges are hidden) shows an image icon with the media count; attachments adds a paperclip with a count at every width. Both are opt-in, sit in the end part, and describe the link.
- Time: a time string renders a static <time> exactly as before; a datetime alone renders <x-ui.relative-time> (server label first, then a live label every minute) inside the queue-row-time span, which the link's description still names.
- Declares registry capability flags: a11y, 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.
- Showing one record that waits for a decision (a message, a request, a report, a submission) as a dense row with a leading icon or avatar, a title, a meta line and trailing badges or a time.
- Rows of a <x-ui.queue-list>, or of your own list under <x-ui.list-cursor>.
Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- Work tracker issues with key, state, priority and assignee: use <x-ui.issue-row>.
- A notification in a panel or a toast: use <x-ui.notification-card> or toast.
- A table row with sortable columns: use <x-ui.table> or <x-ui.data-table>.
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
native
- The checkbox has a 24px target around its 16px box (WCAG 2.5.8) and an accessible name from selectLabel or the title.
- Long titles and meta lines truncate with dir="auto", so text in another script truncates at its own end; parts use logical properties and mirror under dir="rtl" (IC-015). The row transition is colour only and is removed under prefers-reduced-motion.
- current puts aria-current on the link ("true" by default), so a screen reader hears which record is open; the bar is a second cue besides colour. Thumbnails without alt are hidden from assistive tech; the "+n" chip speaks as "n more".
- 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
Livewire can update this component through forwarded wire:* attributes.
Source
The exact, editable file ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{--
Queue Row: one row of a triage queue (an inbox message, a review item,
a moderation report, any record that waits for a decision).
Parts from the logical start: an optional selection checkbox, the start
slot (an icon, avatar or logo; `avatar` renders one for you), the title
as the row's link with a meta line under it, then the end: `badges`,
`time` and the end slot.
The title is the only link. Its ::after covers the whole row (the
"stretched link"), so a click anywhere opens the record and the row
works as a plain link without JavaScript. The checkbox and any link,
button or field in the end slot sit above it and stay separate controls:
nothing interactive is ever nested inside the <a>.
Inside a queue list the row inherits `selectable` and `name` through
@aware, so rows written into a list need neither.
Opt-in parts: `media` adds a strip of small thumbnails with a "+n" chip
to the end (it hides on narrow rows before it can squeeze the title,
meta or time), and `media-indicator` shows a compact image icon with the
count in its place on those narrow rows (a phone); `attachments` adds a
paperclip with a count; `current` marks the record that is open next to
the list with aria-current and a start bar; `datetime` without `time`
renders a live relative time through relative-time.
--}}
@aware([
'selectable' => false,
'name' => null,
])
@props([
'id' => null,
'title' => null,
'href' => null,
// A secondary line under the title (sender, excerpt, source). Or the meta slot.
'meta' => null,
// A start avatar when the start slot is empty: a name, or { name, src, initials }.
'avatar' => null,
// End badges: strings, or { label, tone } where tone is a badge tone.
'badges' => [],
// End time text (for example "2h" or "Sep 30"), with an ISO datetime for <time>.
// A datetime without a time renders a live relative time (relative-time).
'time' => null,
'datetime' => null,
// End thumbnails: image URLs, or { src, alt }. Without alt they are decorative.
'media' => [],
// The most thumbnails shown on a wide row (a narrow row shows at most 2, a very narrow one none).
'mediaMax' => 3,
// The full number of media items when media holds only the first few. Null: count(media).
'mediaTotal' => null,
// On a row too narrow for the strip (under 28rem, a phone), a compact image icon with the media count.
'mediaIndicator' => false,
// A count of other attachments (files): a paperclip with the count at every width. 0 or null: none.
'attachments' => null,
// The record that is open (for example in a preview pane): true, or an aria-current token.
'current' => false,
'unread' => false,
'selectable' => false,
'selected' => false,
'focused' => false,
// Rendered hidden (for example an item the user already handled).
'hidden' => false,
'name' => null,
// The accessible name of the checkbox. Null: "Select <title>".
'selectLabel' => null,
'as' => 'li',
])
@php
$tag = in_array($as, ['li', 'div'], true) ? $as : 'li';
$titleText = trim(strip_tags((string) $title));
$rowId = (string) ($id ?? 'queue-'.substr(md5($titleText), 0, 8));
$baseId = 'queue-row-'.substr(md5($rowId), 0, 10);
$selectable = filter_var($selectable, FILTER_VALIDATE_BOOLEAN);
$selected = filter_var($selected, FILTER_VALIDATE_BOOLEAN);
$unread = filter_var($unread, FILTER_VALIDATE_BOOLEAN);
$isHidden = filter_var($hidden, FILTER_VALIDATE_BOOLEAN);
$hasStart = isset($start) && ! $start->isEmpty();
$hasEnd = isset($end) && ! $end->isEmpty();
$hasMeta = $meta instanceof \Illuminate\Contracts\Support\Htmlable ? trim((string) $meta->toHtml()) !== '' : filled($meta);
// Start avatar: a name, or { name, src|avatar, initials }.
$avatarName = is_array($avatar) ? ($avatar['name'] ?? null) : $avatar;
$avatarName = is_scalar($avatarName) && (string) $avatarName !== '' ? (string) $avatarName : null;
$avatarSrc = is_array($avatar) ? ($avatar['src'] ?? $avatar['avatar'] ?? null) : null;
$avatarInitials = is_array($avatar) && filled($avatar['initials'] ?? null)
? (string) $avatar['initials']
: ($avatarName !== null
? mb_strtoupper(collect(preg_split('/\s+/u', trim($avatarName)) ?: [])->filter()->take(2)->map(fn ($part) => mb_substr($part, 0, 1))->implode(''))
: '');
$tones = ['success', 'warning', 'destructive', 'info', 'neutral'];
$badgeItems = [];
foreach ((array) $badges as $badge) {
$badgeLabel = is_array($badge) ? ($badge['label'] ?? $badge['name'] ?? null) : $badge;
if (! is_scalar($badgeLabel) || (string) $badgeLabel === '') {
continue;
}
$badgeTone = is_array($badge) ? ($badge['tone'] ?? null) : null;
$badgeItems[] = ['label' => (string) $badgeLabel, 'tone' => in_array($badgeTone, $tones, true) ? $badgeTone : null];
}
// End thumbnails: a URL, or { src|url, alt }. Up to mediaMax on a wide row, 2 on a
// narrower one; the chip counts the rest, so each width has its own chip.
$mediaItems = [];
foreach ((array) $media as $mediaItem) {
$mediaSrc = is_array($mediaItem) ? ($mediaItem['src'] ?? $mediaItem['url'] ?? null) : $mediaItem;
if (! is_scalar($mediaSrc) || trim((string) $mediaSrc) === '') {
continue;
}
$mediaAlt = is_array($mediaItem) && is_scalar($mediaItem['alt'] ?? null) ? trim((string) $mediaItem['alt']) : '';
$mediaItems[] = ['src' => (string) $mediaSrc, 'alt' => $mediaAlt];
}
$mediaMax = max(1, (int) $mediaMax);
$mediaTotal = max(count($mediaItems), (int) ($mediaTotal ?? 0));
$mediaShown = array_slice($mediaItems, 0, $mediaMax);
$mediaRestWide = $mediaTotal - count($mediaShown);
$mediaRestNarrow = $mediaTotal - min(2, count($mediaShown));
$hasMedia = $mediaShown !== [];
$mediaLabelled = collect($mediaShown)->contains(fn (array $item): bool => $item['alt'] !== '');
$showMediaIndicator = $hasMedia && filter_var($mediaIndicator, FILTER_VALIDATE_BOOLEAN);
$attachmentCount = is_numeric($attachments) ? max(0, (int) $attachments) : 0;
// current: true is aria-current="true"; a token (page, step, location, date, time) is kept.
$currentToken = match (true) {
$current === true, $current === 'true', $current === 1, $current === '1' => 'true',
in_array($current, ['page', 'step', 'location', 'date', 'time'], true) => $current,
default => null,
};
// A datetime alone renders a live relative time.
$liveTime = ! filled($time) && filled($datetime);
$describedBy = implode(' ', array_filter([
$unread ? $baseId.'-unread' : null,
$hasMeta ? $baseId.'-meta' : null,
$mediaLabelled ? $baseId.'-media' : null,
$showMediaIndicator ? $baseId.'-media-count' : null,
$attachmentCount > 0 ? $baseId.'-attachments' : null,
$badgeItems !== [] ? $baseId.'-badges' : null,
filled($time) || $liveTime ? $baseId.'-time' : null,
]));
$linkClasses = 'min-w-0 scroll-mt-12 truncate text-foreground outline-none after:absolute after:inset-0 focus-visible:after:ring-[length:var(--ring-width)] focus-visible:after:ring-inset focus-visible:after:ring-ring'
.($unread ? ' font-semibold' : ' font-medium');
// Controls inside the start, meta and end parts sit above the stretched link.
$raise = '[&_a]:relative [&_a]:z-10 [&_button]:relative [&_button]:z-10 [&_input]:relative [&_input]:z-10 [&_select]:relative [&_select]:z-10';
@endphp
<{{ $tag }}
data-slot="queue-row"
data-queue-id="{{ $rowId }}"
@if ($unread) data-unread="true" @endif
@if ($selected) data-selected="true" @endif
@if (filter_var($focused, FILTER_VALIDATE_BOOLEAN)) data-focused="true" @endif
@if ($currentToken !== null) data-current="true" @endif
@if ($isHidden) data-hidden="true" hidden @endif
{{ $attributes->merge(['class' => 'relative flex min-h-11 min-w-0 items-center gap-3 px-4 py-2 text-sm transition-colors motion-reduce:transition-none hover:bg-accent/50 data-[focused]:bg-accent/60 data-[selected]:bg-accent data-[current]:before:absolute data-[current]:before:inset-y-2 data-[current]:before:start-0 data-[current]:before:w-0.5 data-[current]:before:rounded-full data-[current]:before:bg-primary'.($hasMedia ? ' @container/queue-row' : '')]) }}
>
@if ($selectable)
{{-- A 24px target around the 16px box (WCAG 2.5.8), above the link. --}}
<label data-slot="queue-row-select" class="relative z-10 -ms-1 inline-flex size-6 shrink-0 cursor-pointer items-center justify-center">
<x-ui.checkbox
:id="$baseId.'-select'"
:name="filled($name) ? $name.'[]' : null"
:value="$rowId"
:checked="$selected"
:aria-label="filled($selectLabel) ? (string) $selectLabel : __('Select :item', ['item' => $titleText])"
/>
</label>
@endif
@if ($hasStart)
<span data-slot="queue-row-start" class="inline-flex shrink-0 items-center {{ $raise }}">{{ $start }}</span>
@elseif ($avatarName !== null)
<span data-slot="queue-row-start" class="inline-flex shrink-0 items-center" aria-hidden="true">
<x-ui.avatar size="sm" :src="$avatarSrc" alt="" :fallback="$avatarInitials" />
</span>
@endif
{{-- The body takes the remaining space; dir="auto" lets a title in
another script than the page truncate at its own end. --}}
<span data-slot="queue-row-body" class="flex min-w-0 flex-1 flex-col">
<span data-slot="queue-row-title" class="flex min-w-0 items-center gap-2">
@if ($unread)
<span data-slot="queue-row-unread" class="size-2 shrink-0 rounded-full bg-primary" aria-hidden="true"></span>
<span id="{{ $baseId }}-unread" class="sr-only">{{ __('Unread') }}</span>
@endif
@if (filled($href))
<a href="{{ $href }}" dir="auto" data-slot="queue-row-link"@if ($currentToken !== null) aria-current="{{ $currentToken }}"@endif @if ($describedBy !== '') aria-describedby="{{ $describedBy }}"@endif class="{{ $linkClasses }}">{{ $title }}</a>
@else
<button type="button" dir="auto" data-slot="queue-row-link"@if ($currentToken !== null) aria-current="{{ $currentToken }}"@endif @if ($describedBy !== '') aria-describedby="{{ $describedBy }}"@endif class="{{ $linkClasses }} text-start">{{ $title }}</button>
@endif
</span>
@if ($hasMeta)
<span id="{{ $baseId }}-meta" data-slot="queue-row-meta" dir="auto" class="min-w-0 truncate text-xs text-muted-foreground {{ $raise }}">{{ $meta }}</span>
@endif
</span>
@if ($hasMedia || $attachmentCount > 0 || $badgeItems !== [] || filled($time) || $liveTime || $hasEnd)
<span data-slot="queue-row-end" class="inline-flex max-w-1/2 shrink-0 items-center justify-end gap-2 text-xs text-muted-foreground {{ $raise }}">
@if ($hasMedia)
{{-- Fixed 32px thumbnails (no layout shift), lazy loaded. The strip shows from a
28rem row, the thumbnails after the second from 36rem; below that it is gone,
so it never squeezes the title, the meta line or the time. --}}
<span
@if ($mediaLabelled) id="{{ $baseId }}-media" @else aria-hidden="true" @endif
data-slot="queue-row-media"
data-count="{{ $mediaTotal }}"
class="hidden shrink-0 items-center gap-1 @md/queue-row:flex"
>
@foreach ($mediaShown as $mediaItem)
<img
src="{{ $mediaItem['src'] }}"
alt="{{ $mediaItem['alt'] }}"
width="32"
height="32"
loading="lazy"
decoding="async"
data-slot="queue-row-thumbnail"
class="{{ $loop->index >= 2 ? 'hidden @xl/queue-row:block' : 'block' }} size-8 shrink-0 rounded-sm border border-border bg-muted object-cover"
/>
@endforeach
@foreach (array_unique(array_filter([$mediaRestNarrow, $mediaRestWide])) as $rest)
@php
$chipWidth = $mediaRestNarrow === $mediaRestWide ? 'inline-flex' : ($rest === $mediaRestNarrow ? 'inline-flex @xl/queue-row:hidden' : 'hidden @xl/queue-row:inline-flex');
@endphp
<span data-slot="queue-row-media-more" class="{{ $chipWidth }} h-8 min-w-8 shrink-0 items-center justify-center rounded-sm bg-muted px-1 font-medium tabular-nums text-muted-foreground">
<span aria-hidden="true">+{{ $rest }}</span>
<span class="sr-only">{{ __(':count more', ['count' => $rest]) }}</span>
</span>
@endforeach
</span>
@endif
@if ($showMediaIndicator)
{{-- In place of the strip on a row under 28rem: an icon and the full count. --}}
<span id="{{ $baseId }}-media-count" data-slot="queue-row-media-count" data-count="{{ $mediaTotal }}" class="inline-flex shrink-0 items-center gap-1 whitespace-nowrap tabular-nums @md/queue-row:hidden">
<x-ui.icon name="image" size="xs" />
<span aria-hidden="true">{{ $mediaTotal }}</span>
<span class="sr-only">{{ trans_choice(':count media item|:count media items', $mediaTotal, ['count' => $mediaTotal]) }}</span>
</span>
@endif
@if ($attachmentCount > 0)
<span id="{{ $baseId }}-attachments" data-slot="queue-row-attachments" data-count="{{ $attachmentCount }}" class="inline-flex shrink-0 items-center gap-1 whitespace-nowrap tabular-nums">
<x-ui.icon name="paperclip" size="xs" />
<span aria-hidden="true">{{ $attachmentCount }}</span>
<span class="sr-only">{{ trans_choice(':count attachment|:count attachments', $attachmentCount, ['count' => $attachmentCount]) }}</span>
</span>
@endif
@if ($badgeItems !== [])
{{-- One row of whole badges: a badge that does not fit wraps to a second
row that max-h-5 clips, instead of shrinking to a stub. dir="auto" lets
a label wider than the row truncate at its own end under dir="rtl". --}}
<span id="{{ $baseId }}-badges" data-slot="queue-row-badges" class="hidden max-h-5 min-w-0 flex-wrap items-center justify-end gap-1 overflow-hidden sm:flex">
@foreach ($badgeItems as $badge)
<x-ui.badge variant="outline" size="sm" :dot="$badge['tone'] ?? false" dir="auto" class="max-w-full shrink-0" :title="$badge['label']">{{ $badge['label'] }}</x-ui.badge>
@endforeach
</span>
@endif
@if (filled($time))
<time id="{{ $baseId }}-time" data-slot="queue-row-time" @if (filled($datetime)) datetime="{{ $datetime }}" @endif class="shrink-0 whitespace-nowrap tabular-nums">{{ $time }}</time>
@elseif ($liveTime)
<span id="{{ $baseId }}-time" data-slot="queue-row-time" data-live="true" class="shrink-0 whitespace-nowrap">
<x-ui.relative-time :datetime="$datetime" />
</span>
@endif
@if ($hasEnd)
{{ $end }}
@endif
</span>
@endif
</{{ $tag }}>
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