List Cursor
A roving keyboard cursor for any list of rows: the shared createListCursor ES module (j/k and arrows, Home/End, Enter, x to select, Shift+J/K to extend, Escape to clear, Livewire-morph safe) that issue-list and queue-list build on, plus the uiListCursor Alpine data and a thin <x-ui.list-cursor> wrapper for rows marked with data-list-row.
Preview
Use J and K or the arrow keys to move between rows and Enter to open.
- Quarterly report Edited 2 hours ago
- Onboarding checklist Edited yesterday
- Brand guidelines Edited last week
- Release notes draft Edited last month
{{-- Any list whose rows carry data-list-row and data-list-id gets the keyboard cursor. Style [data-focused] and [data-selected] yourself. --}}
<div class="w-full max-w-xl">
<x-ui.list-cursor :label="__('Recent documents')" class="rounded-lg border border-border bg-background">
<ul role="list" class="divide-y divide-border">
@foreach ([
['id' => 'doc-1', 'title' => __('Quarterly report'), 'meta' => __('Edited 2 hours ago')],
['id' => 'doc-2', 'title' => __('Onboarding checklist'), 'meta' => __('Edited yesterday')],
['id' => 'doc-3', 'title' => __('Brand guidelines'), 'meta' => __('Edited last week')],
['id' => 'doc-4', 'title' => __('Release notes draft'), 'meta' => __('Edited last month')],
] as $doc)
<li data-list-row data-list-id="{{ $doc['id'] }}" wire:key="doc-{{ $doc['id'] }}" class="relative flex min-h-11 min-w-0 flex-col justify-center px-4 py-2 text-sm hover:bg-accent/50 data-[focused]:bg-accent/60 data-[selected]:bg-accent">
<a href="#{{ $doc['id'] }}" class="block min-w-0 truncate font-medium leading-6 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">{{ $doc['title'] }}</a>
<span class="truncate text-xs text-muted-foreground">{{ $doc['meta'] }}</span>
</li>
@endforeach
</ul>
</x-ui.list-cursor>
</div>
Installation
php artisan ui:add list-cursor
Note
This component ships an Alpine behavior module at
resources/js/ui/list-cursor.js. Import it once from your bundle so it registers on alpine:init:
import './list-cursor.js';
Registry contract
php artisan ui:add list-cursor
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/list-cursor.blade.php -
resources/js/ui/list-cursor.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: List Cursor (`list-cursor`)
A roving keyboard cursor for any list of rows: the shared createListCursor ES module (j/k and arrows, Home/End, Enter, x to select, Shift+J/K to extend, Escape to clear, Livewire-morph safe) that issue-list and queue-list build on, plus the uiListCursor Alpine data and a thin <x-ui.list-cursor> wrapper for rows marked with data-list-row.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add list-cursor
```
## Usage
```blade
{{-- Any list whose rows carry data-list-row and data-list-id gets the keyboard cursor. Style [data-focused] and [data-selected] yourself. --}}
<div class="w-full max-w-xl">
<x-ui.list-cursor :label="__('Recent documents')" class="rounded-lg border border-border bg-background">
<ul role="list" class="divide-y divide-border">
@foreach ([
['id' => 'doc-1', 'title' => __('Quarterly report'), 'meta' => __('Edited 2 hours ago')],
['id' => 'doc-2', 'title' => __('Onboarding checklist'), 'meta' => __('Edited yesterday')],
['id' => 'doc-3', 'title' => __('Brand guidelines'), 'meta' => __('Edited last week')],
['id' => 'doc-4', 'title' => __('Release notes draft'), 'meta' => __('Edited last month')],
] as $doc)
<li data-list-row data-list-id="{{ $doc['id'] }}" wire:key="doc-{{ $doc['id'] }}" class="relative flex min-h-11 min-w-0 flex-col justify-center px-4 py-2 text-sm hover:bg-accent/50 data-[focused]:bg-accent/60 data-[selected]:bg-accent">
<a href="#{{ $doc['id'] }}" class="block min-w-0 truncate font-medium leading-6 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">{{ $doc['title'] }}</a>
<span class="truncate text-xs text-muted-foreground">{{ $doc['meta'] }}</span>
</li>
@endforeach
</ul>
</x-ui.list-cursor>
</div>
```
## Props
- `selectable` (bool, default `false`) — Enables x, Shift+J/K range selection and Escape. The row checkbox (select selector) mirrors the selection.
- `selected` (array, default `[]`) — The ids selected at first paint (strings; numbers are cast). With x-model or wire:model the bound value wins; rows with data-selected="true" seed it when empty.
- `label` (string|null, default `null`) — The accessible name of the list (role="group"). Null uses "List".
- `row` (string, default `[data-list-row]`) — CSS selector of a row. Rows of a nested list-cursor are ignored.
- `link` (string, default `[data-list-link], a[href]`) — CSS selector of the row's primary link or button; the first match carries the roving tabindex and opens the row.
- `idAttribute` (string, default `data-list-id`) — The row attribute holding its id (used for the cursor, the selection and the events).
- `select` (string, default `[data-list-select]`) — CSS selector of the row's selection checkbox (used only when selectable).
- `shiftArrows` (bool, default `false`) — Shift+ArrowDown/ArrowUp also extend the selection. Off: they move like the plain arrows.
- `eventPrefix` (string, default `list-cursor`) — The prefix of the dispatched events: <prefix>-focus, <prefix>-open and <prefix>-selection-change.
- `hint` (string|null, default `null`) — The visually hidden key description linked with aria-describedby. Null uses a translated default that matches selectable.
## Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- Giving your own list of rows (links or buttons) a keyboard cursor with j/k, Enter to open and x to select, without a bespoke component.
- Writing a new list component that needs the same keyboard model as issue-list and queue-list: import createListCursor from list-cursor.js.
## Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- A tracker list with state, priority and assignee columns: use <x-ui.issue-list>.
- A triage queue with optimistic hide and restore: use <x-ui.queue-list>.
- A menu, listbox or grid widget with its own ARIA pattern: use dropdown, command or data-table.
## Anti-patterns
- Adding display ornament without informational value
## Rules
- Use the `<brok:list-cursor>` tag (or `<x-ui.list-cursor>`) 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/list-cursor
- Registry JSON (files, props, contract): https://brokui.dev/r/open/list-cursor.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
Long Content
{{-- Long unbroken titles truncate inside the row; the cursor and the keys are unchanged. --}}
<div class="w-full max-w-xs">
<x-ui.list-cursor :label="__('Links')" class="rounded-lg border border-border bg-background">
<ul role="list" class="divide-y divide-border">
<li data-list-row data-list-id="a" class="relative flex min-h-11 min-w-0 items-center px-4 py-2 text-sm data-[focused]:bg-accent/60">
<a href="#a" dir="auto" class="block min-w-0 truncate font-medium leading-6 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">{{ __('A deliberately long row title that verifies wrapping, overflow and content expansion without clipping the row') }}</a>
</li>
<li data-list-row data-list-id="b" class="relative flex min-h-11 min-w-0 items-center px-4 py-2 text-sm data-[focused]:bg-accent/60">
<a href="#b" dir="auto" class="block min-w-0 truncate font-medium leading-6 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">https://example.com/a/very/long/unbroken/path/that/never/wraps/on/its/own</a>
</li>
<li data-list-row data-list-id="c" class="relative flex min-h-11 min-w-0 items-center px-4 py-2 text-sm data-[focused]:bg-accent/60">
<a href="#c" dir="auto" class="block min-w-0 truncate font-medium leading-6 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">مراجعة طلب الاسترداد للعميل</a>
</li>
</ul>
</x-ui.list-cursor>
</div>
Selection
{{-- selectable: x toggles, Shift+J/K extend, Escape clears. x-model (or wire:model) binds the ids. --}}
<div x-data="{ picked: ['file-2'] }" class="flex w-full max-w-xl flex-col gap-4">
<x-ui.list-cursor :label="__('Files')" selectable x-model="picked" class="rounded-lg border border-border bg-background">
<ul role="list" class="divide-y divide-border">
@foreach ([
['id' => 'file-1', 'title' => 'invoice-2026-09.pdf'],
['id' => 'file-2', 'title' => 'contract-signed.pdf'],
['id' => 'file-3', 'title' => 'team-photo.jpg'],
['id' => 'file-4', 'title' => 'budget.xlsx'],
['id' => 'file-5', 'title' => 'notes.md'],
] as $file)
<li data-list-row data-list-id="{{ $file['id'] }}" class="relative flex min-h-11 min-w-0 items-center gap-2 px-4 py-2 text-sm hover:bg-accent/50 data-[focused]:bg-accent/60 data-[selected]:bg-accent">
<label class="relative z-10 inline-flex size-6 shrink-0 items-center justify-center">
<x-ui.checkbox data-list-select name="files[]" :value="$file['id']" :aria-label="__('Select :name', ['name' => $file['title']])" />
</label>
<a href="#{{ $file['id'] }}" dir="auto" class="block min-w-0 truncate font-medium leading-6 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">{{ $file['title'] }}</a>
</li>
@endforeach
</ul>
</x-ui.list-cursor>
<p class="text-sm text-muted-foreground">
{{ __('Selected:') }} <span data-testid="list-cursor-selected" x-text="picked.length ? picked.join(', ') : '-'"></span>
</p>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| selectable | bool | false | Enables x, Shift+J/K range selection and Escape. The row checkbox (select selector) mirrors the selection. |
| selected | array | [] | The ids selected at first paint (strings; numbers are cast). With x-model or wire:model the bound value wins; rows with data-selected="true" seed it when empty. |
| label | string | null | null | The accessible name of the list (role="group"). Null uses "List". |
| row | string | [data-list-row] | CSS selector of a row. Rows of a nested list-cursor are ignored. |
| link | string | [data-list-link], a[href] | CSS selector of the row's primary link or button; the first match carries the roving tabindex and opens the row. |
| idAttribute | string | data-list-id | The row attribute holding its id (used for the cursor, the selection and the events). |
| select | string | [data-list-select] | CSS selector of the row's selection checkbox (used only when selectable). |
| shiftArrows | bool | false | Shift+ArrowDown/ArrowUp also extend the selection. Off: they move like the plain arrows. |
| eventPrefix | string | list-cursor | The prefix of the dispatched events: <prefix>-focus, <prefix>-open and <prefix>-selection-change. |
| hint | string | null | null | The visually hidden key description linked with aria-describedby. Null uses a translated default that matches selectable. |
Slots
default— Your rows (for example a <ul role="list"> of <li data-list-row data-list-id="…"> with an <a href> inside). The wrapper adds no row styling: style [data-focused] and [data-selected] yourself.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- createListCursor(root, options) (named export of list-cursor.js) returns a controller: rows(), visibleRows(), rowById(id), cursor (the id), cursorRow(), setCursor(rowOrId), focusRow(row), move(from, delta), focusId(id, { focus }), step(delta, { focus }), toggle(id, on?), extendBy(delta, { focus }), clearSelection(), rowsIn(group), groupOf(row), sync(), schedule(), keydown/click/change/focusin/focusout(event) and destroy(). Options: row, link, id, select, group, owner (selector of the list root when lists nest), selectable, rangeSelect (default true), shiftArrows (default false), getSelected/setSelected (bind the selection to your own state), emit(type, detail, cancelable) or eventPrefix, listen (false: call the handlers from your own x-on), observeAttributes, beforeSync and afterSync hooks. isTyping and setAttr are exported too.
- Moving the cursor from code: focusId(id) puts the cursor on the visible row with that id and step(delta) moves it delta visible rows from the cursor row (clamped at the first and last row); both drop the range anchor, emit focus when the cursor changed and return the cursor id (null when no visible row matches). Focus follows when the list holds focus, or always with { focus: true }; otherwise the row is scrolled into view (block: nearest) and focus stays where it is, for example in a preview pane. The uiListCursor Alpine scope exposes them as focusId(id, options) and move(delta, options), so a shortcut outside the list never has to forward key presses.
- Changing the selection from code: toggle(id, on?) selects, clears or flips one row; extendBy(delta, { focus }) is Shift+J/K without a key press: it moves the cursor delta visible rows (clamped) and selects every row from the anchor to it, and repeated calls grow or shrink the same range until another cursor move starts a new one; clearSelection() clears it. Each change emits selection-change { ids }. extendBy acts only when the list is selectable with rangeSelect, and focus follows as for focusId. The uiListCursor scope exposes them as toggleSelection(id?, selected?) (the cursor row by default; only when selectable), extendSelection(delta, options) and clearSelection(); each returns the selected ids.
- Initial focus: nothing moves focus on page load. The cursor row only gets tabindex=0, so the first Tab on the page still reaches a skip link and Tab enters the list on the cursor row. Focus moves on a key press inside the list, on an explicit { focus: true }, or back to the list after a morph dropped the node that had it. Do not focus a row from your own init code either; a page shortcut that calls focusId or step with { focus: true } after the user presses a key gives keyboard users the same start.
- Keyboard, only while focus is in the list, not in a text field or in a widget that owns its keys (menu, menubar, listbox, dialog, combobox, tablist), and without Ctrl, Meta or Alt: j or ArrowDown and k or ArrowUp move to the next or previous visible row (rows inside a [hidden] ancestor are skipped; from a non-row element such as 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, mirrored under dir="rtl"; Enter on the link opens it natively and from the checkbox clicks the link; x toggles the cursor row's selection; Shift+J and Shift+K (and Shift+Arrow with shiftArrows) move the cursor and select every visible row from the anchor row to it on top of the selection the range started with, so pressing back shrinks the range; any other move, x, a click or Escape drops the anchor; Escape clears the selection. Handled keys call preventDefault, which the hotkeys registry respects.
- Roving tabindex: only the cursor row's link has tabindex=0; the other rows' links and every other control in a row (except inside own-keys widgets) get tabindex=-1, so Tab enters the list once and leaves it.
- Livewire: key every row. A MutationObserver re-applies the tabindex, selection marks (data-selected, checkbox checked) and cursor mark (data-focused while the list has focus) after a morph; the cursor stays on the same row id, falls to the row now in its place when that row is gone, and focus returns to it when the morph removed the focused node.
- Events (wrapper; bubbling CustomEvents on the root): list-cursor-focus { id } when the cursor moves to another row; list-cursor-open { id, href } (cancelable) on a plain click or Enter on the row link, where preventDefault() stops the navigation (a modified or middle click keeps the browser's new-tab behaviour and sends no event); list-cursor-selection-change { ids } after the user changes the selection.
- Binding: x-modelable exposes the selected ids (lcSelected), so wire:model or x-model binds them; setting the outer value updates the rows.
- Without JavaScript the rows are whatever you rendered: plain links stay links and checkboxes still post.
- 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.
- Giving your own list of rows (links or buttons) a keyboard cursor with j/k, Enter to open and x to select, without a bespoke component.
- Writing a new list component that needs the same keyboard model as issue-list and queue-list: import createListCursor from list-cursor.js.
Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- A tracker list with state, priority and assignee columns: use <x-ui.issue-list>.
- A triage queue with optimistic hide and restore: use <x-ui.queue-list>.
- A menu, listbox or grid widget with its own ARIA pattern: use dropdown, command or 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
- Enter Escape ArrowUp ArrowDown ArrowLeft ArrowRight Home End
- Focus
managed
- The wrapper is a role="group" with an accessible name and a visually hidden description of its keys (aria-describedby). Single-key shortcuts act only while focus is inside the list (WCAG 2.1.4).
- Nothing is announced on a move: focus itself carries the row. Give each row link a clear name and, when rows have more parts, an aria-describedby.
- 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="list-cursor-{{ $record->id }}">
{{-- Any list whose rows carry data-list-row and data-list-id gets the keyboard cursor. Style [data-focused] and [data-selected] yourself. --}}
<div class="w-full max-w-xl">
<x-ui.list-cursor :label="__('Recent documents')" class="rounded-lg border border-border bg-background">
<ul role="list" class="divide-y divide-border">
@foreach ([
['id' => 'doc-1', 'title' => __('Quarterly report'), 'meta' => __('Edited 2 hours ago')],
['id' => 'doc-2', 'title' => __('Onboarding checklist'), 'meta' => __('Edited yesterday')],
['id' => 'doc-3', 'title' => __('Brand guidelines'), 'meta' => __('Edited last week')],
['id' => 'doc-4', 'title' => __('Release notes draft'), 'meta' => __('Edited last month')],
] as $doc)
<li data-list-row data-list-id="{{ $doc['id'] }}" wire:key="doc-{{ $doc['id'] }}" class="relative flex min-h-11 min-w-0 flex-col justify-center px-4 py-2 text-sm hover:bg-accent/50 data-[focused]:bg-accent/60 data-[selected]:bg-accent">
<a href="#{{ $doc['id'] }}" class="block min-w-0 truncate font-medium leading-6 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">{{ $doc['title'] }}</a>
<span class="truncate text-xs text-muted-foreground">{{ $doc['meta'] }}</span>
</li>
@endforeach
</ul>
</x-ui.list-cursor>
</div>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{--
List Cursor: gives any list of rows a roving keyboard cursor.
Mark each row with `data-list-row` and its id with `data-list-id`; the
row's first `a[href]` (or an element with `data-list-link`) carries the
roving tabindex. With `selectable`, a checkbox with `data-list-select`
inside the row is its selection box. The rows stay plain links, so the
list works without JavaScript; the behaviour adds j/k and arrow keys,
Home/End, Enter to open, x to select, Shift+J/K to extend the selection
and Escape to clear it. x-model or wire:model binds the selected ids.
The behaviour is the shared list-cursor module (createListCursor), which
issue-list and queue-list also use; call it from your own script when a
list needs more than this wrapper.
--}}
@props([
'selectable' => false,
// The ids selected at first paint. With x-model or wire:model the bound value wins.
'selected' => [],
// The accessible name of the list (role="group"). Null uses "List".
'label' => null,
// Selectors, when your rows use other attributes.
'row' => '[data-list-row]',
'link' => '[data-list-link], a[href]',
'idAttribute' => 'data-list-id',
'select' => '[data-list-select]',
// Shift+Arrow keys also extend the selection (Shift+J/K always do).
'shiftArrows' => false,
// The prefix of the dispatched events: <prefix>-focus, -open, -selection-change.
'eventPrefix' => 'list-cursor',
'hint' => null,
])
@php
$selectable = filter_var($selectable, FILTER_VALIDATE_BOOLEAN);
$label = filled($label) ? (string) $label : __('List');
$hintId = 'list-cursor-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 rows, Enter to open, X to select, Shift with J or K to extend the selection and Escape to clear it.')
: __('Use J and K or the arrow keys to move between rows and Enter to open.'));
$config = [
'row' => (string) $row,
'link' => (string) $link,
'id' => (string) $idAttribute,
'select' => $selectable ? (string) $select : null,
'selectable' => $selectable,
'shiftArrows' => filter_var($shiftArrows, FILTER_VALIDATE_BOOLEAN),
'eventPrefix' => (string) $eventPrefix,
'selected' => array_values(array_map('strval', array_filter((array) $selected, 'is_scalar'))),
];
@endphp
<div
data-slot="list-cursor"
role="group"
aria-label="{{ $label }}"
aria-describedby="{{ $hintId }}"
@if ($selectable) data-selectable="true" @endif
x-data="uiListCursor({{ \Illuminate\Support\Js::from($config) }})"
x-modelable="lcSelected"
{{ $attributes->merge(['class' => 'min-w-0']) }}
>
<p id="{{ $hintId }}" data-slot="list-cursor-hint" class="sr-only">{{ $hint }}</p>
{{ $slot }}
</div>
/**
* List Cursor: a roving keyboard cursor over the rows of a list.
*
* `createListCursor(root, options)` gives any list of rows the same keyboard
* model: j/k and ArrowDown/ArrowUp move the cursor, Home/End jump to the
* first and last row, ArrowLeft/ArrowRight move between the controls of the
* cursor row (mirrored under dir="rtl"), x toggles the row's selection,
* Shift+J/Shift+K (and, opt-in, Shift+Arrow) extend the selection from an
* anchor, Escape clears it and Enter opens the row. issue-list and queue-list
* build on it; the `uiListCursor` Alpine data and `<x-ui.list-cursor>` give it
* to any markup whose rows carry `data-list-row` and `data-list-id`.
*
* State lives here and the DOM follows it: `sync()` writes the roving
* tabindex, the selection marks and the cursor mark, and writes an attribute
* only when its value differs. A MutationObserver runs it again after any
* change to the rows, so a Livewire morph (which puts the server's attributes
* back) or a row added or removed by the server converges in one more pass.
* The cursor is kept by 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, and
* focus returns to it when the morph dropped the focused node.
*
* Keys act only when focus is inside the list, never while typing in a text
* field or inside a widget that owns its keys (menu, listbox, dialog,
* combobox, tablist), and never with Ctrl, Meta or Alt held. A handled key
* calls preventDefault(); the hotkeys registry skips a prevented event.
*
* Code moves the cursor with `focusId(id)` and `step(delta)` (the Alpine
* wrapper exposes them as `focusId(id)` and `move(delta)`), so a shortcut
* outside the list or a preview pane never forwards key presses. The
* selection has the same: `toggle(id, on?)`, `extendBy(delta, { focus? })`
* (Shift+J/K without a key press) and `clearSelection()`.
*
* Nothing here moves focus on page load: the cursor row only gets the
* roving tabindex, so the first Tab still reaches a skip link. Focus moves
* on a key press inside the list, on an explicit { focus: true }, or back
* to the list after a morph dropped the node that had it.
*
* Events go through `options.emit(type, detail, cancelable)`, which returns
* false when a listener canceled the event. The default dispatches a
* bubbling CustomEvent `<eventPrefix>-<type>` on the root:
* focus { id } the cursor moved to another row
* open { id, href } cancelable; preventDefault() stops the link
* selection-change { ids } after the user changed the selection
*/
export const FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]';
// Widgets inside a row that own their keys (a row-actions menu, a picker).
export const OWN_KEYS =
'[role="menu"], [role="menubar"], [role="listbox"], [role="dialog"], [role="combobox"], [role="tablist"]';
const NON_TEXT_INPUTS = [
'checkbox',
'radio',
'button',
'submit',
'reset',
'range',
'color',
'file',
'image',
];
const OBSERVED = ['hidden', 'tabindex', 'data-selected', 'data-focused'];
/** True while the target takes text input, so letter keys belong to it. */
export function isTyping(target) {
if (target.isContentEditable) return true;
const tag = target.tagName;
if (tag === 'TEXTAREA' || tag === 'SELECT') return true;
if (tag === 'INPUT')
return !NON_TEXT_INPUTS.includes((target.getAttribute('type') || 'text').toLowerCase());
return false;
}
/** True while a modal owns focus: the list sits under an inert ancestor, or a native modal <dialog> is open. */
function behindModal(root) {
if (root.closest('[inert]')) return true;
for (const dialog of document.querySelectorAll('dialog[open]')) {
try {
if (dialog.matches(':modal') && !dialog.contains(root)) return true;
} catch {
// An engine without :modal: an open <dialog> is treated as non-modal.
}
}
return false;
}
/** Writes an attribute only when it changes (null removes it). */
export function setAttr(el, name, value) {
if (!el) return;
if (value === null) {
if (el.hasAttribute(name)) el.removeAttribute(name);
} else if (el.getAttribute(name) !== value) {
el.setAttribute(name, value);
}
}
/**
* @param {HTMLElement} root The list element. Keys, clicks and focus are read from it.
* @param {object} options
* @param {string} [options.row] Row selector.
* @param {string} [options.link] The row's primary link (or button) selector; it carries the roving tabindex.
* @param {string|string[]} [options.id] The row attribute holding its id; with a list, the first one the row has.
* @param {?string} [options.select] The row's selection checkbox selector.
* @param {?string} [options.group] Group selector, for rowsIn(group) and groupOf(row).
* @param {?string} [options.owner] Selector of the list root, when lists nest: a row belongs to this list only when row.closest(owner) is the root.
* @param {boolean} [options.selectable] x, Escape and the range keys act.
* @param {boolean} [options.rangeSelect] Shift+J/Shift+K extend the selection (when selectable).
* @param {boolean} [options.shiftArrows] Shift+ArrowDown/Up extend too. Off: they move like the plain arrows.
* @param {() => string[]} [options.getSelected]
* @param {(ids: string[]) => void} [options.setSelected]
* @param {(type: string, detail: object, cancelable: boolean) => boolean} [options.emit]
* @param {string} [options.eventPrefix] Prefix of the default CustomEvent names.
* @param {boolean} [options.listen] Attach the root listeners here. False: the host calls keydown/click/change/focusin/focusout.
* @param {string[]} [options.observeAttributes] Extra attributes whose change re-syncs.
* @param {() => void} [options.beforeSync] Runs first in every sync (for example to apply collapsed groups).
* @param {(state: {rows: Element[], visible: Element[], cursor: ?Element}) => void} [options.afterSync]
*/
export function createListCursor(root, options = {}) {
const o = {
row: '[data-list-row]',
link: '[data-list-link], a[href]',
id: 'data-list-id',
select: null,
group: null,
owner: null,
selectable: false,
rangeSelect: true,
shiftArrows: false,
eventPrefix: 'list-cursor',
listen: true,
observeAttributes: [],
...options,
};
let own = Array.isArray(o.selected) ? o.selected.map(String) : [];
const getSelected = o.getSelected ?? (() => own);
const setSelected =
o.setSelected ??
((ids) => {
own = ids;
schedule();
});
const emit =
o.emit ??
((type, detail, cancelable = false) =>
root.dispatchEvent(
new CustomEvent(`${o.eventPrefix}-${type}`, { detail, bubbles: true, cancelable }),
));
let cursorId = null;
let active = false;
// True while focus belongs to the list, so a morph that drops the
// focused node can put focus back on the cursor row.
let keepFocus = false;
// What had focus inside the list: its id and data-anchor-key, so the same
// control is found again after a morph replaced it. `owned` is true when
// it sat inside a widget that restores focus itself.
let lastFocus = null;
let lastIndex = 0;
let scheduled = false;
let destroyed = false;
// Range selection: the anchor row, the selection before the range, and
// the cursor the last extension left (another move resets the anchor).
let anchor = null;
let base = [];
let rangeCursor = null;
const idAttributes = Array.isArray(o.id) ? o.id : [o.id];
const idOf = (row) => {
for (const name of idAttributes) {
const id = row?.getAttribute(name);
if (id) return id;
}
return null;
};
const linkOf = (row) => row?.querySelector(o.link) ?? null;
const selectedIds = () => (getSelected() ?? []).map(String);
function rows() {
return Array.from(root.querySelectorAll(o.row)).filter(
(row) => (!o.owner || row.closest(o.owner) === root) && idOf(row),
);
}
function isVisible(row) {
const hidden = row.closest('[hidden]');
return !hidden || !root.contains(hidden);
}
const visibleRows = () => rows().filter(isVisible);
const rowById = (id) => rows().find((row) => idOf(row) === String(id)) ?? null;
/** Picks the cursor row: the same id, else the row now at its place. */
function resolveCursor(all, visible) {
const current = all.find((row) => idOf(row) === cursorId);
if (current && visible.includes(current)) return current;
if (visible.length === 0) return null;
if (current) {
// The cursor row was hidden: the next visible row, else the last one.
const after = visible.find(
(row) => current.compareDocumentPosition(row) & Node.DOCUMENT_POSITION_FOLLOWING,
);
return after ?? visible[visible.length - 1];
}
return visible[Math.min(lastIndex, visible.length - 1)];
}
function sync() {
if (destroyed || !root.isConnected) return;
o.beforeSync?.();
const all = rows();
const visible = all.filter(isVisible);
const cursor = resolveCursor(all, visible);
if (cursor) {
cursorId = idOf(cursor);
lastIndex = visible.indexOf(cursor);
}
const selected = new Set(selectedIds());
for (const row of all) {
const id = idOf(row);
const link = linkOf(row);
for (const el of row.querySelectorAll(FOCUSABLE)) {
if (el === link || el.closest(OWN_KEYS)) continue;
setAttr(el, 'tabindex', '-1');
}
setAttr(link, 'tabindex', row === cursor ? '0' : '-1');
setAttr(row, 'data-selected', selected.has(id) ? 'true' : null);
setAttr(row, 'data-focused', row === cursor && active ? 'true' : null);
const box = o.select ? row.querySelector(o.select) : null;
if (box && box.checked !== selected.has(id)) box.checked = selected.has(id);
}
// A morph that dropped focus (it fell to <body>) gets it back on the
// same control (by id, else data-anchor-key), else the cursor row's
// link; never behind a modal, nor from a widget that restores focus.
const focused = document.activeElement;
const dropped =
!focused || focused === document.body || focused === document.documentElement;
if (keepFocus && dropped && !lastFocus?.owned && !behindModal(root)) {
focusTarget(cursor)?.focus({ preventScroll: true });
}
o.afterSync?.({ rows: all, visible, cursor });
}
/** The element that had focus before a morph, found again by id or key; else the cursor row's link. */
function focusTarget(cursor) {
const usable = (el) =>
el &&
root.contains(el) &&
el.isConnected &&
!el.disabled &&
el.getClientRects().length > 0;
if (lastFocus?.id) {
const byId = document.getElementById(lastFocus.id);
if (usable(byId)) return byId;
}
if (lastFocus?.key) {
const byKey = root.querySelector(`[data-anchor-key="${CSS.escape(lastFocus.key)}"]`);
if (usable(byKey) && byKey.matches(FOCUSABLE)) return byKey;
}
return cursor ? linkOf(cursor) : null;
}
function schedule() {
if (scheduled || destroyed) return;
scheduled = true;
queueMicrotask(() => {
scheduled = false;
sync();
});
}
/** Moves the cursor to a row (by element or id); emits focus when it changed. */
function setCursor(target, { silent = false } = {}) {
const row = typeof target === 'string' ? rowById(target) : target;
const id = idOf(row);
if (!id || id === cursorId) return;
cursorId = id;
if (!silent) emit('focus', { id }, false);
}
function focusRow(row) {
if (!row) return;
setCursor(row);
sync();
linkOf(row)?.focus();
}
/** The visible row `delta` rows from the element (a row or a group header). */
function rowFrom(from, delta) {
const visible = visibleRows();
if (visible.length === 0) return null;
const row = from.closest(o.row);
if (row && visible.includes(row)) return visible[visible.indexOf(row) + delta] ?? null;
if (delta > 0)
return (
visible.find(
(item) => from.compareDocumentPosition(item) & Node.DOCUMENT_POSITION_FOLLOWING,
) ?? null
);
return (
[...visible]
.reverse()
.find(
(item) => from.compareDocumentPosition(item) & Node.DOCUMENT_POSITION_PRECEDING,
) ?? null
);
}
function move(from, delta) {
const target = rowFrom(from, delta);
if (target) focusRow(target);
}
/**
* Moves the cursor from code (a shortcut outside the list, a preview
* pane, a server event): to a visible row by id. Focus follows when the
* list holds focus, or always with { focus: true }; otherwise the row is
* only scrolled into view. Emits focus when the cursor changed. Returns
* the cursor id, or null when no visible row has that id.
*/
function focusId(id, { focus = null } = {}) {
const row = id === null || id === undefined ? null : rowById(String(id));
if (!row || !isVisible(row)) return null;
anchor = null;
setCursor(row);
const moveFocus = focus ?? (keepFocus || root.contains(document.activeElement));
sync();
if (moveFocus) linkOf(row)?.focus();
else row.scrollIntoView?.({ block: 'nearest' });
return cursorId;
}
/**
* Moves the cursor `delta` visible rows from the cursor row (clamped to
* the first and last row), like j/k without a key press. Returns the
* cursor id, or null when no row is visible.
*/
function step(delta, options = {}) {
const visible = visibleRows();
if (visible.length === 0) return null;
const current = visible.findIndex((row) => idOf(row) === cursorId);
const from = current === -1 ? Math.min(lastIndex, visible.length - 1) : current;
const index = Math.max(
0,
Math.min(visible.length - 1, from + Math.trunc(Number(delta) || 0)),
);
return focusId(idOf(visible[index]), options);
}
/** ArrowLeft/ArrowRight: the controls of one row, in reading order. */
function moveInRow(row, from, forward) {
const items = Array.from(row.querySelectorAll(FOCUSABLE)).filter(
(el) => !el.closest(OWN_KEYS) && !el.disabled && el.getClientRects().length > 0,
);
const index = items.findIndex((el) => el === from || el.contains(from));
const next = items[index + (forward ? 1 : -1)];
next?.focus();
return Boolean(next);
}
function commitSelection(ids) {
setSelected(ids);
emit('selection-change', { ids: [...ids] }, false);
}
function toggle(id, on = null) {
id = String(id);
anchor = null;
const current = selectedIds();
const has = current.includes(id);
const next = on ?? !has;
if (next === has) return;
commitSelection(next ? [...current, id] : current.filter((item) => item !== id));
}
function clearSelection() {
anchor = null;
if (selectedIds().length === 0) return false;
commitSelection([]);
return true;
}
/**
* Shift+J/K: moves the cursor and selects every row from the anchor to
* it. `focus: false` moves the cursor without moving focus (the row
* scrolls into view instead).
*/
function extend(from, delta, { focus = true } = {}) {
const row = from.closest(o.row);
const visible = visibleRows();
if (!row || !visible.includes(row)) return;
if (anchor === null || rangeCursor !== idOf(row) || !rowById(anchor)) {
anchor = idOf(row);
base = selectedIds();
}
const target = rowFrom(from, delta) ?? row;
const [start, end] = [visible.indexOf(rowById(anchor)), visible.indexOf(target)].sort(
(a, b) => a - b,
);
const range = visible.slice(start, end + 1).map(idOf);
const ids = [...base.filter((id) => !range.includes(id)), ...range];
if (focus) {
if (target !== row) focusRow(target);
} else if (target !== row) {
setCursor(target);
sync();
target.scrollIntoView?.({ block: 'nearest' });
}
rangeCursor = idOf(target);
const before = selectedIds();
if (before.length !== ids.length || before.some((id, index) => id !== ids[index]))
commitSelection(ids);
}
/**
* Extends the selection from code, like Shift+J/K without a key press:
* moves the cursor `delta` visible rows from the cursor row (clamped)
* and selects every row from the anchor to it. Repeated calls grow or
* shrink the same range; any other cursor move starts a new one. Focus
* follows when the list holds focus, or always with { focus: true }.
* Acts only when the list is selectable with range selection. Returns
* the selected ids.
*/
function extendBy(delta, { focus = null } = {}) {
if (!o.selectable || !o.rangeSelect) return selectedIds();
const visible = visibleRows();
if (visible.length === 0) return selectedIds();
const current = visible.findIndex((row) => idOf(row) === cursorId);
const from = current === -1 ? Math.min(lastIndex, visible.length - 1) : current;
const to = Math.max(0, Math.min(visible.length - 1, from + Math.trunc(Number(delta) || 0)));
const moveFocus = focus ?? (keepFocus || root.contains(document.activeElement));
extend(visible[from], to - from, { focus: moveFocus });
if (moveFocus) linkOf(visible[to])?.focus();
return selectedIds();
}
function keydown(event) {
if (
event.defaultPrevented ||
event.isComposing ||
event.ctrlKey ||
event.metaKey ||
event.altKey
)
return;
const target = event.target;
if (!(target instanceof Element) || isTyping(target)) return;
const ownKeys = target.closest(OWN_KEYS);
if (ownKeys && root.contains(ownKeys)) return;
const row = target.closest(o.row);
const rtl = getComputedStyle(root).direction === 'rtl';
const letter = event.key.length === 1;
const ranged = o.selectable && o.rangeSelect && event.shiftKey && (letter || o.shiftArrows);
let handled = true;
switch (event.key) {
case 'j':
case 'ArrowDown':
case 'k':
case 'ArrowUp': {
const delta = event.key === 'j' || event.key === 'ArrowDown' ? 1 : -1;
if (ranged) {
extend(target, delta);
} else {
anchor = null;
move(target, delta);
}
break;
}
case 'J':
case 'K':
if (!ranged) return;
extend(target, event.key === 'J' ? 1 : -1);
break;
case 'Home':
case 'End': {
if (!row) return;
anchor = null;
const visible = visibleRows();
focusRow(event.key === 'Home' ? visible[0] : visible[visible.length - 1]);
break;
}
case 'ArrowRight':
case 'ArrowLeft':
if (!row) return;
handled = moveInRow(row, target, (event.key === 'ArrowRight') !== rtl);
break;
case 'x':
// A disabled selection checkbox keeps the row out of the selection.
if (!row || !o.selectable || (o.select && row.querySelector(o.select)?.disabled))
return;
toggle(idOf(row));
break;
case 'Enter':
// The link opens itself (Enter clicks it); from the
// selection checkbox, Enter opens the row too.
if (!row || !o.select || !target.matches(o.select)) return;
linkOf(row)?.click();
break;
case 'Escape':
handled = clearSelection();
break;
default:
return;
}
if (handled) event.preventDefault();
}
/** A click on a row link: moves the cursor and emits a cancelable open. */
function click(event) {
const target = event.target instanceof Element ? event.target : null;
const link = target?.closest(o.link);
const row = link?.closest(o.row);
if (!row || !root.contains(row) || linkOf(row) !== link) return;
anchor = null;
setCursor(row);
// A disabled row (aria-disabled on its link) takes the cursor but opens nothing.
if (link.getAttribute('aria-disabled') === 'true') {
event.preventDefault();
return;
}
// A modified or middle click keeps the browser's own meaning (a new tab).
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
return;
const open = emit('open', { id: idOf(row), href: link.getAttribute('href') }, true);
if (!open) event.preventDefault();
}
function change(event) {
const box =
o.select && event.target instanceof Element && event.target.matches(o.select)
? event.target
: null;
const row = box?.closest(o.row);
if (!row || !root.contains(row)) return;
setCursor(row);
toggle(idOf(row), box.checked);
}
function focusin(event) {
keepFocus = true;
active = true;
const target = event.target instanceof Element ? event.target : null;
const own = target?.closest(`${OWN_KEYS}, [data-slot="popover-content"]`);
lastFocus = target
? {
id: target.id || null,
key: target.getAttribute('data-anchor-key') || null,
owned: Boolean(own && root.contains(own)),
}
: null;
const row = target ? target.closest(o.row) : null;
if (row && root.contains(row) && idOf(row)) setCursor(row);
schedule();
}
function focusout(event) {
const next = event.relatedTarget;
if (next && root.contains(next)) return;
active = false;
// Focus moved to a real element elsewhere; a null target is a
// window blur or a node the morph removed.
if (next) keepFocus = false;
schedule();
}
const onPointerDown = (event) => {
if (!root.contains(event.target)) keepFocus = false;
};
document.addEventListener('pointerdown', onPointerDown, true);
const observer = new MutationObserver(schedule);
observer.observe(root, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: [...new Set([...OBSERVED, ...idAttributes, ...o.observeAttributes])],
});
const listeners = { keydown, click, change, focusin, focusout };
if (o.listen) {
for (const [name, handler] of Object.entries(listeners))
root.addEventListener(name, handler);
}
sync();
return {
root,
get cursor() {
return cursorId;
},
get active() {
return active;
},
/** True while focus belongs to the list (also after a morph dropped it). */
get holdsFocus() {
return keepFocus;
},
idOf,
linkOf,
rows,
visibleRows,
rowById,
isVisible,
cursorRow: () => rows().find((row) => idOf(row) === cursorId) ?? null,
rowsIn: (group) => rows().filter((row) => row.closest(o.group) === group),
groupOf: (row) => (o.group ? row.closest(o.group) : null),
setCursor,
focusRow,
move,
focusId,
step,
toggle,
extendBy,
clearSelection,
sync,
schedule,
...listeners,
destroy() {
destroyed = true;
observer.disconnect();
document.removeEventListener('pointerdown', onPointerDown, true);
if (o.listen) {
for (const [name, handler] of Object.entries(listeners))
root.removeEventListener(name, handler);
}
},
};
}
document.addEventListener('alpine:init', () => {
// Any list whose rows carry data-list-row and data-list-id (the
// selectors are configurable). Internal state is prefixed (`lc*`): an
// x-model on the root resolves in this scope first.
window.Alpine.data('uiListCursor', (config = {}) => {
let cursor = null;
return {
lcSelected: Array.isArray(config.selected) ? config.selected.map(String) : [],
init() {
const root = this.$el;
cursor = createListCursor(root, {
...config,
owner: config.owner ?? '[data-slot="list-cursor"]',
getSelected: () => this.lcSelected,
setSelected: (ids) => {
this.lcSelected = ids;
},
});
if (this.lcSelected.length === 0 && config.selectable) {
this.lcSelected = cursor
.rows()
.filter((row) => row.dataset.selected === 'true')
.map(cursor.idOf);
}
this.$watch('lcSelected', () => cursor?.schedule());
},
/** Moves the cursor to the row with this id (see focusId in createListCursor). */
focusId(id, options = {}) {
return cursor ? cursor.focusId(id, options) : null;
},
/** Moves the cursor `delta` rows, like j/k without a key press. */
move(delta, options = {}) {
return cursor ? cursor.step(delta, options) : null;
},
/** Selects or clears one row (the cursor row by default); returns the selected ids. */
toggleSelection(id = null, selected = null) {
if (!cursor || !config.selectable) return [...this.lcSelected];
const target = id ?? cursor.cursor;
const row = target === null || target === undefined ? null : cursor.rowById(String(target));
// Only a visible row whose selection checkbox is enabled, as with x.
if (!row || !cursor.isVisible(row)) return [...this.lcSelected];
if (config.select && row.querySelector(config.select)?.disabled) return [...this.lcSelected];
cursor.toggle(String(target), selected === null || selected === undefined ? null : Boolean(selected));
return [...this.lcSelected];
},
/** Shift+J/K without a key press (see extendBy in createListCursor); returns the selected ids. */
extendSelection(delta = 1, options = {}) {
return cursor ? cursor.extendBy(delta, options) : [...this.lcSelected];
},
/** Clears the selection; returns the selected ids (an empty list). */
clearSelection() {
cursor?.clearSelection();
return [...this.lcSelected];
},
destroy() {
cursor?.destroy();
cursor = null;
},
};
});
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.3.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