Property Picker
A compact, Linear-style popover picker that changes one property of a record (state, priority, assignee, labels) with search, check marks, digit shortcuts, a hotkey and wire:model.
Preview
{{-- The properties of one task, each a picker: state, priority, assignee
and labels. Click a property, or focus the row and press S, P, A or L. --}}
<div
x-data="{ state: 'unstarted', priority: 'medium', assignee: null, labels: ['feature', 'design'] }"
data-hotkey-scope="task"
tabindex="0"
class="flex w-full max-w-xl flex-wrap items-center gap-1 rounded-md border border-border p-2 focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
>
<span class="me-2 min-w-0 flex-1 truncate text-sm font-medium text-foreground">{{ __('Draft the launch checklist') }}</span>
<x-ui.property-picker x-model="state" :label="__('State')" :placeholder="__('Change state to…')" hotkey="s">
@foreach ([['backlog', __('Backlog')], ['unstarted', __('Todo')], ['started', __('In progress')], ['completed', __('Done')], ['cancelled', __('Cancelled')]] as [$value, $name])
<x-ui.property-picker.option :value="$value" :label="$name" :shortcut="(string) $loop->iteration">
<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 x-model="priority" variant="icon" :label="__('Priority')" :placeholder="__('Set priority to…')" hotkey="p">
@foreach ([['none', __('No priority')], ['urgent', __('Urgent')], ['high', __('High')], ['medium', __('Medium')], ['low', __('Low')]] as [$value, $name])
<x-ui.property-picker.option :value="$value" :label="$name" :shortcut="(string) $loop->index">
<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 x-model="assignee" variant="icon" :label="__('Assignee')" :placeholder="__('Assign to…')" hotkey="a" :empty-label="__('No assignee')">
@foreach ([[1, 'Ada Lovelace', 'AL'], [2, 'Grace Hopper', 'GH']] as [$id, $person, $initials])
<x-ui.property-picker.option :value="$id" :label="$person">
<x-slot:icon><x-ui.avatar size="xs" :fallback="$initials" class="!size-5 text-2xs" /></x-slot:icon>
</x-ui.property-picker.option>
@endforeach
</x-ui.property-picker>
<x-ui.property-picker x-model="labels" multiple :label="__('Labels')" :placeholder="__('Add labels…')" hotkey="l" :empty-label="__('Add label')" :count-label="__(':count labels')">
@foreach ([['bug', __('Bug')], ['feature', __('Feature')], ['design', __('Design')]] as [$value, $name])
<x-ui.property-picker.option :value="$value" :label="$name">
<x-slot:icon><span class="size-2 rounded-full bg-muted-foreground"></span></x-slot:icon>
</x-ui.property-picker.option>
@endforeach
</x-ui.property-picker>
</div>
Variant options
Side options
Align options
Installation
php artisan ui:add property-picker
Note
This component ships an Alpine behavior module at
resources/js/ui/property-picker.js. Import it once from your bundle so it registers on alpine:init:
import './property-picker.js';
Registry contract
php artisan ui:add property-picker
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/property-picker.blade.php -
resources/views/components/ui/property-picker/trigger.blade.php -
resources/views/components/ui/property-picker/option.blade.php -
resources/js/ui/property-picker.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: Property Picker (`property-picker`)
A compact, Linear-style popover picker that changes one property of a record (state, priority, assignee, labels) with search, check marks, digit shortcuts, a hotkey and wire:model.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add property-picker
```
## Usage
```blade
{{-- The properties of one task, each a picker: state, priority, assignee
and labels. Click a property, or focus the row and press S, P, A or L. --}}
<div
x-data="{ state: 'unstarted', priority: 'medium', assignee: null, labels: ['feature', 'design'] }"
data-hotkey-scope="task"
tabindex="0"
class="flex w-full max-w-xl flex-wrap items-center gap-1 rounded-md border border-border p-2 focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
>
<span class="me-2 min-w-0 flex-1 truncate text-sm font-medium text-foreground">{{ __('Draft the launch checklist') }}</span>
<x-ui.property-picker x-model="state" :label="__('State')" :placeholder="__('Change state to…')" hotkey="s">
@foreach ([['backlog', __('Backlog')], ['unstarted', __('Todo')], ['started', __('In progress')], ['completed', __('Done')], ['cancelled', __('Cancelled')]] as [$value, $name])
<x-ui.property-picker.option :value="$value" :label="$name" :shortcut="(string) $loop->iteration">
<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 x-model="priority" variant="icon" :label="__('Priority')" :placeholder="__('Set priority to…')" hotkey="p">
@foreach ([['none', __('No priority')], ['urgent', __('Urgent')], ['high', __('High')], ['medium', __('Medium')], ['low', __('Low')]] as [$value, $name])
<x-ui.property-picker.option :value="$value" :label="$name" :shortcut="(string) $loop->index">
<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 x-model="assignee" variant="icon" :label="__('Assignee')" :placeholder="__('Assign to…')" hotkey="a" :empty-label="__('No assignee')">
@foreach ([[1, 'Ada Lovelace', 'AL'], [2, 'Grace Hopper', 'GH']] as [$id, $person, $initials])
<x-ui.property-picker.option :value="$id" :label="$person">
<x-slot:icon><x-ui.avatar size="xs" :fallback="$initials" class="!size-5 text-2xs" /></x-slot:icon>
</x-ui.property-picker.option>
@endforeach
</x-ui.property-picker>
<x-ui.property-picker x-model="labels" multiple :label="__('Labels')" :placeholder="__('Add labels…')" hotkey="l" :empty-label="__('Add label')" :count-label="__(':count labels')">
@foreach ([['bug', __('Bug')], ['feature', __('Feature')], ['design', __('Design')]] as [$value, $name])
<x-ui.property-picker.option :value="$value" :label="$name">
<x-slot:icon><span class="size-2 rounded-full bg-muted-foreground"></span></x-slot:icon>
</x-ui.property-picker.option>
@endforeach
</x-ui.property-picker>
</div>
```
## Props
- `value` (string|int|array|null, default `null`) — The first chosen value, or an array with multiple. Later values arrive through wire:model or x-model; the value is kept out of x-data, so a Livewire render never starts the picker again.
- `multiple` (bool, default `false`) — Choose any number of options (labels). Each pick toggles a value, the picker stays open and the list is aria-multiselectable.
- `label` (string|null, default `null`) — The property's name ("Priority"). It names the panel and the list, and the trigger reads "<label>: <value>". Null: the translated "Options".
- `placeholder` (string|null, default `null`) — Placeholder of the search field. Null: the translated "Filter…".
- `emptyText` (string|null, default `null`) — Row shown when no option matches the search. Null: the translated "No options match.".
- `emptyLabel` (string|null, default `null`) — Trigger text while nothing is chosen ("No priority"). Null: the translated "None".
- `countLabel` (string|null, default `null`) — Trigger text for two or more chosen values, with :count. Null: the translated ":count selected".
- `variant` (property|button|icon, default `property`) — Look of the default trigger: property is a quiet row property, button an outlined button, icon shows the icon only and keeps the value readable to screen readers.
- `hotkey` (string|null, default `null`) — Key that opens the picker through the hotkey registry ("p"). Inside an element with data-hotkey-scope the key belongs to that scope, so the focused row's picker opens.
- `hotkeyDescription` (string|null, default `null`) — Help-list text of the hotkey. Null: the translated "Change :property".
- `hotkeyGroup` (string|null, default `null`) — Help-list group of the hotkey. Null: the translated "Properties".
- `closeOnSelect` (bool|null, default `null`) — Close after a pick. Null: close for a single value, stay open with multiple.
- `name` (string|null, default `null`) — Form field name: the chosen value(s) go in hidden inputs (name[] with multiple).
- `disabled` (bool, default `false`) — Disables the trigger and the hotkey.
- `server` (bool, default `false`) — Server mode of the inner command: the picker dispatches property-picker-search { query, sequence, done(), fail(), isCurrent() } and the host renders the options (see command server mode).
- `debounce` (int, default `200`) — Server mode: milliseconds before property-picker-search is dispatched.
- `side` (top|bottom|start|end, default `bottom`) — Preferred side of the panel; it flips when there is no room.
- `align` (start|center|end, default `start`) — Alignment of the panel along the trigger.
- `detached` (bool, default `false`) — No trigger of its own: the picker opens only from property-picker-open { id, anchor } or its hotkey, placed against that element, so one picker can serve every row of a list. Without an anchor it opens against the focused element.
- `actionsLabel` (string|null, default `null`) — Accessible name of the `actions` footer group. Null: the command's translated "Actions".
- `keywords` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `shortcut` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `disabledReason` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `valueExpr` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `labelExpr` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
## Use when
- Use for a small set of mutually exclusive options that users should compare visibly before choosing.
- A row or a detail page changes one property of a record (state, priority, assignee, labels) in place, by click or by a key.
- The choice needs search, icons and a visible check on the current value, in less space than a select field.
## Avoid when
- Do not hide a small option set in a dropdown when recognition and comparison matter.
- A form field with a label and validation text; use combobox or select.
- Running commands rather than setting a value; use command.
- A status pill whose menu previews each status as a pill; use status-select.
## Anti-patterns
- Hiding a small comparable set in a dropdown
## Rules
- Use the `<brok:property-picker>` tag (or `<x-ui.property-picker>`) 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/property-picker
- Registry JSON (files, props, contract): https://brokui.dev/r/open/property-picker.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- Footer actions: the `actions` slot holds command.item rows that show
whatever the query ("Create label “…”", `persistent`). The typed text is
`query` in their Alpine scope. Picking one never changes the value; the
picker dispatches property-picker-action { value, query, context? },
here handled by adding and choosing the new label. --}}
<div
x-data="{
labels: ['bug'],
options: [{ id: 'bug', name: @js(__('Bug')) }, { id: 'feature', name: @js(__('Feature')) }, { id: 'design', name: @js(__('Design')) }],
create(name) {
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
if (!this.options.some((option) => option.id === id)) this.options.push({ id, name });
if (!this.labels.includes(id)) this.labels = [...this.labels, id];
},
}"
class="flex w-full max-w-md items-center gap-2"
>
<x-ui.property-picker
x-model="labels"
multiple
:label="__('Labels')"
:placeholder="__('Add or create labels…')"
:empty-label="__('Add label')"
:count-label="__(':count labels')"
x-on:property-picker-action="$event.detail.query.trim() !== '' ? create($event.detail.query.trim()) : $event.preventDefault()"
>
<template x-for="option in options" :key="option.id">
<x-ui.property-picker.option value-expr="option.id" label-expr="option.name">
<x-slot:icon><span class="size-2 rounded-full bg-muted-foreground"></span></x-slot:icon>
</x-ui.property-picker.option>
</template>
<x-slot:actions>
<x-ui.command.item value="create-label" persistent>
<span class="min-w-0 truncate" x-text="query.trim() === '' ? @js(__('Type a name to create a label')) : @js(__('Create label “:name”')).replace(':name', query.trim())">{{ __('Type a name to create a label') }}</span>
</x-ui.command.item>
</x-slot:actions>
</x-ui.property-picker>
<span data-slot="preview-event-log" class="font-mono text-xs text-muted-foreground" x-text="labels.join(', ') || '—'">bug</span>
</div>
{{-- One detached picker per property serves a whole list: each row's state,
priority and labels are buttons (issue-row `pickers`) that dispatch
property-picker-open { id, anchor, value, context }. The panel opens
beside the clicked property, flips or shifts to stay on screen, and
returns focus to it; the change detail carries the row id. A row with
no labels shows a quiet "Add label" button on hover and focus. --}}
@php
$states = [['backlog', __('Backlog')], ['unstarted', __('Todo')], ['started', __('In progress')], ['review', __('In review')], ['completed', __('Done')]];
$priorities = [['urgent', __('Urgent')], ['high', __('High')], ['medium', __('Medium')], ['low', __('Low')], ['none', __('No priority')]];
$labels = [['bug', __('Bug')], ['feature', __('Feature')], ['design', __('Design')]];
@endphp
<div x-data="{ last: '' }" x-on:change="if ($event.target.matches('[data-slot=property-picker]')) last = $event.detail.context + ' ' + $event.target.id.replace('list-', '') + ': ' + [].concat($event.detail.value).join(', ')" class="flex w-full max-w-4xl flex-col gap-2">
<ul role="list" class="divide-y divide-border overflow-clip rounded-lg border border-border bg-background">
<x-ui.issue-row id="ENG-142" key="ENG-142" href="#ENG-142" :title="__('Sign-in fails when the session cookie has expired')" state="started" priority="urgent" :labels="[['id' => 'bug', 'name' => __('Bug'), 'tone' => 'destructive']]" pickers="list" properties="priority,key,state,labels" />
<x-ui.issue-row id="ENG-150" key="ENG-150" href="#ENG-150" :title="__('Export the invoice list as a spreadsheet')" state="unstarted" priority="medium" :labels="[['id' => 'feature', 'name' => __('Feature')], ['id' => 'design', 'name' => __('Design')]]" pickers="list" properties="priority,key,state,labels" />
<x-ui.issue-row id="ENG-151" key="ENG-151" href="#ENG-151" :title="__('Archive projects that have no open issues')" state="backlog" priority="low" pickers="list" properties="priority,key,state,labels" />
</ul>
<p class="text-sm text-muted-foreground">{{ __('Last change:') }} <span data-slot="preview-event-log" class="font-mono text-xs" x-text="last === '' ? @js(__('none')) : last">{{ __('none') }}</span></p>
<x-ui.property-picker id="list-state" detached :label="__('State')" :placeholder="__('Change state to…')">
@foreach ($states as [$value, $name])
<x-ui.property-picker.option :value="$value" :label="$name" :shortcut="(string) $loop->iteration">
<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')" :placeholder="__('Set priority to…')">
@foreach ($priorities as [$value, $name])
<x-ui.property-picker.option :value="$value" :label="$name" :shortcut="(string) ($loop->iteration % 5)">
<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-labels" detached multiple align="end" :label="__('Labels')" :placeholder="__('Add labels…')">
@foreach ($labels as [$value, $name])
<x-ui.property-picker.option :value="$value" :label="$name">
<x-slot:icon><span class="size-2 rounded-full bg-muted-foreground"></span></x-slot:icon>
</x-ui.property-picker.option>
@endforeach
</x-ui.property-picker>
</div>
{{-- Assignee picker in the button variant, with avatars. The search matches
the name and the e-mail address (keywords). --}}
<div x-data="{ assignee: 2 }" class="flex w-full max-w-md items-center gap-2">
<x-ui.property-picker x-model="assignee" variant="button" :label="__('Assignee')" :placeholder="__('Assign to…')" hotkey="a" :empty-label="__('No assignee')">
<x-ui.property-picker.option value="" :label="__('No assignee')" shortcut="0">
<x-slot:icon>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-5 text-muted-foreground"><circle cx="12" cy="12" r="9" stroke-dasharray="3 3" /></svg>
</x-slot:icon>
</x-ui.property-picker.option>
@foreach ([[1, 'Ada Lovelace', 'AL', 'ada@example.com'], [2, 'Grace Hopper', 'GH', 'grace@example.com'], [3, 'Katherine Johnson', 'KJ', 'katherine@example.com']] as [$id, $person, $initials, $email])
<x-ui.property-picker.option :value="$id" :label="$person" :keywords="$email">
<x-slot:icon><x-ui.avatar size="xs" :fallback="$initials" class="!size-5 text-2xs" /></x-slot:icon>
</x-ui.property-picker.option>
@endforeach
</x-ui.property-picker>
<span data-slot="preview-event-log" class="font-mono text-xs text-muted-foreground" x-text="assignee === null ? '—' : assignee">2</span>
</div>
{{-- A read-only property: the trigger is disabled and its hotkey does not open the picker. --}}
<x-ui.property-picker value="high" disabled :label="__('Priority')" hotkey="p">
@foreach ([['urgent', __('Urgent')], ['high', __('High')], ['low', __('Low')]] 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>
{{-- Nothing chosen yet and one option that cannot be picked: the trigger
shows the empty label, and a search that matches nothing shows the empty row. --}}
<x-ui.property-picker variant="button" :label="__('Milestone')" :empty-label="__('No milestone')" :empty-text="__('No milestone matches.')">
<x-ui.property-picker.option value="beta" :label="__('Public beta')" />
<x-ui.property-picker.option value="launch" :label="__('Launch')" disabled :disabled-reason="__('The launch milestone is closed.')" />
</x-ui.property-picker>
{{-- Error: in server mode the host calls fail(message) when a search cannot
be answered. The list shows the error row and the status region reads it
out; the value does not change. This preview's fake server fails every
search: open the picker and type a name. --}}
<div
x-data="{
assignee: null,
timers: [],
search(detail) {
this.timers.push(setTimeout(() => detail.fail(@js(__('Could not load people. Try again.'))), 300));
},
destroy() { this.timers.forEach(clearTimeout) },
}"
class="flex items-center gap-2"
>
<x-ui.property-picker
x-model="assignee"
variant="button"
server
:label="__('Assignee')"
:placeholder="__('Search people…')"
:empty-label="__('No assignee')"
x-on:property-picker-search="search($event.detail)"
>
<x-ui.property-picker.option value="" :label="__('No assignee')" />
</x-ui.property-picker>
</div>
{{-- Labels picker with `multiple`: each pick toggles a label and the picker
stays open; the trigger shows the one label or the count. --}}
<div x-data="{ labels: ['bug'] }" class="flex w-full max-w-md items-center gap-2">
<x-ui.property-picker x-model="labels" multiple :label="__('Labels')" :placeholder="__('Add labels…')" hotkey="l" :empty-label="__('Add label')" :count-label="__(':count labels')">
@foreach ([['bug', __('Bug')], ['feature', __('Feature')], ['design', __('Design')], ['billing', __('Billing')]] as [$value, $name])
<x-ui.property-picker.option :value="$value" :label="$name">
<x-slot:icon><span class="size-2 rounded-full bg-muted-foreground"></span></x-slot:icon>
</x-ui.property-picker.option>
@endforeach
</x-ui.property-picker>
<span data-slot="preview-event-log" class="font-mono text-xs text-muted-foreground" x-text="labels.join(', ') || '—'">bug</span>
</div>
{{-- Server search: the picker re-dispatches the inner command's search as
`property-picker-search`; the host answers with done() or fail(). This
preview answers from a fake server after 400 ms. While it waits the list
is busy and shows a loading row; type "fail" to see the error row. --}}
<div
x-data="{
people: [
{ id: 1, name: 'Ada Lovelace', initials: 'AL' },
{ id: 2, name: 'Grace Hopper', initials: 'GH' },
{ id: 3, name: 'Katherine Johnson', initials: 'KJ' },
{ id: 4, name: 'Margaret Hamilton', initials: 'MH' },
],
answer: { sequence: 0, hits: [] },
assignee: null,
timers: [],
init() { this.answer.hits = this.people.slice(0, 2) },
search(detail) {
const query = detail.query.trim().toLowerCase();
this.timers.push(setTimeout(() => {
if (query.includes('fail')) {
detail.fail();
return;
}
if (! detail.isCurrent()) return;
this.answer = { sequence: detail.sequence, hits: this.people.filter((person) => person.name.toLowerCase().includes(query)) };
detail.done();
}, 400));
},
destroy() { this.timers.forEach(clearTimeout) },
}"
class="flex items-center gap-2"
>
<x-ui.property-picker
x-model="assignee"
variant="button"
server
:label="__('Assignee')"
:placeholder="__('Search people…')"
:empty-label="__('No assignee')"
x-on:property-picker-search="search($event.detail)"
>
<x-ui.command.group sequence-expr="answer.sequence">
<template x-for="person in answer.hits" :key="answer.sequence + '-' + person.id">
<x-ui.property-picker.option value-expr="person.id" label-expr="person.name">
<x-slot:icon><x-ui.avatar size="xs" class="!size-5 text-2xs"><span x-text="person.initials"></span></x-ui.avatar></x-slot:icon>
</x-ui.property-picker.option>
</template>
</x-ui.command.group>
</x-ui.property-picker>
<span data-slot="preview-event-log" class="font-mono text-xs text-muted-foreground" x-text="assignee === null ? '—' : assignee">—</span>
</div>
Long Content
{{-- Long content: long option labels truncate in the panel, the trigger
truncates inside a narrow column, and a long list scrolls in the panel
while the search field stays in place. --}}
<div x-data="{ project: 'platform-migration' }" class="flex w-full max-w-48 items-center gap-2">
<x-ui.property-picker x-model="project" variant="button" :label="__('Project')" :placeholder="__('Find a project…')" class="min-w-0">
@foreach ([
['platform-migration', __('Platform migration to the new billing and invoicing infrastructure')],
['onboarding', __('Customer onboarding flow for teams with more than fifty members')],
['reporting', __('Quarterly reporting')],
['accessibility', __('Accessibility review of every public form and its error messages')],
['search', __('Search relevance')],
['mobile', __('Mobile web performance')],
['exports', __('CSV and spreadsheet exports for finance')],
['audit', __('Audit log retention')],
['sso', __('Single sign-on for enterprise workspaces')],
['localization', __('Localization of the settings area')],
] as [$value, $name])
<x-ui.property-picker.option :value="$value" :label="$name">
<x-slot:icon><span class="size-2 rounded-xs bg-muted-foreground"></span></x-slot:icon>
</x-ui.property-picker.option>
@endforeach
</x-ui.property-picker>
</div>
{{-- Priority picker in the icon variant, as a row property: the signal-bar
icon shows, the name is read by screen readers. Digits 0 to 4 pick. --}}
<div x-data="{ priority: 'high' }" class="flex w-full max-w-md items-center gap-2 rounded-md border border-border p-2">
<x-ui.property-picker x-model="priority" variant="icon" :label="__('Priority')" :placeholder="__('Set priority to…')" hotkey="p" :empty-label="__('No priority')">
@foreach ([['none', __('No priority')], ['urgent', __('Urgent')], ['high', __('High')], ['medium', __('Medium')], ['low', __('Low')]] as [$value, $name])
<x-ui.property-picker.option :value="$value" :label="$name" :shortcut="(string) ($loop->index)">
<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>
<span class="min-w-0 truncate text-sm text-foreground">{{ __('Invite email lands in spam') }}</span>
<span data-slot="preview-event-log" class="ms-auto font-mono text-xs text-muted-foreground" x-text="priority">high</span>
</div>
{{-- State picker: the workflow states with their icons and digit shortcuts.
Press S while the row has focus, type to filter, or press a digit. --}}
<div x-data="{ state: 'started' }" data-hotkey-scope="issue-row" tabindex="0" class="flex w-full max-w-md items-center gap-2 rounded-md border border-border p-2 focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring">
<x-ui.property-picker x-model="state" :label="__('State')" :placeholder="__('Change state to…')" hotkey="s" id="state-picker">
@foreach ([['backlog', __('Backlog')], ['unstarted', __('Todo')], ['started', __('In progress')], ['review', __('In review')], ['completed', __('Done')], ['cancelled', __('Cancelled')]] as [$value, $name])
<x-ui.property-picker.option :value="$value" :label="$name" :shortcut="(string) $loop->iteration">
<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>
<span class="min-w-0 truncate text-sm text-foreground">{{ __('Draft the launch checklist') }}</span>
<span data-slot="preview-event-log" class="ms-auto font-mono text-xs text-muted-foreground" x-text="state">started</span>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | int | array | null | null | The first chosen value, or an array with multiple. Later values arrive through wire:model or x-model; the value is kept out of x-data, so a Livewire render never starts the picker again. |
| multiple | bool | false | Choose any number of options (labels). Each pick toggles a value, the picker stays open and the list is aria-multiselectable. |
| label | string | null | null | The property's name ("Priority"). It names the panel and the list, and the trigger reads "<label>: <value>". Null: the translated "Options". |
| placeholder | string | null | null | Placeholder of the search field. Null: the translated "Filter…". |
| emptyText | string | null | null | Row shown when no option matches the search. Null: the translated "No options match.". |
| emptyLabel | string | null | null | Trigger text while nothing is chosen ("No priority"). Null: the translated "None". |
| countLabel | string | null | null | Trigger text for two or more chosen values, with :count. Null: the translated ":count selected". |
| variant | property | button | icon | property | Look of the default trigger: property is a quiet row property, button an outlined button, icon shows the icon only and keeps the value readable to screen readers. |
| hotkey | string | null | null | Key that opens the picker through the hotkey registry ("p"). Inside an element with data-hotkey-scope the key belongs to that scope, so the focused row's picker opens. |
| hotkeyDescription | string | null | null | Help-list text of the hotkey. Null: the translated "Change :property". |
| hotkeyGroup | string | null | null | Help-list group of the hotkey. Null: the translated "Properties". |
| closeOnSelect | bool | null | null | Close after a pick. Null: close for a single value, stay open with multiple. |
| name | string | null | null | Form field name: the chosen value(s) go in hidden inputs (name[] with multiple). |
| disabled | bool | false | Disables the trigger and the hotkey. |
| server | bool | false | Server mode of the inner command: the picker dispatches property-picker-search { query, sequence, done(), fail(), isCurrent() } and the host renders the options (see command server mode). |
| debounce | int | 200 | Server mode: milliseconds before property-picker-search is dispatched. |
| side | top | bottom | start | end | bottom | Preferred side of the panel; it flips when there is no room. |
| align | start | center | end | start | Alignment of the panel along the trigger. |
| detached | bool | false | No trigger of its own: the picker opens only from property-picker-open { id, anchor } or its hotkey, placed against that element, so one picker can serve every row of a list. Without an anchor it opens against the focused element. |
| actionsLabel | string | null | null | Accessible name of the `actions` footer group. Null: the command's translated "Actions". |
| keywords | mixed | null | null | Declared by @props in the registry Blade source. |
| shortcut | mixed | null | null | Declared by @props in the registry Blade source. |
| disabledReason | mixed | null | null | Declared by @props in the registry Blade source. |
| valueExpr | mixed | null | null | Declared by @props in the registry Blade source. |
| labelExpr | mixed | null | null | Declared by @props in the registry Blade source. |
Slots
default— The property-picker.option rows (optionally in command.group, also with sequence in server mode).trigger— A custom trigger: a property-picker.trigger (or popover.trigger). A slot inside property-picker.trigger replaces the value display with server-rendered content.option.icon— On property-picker.option, the icon or avatar; it also shows in the trigger for the chosen value.actions— Footer actions of the list (the command list's `actions` slot): command.item rows, usually `persistent`, such as "Create label “<query>”". The typed text is `query` in Alpine scope (`x-text="query"`). Picking one never changes the value; it dispatches property-picker-action. Give each action a value no option uses.x-ui.property-picker.trigger— Installed subcomponent from the registry item.x-ui.property-picker.option— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Click the trigger, press the hotkey, or dispatch property-picker-open { id } on window to open the panel; focus moves to the search field.
- Open from any element: dispatch property-picker-open { id, anchor, value?, context? } on window (from Alpine: $dispatch('property-picker-open', { id: 'state-picker', anchor: $el })). The panel is placed against anchor, flips and shifts to stay inside the viewport, and returns focus to anchor on close; an anchor with aria-haspopup gets aria-expanded. The same anchor again closes the panel; another anchor moves it. value sets the chosen value(s) first without a change event, and context (a row id) comes back in every change detail { value, context } until the next open.
- On open the (first) chosen option is the active option and scrolls into view, so Enter keeps the value and the arrow keys start from it; with nothing chosen (or the chosen option disabled) the first option is active. Typing filters the options (label and keywords) and makes the first match active; ArrowUp/Down, Home and End move the active option; Enter picks it. A digit picks the option with that shortcut while the search field is empty.
- A single pick closes the panel and returns focus to the trigger; with multiple each pick toggles a value and the panel stays open. Escape and Tab close the panel and return focus to the trigger; a click outside closes it.
- Every pick sets the modelable value (wire:model with any modifier, x-model) and dispatches a bubbling change event { value } (with context when the opening event sent one) from the root; closing dispatches blur, so wire:model.blur and .live.blur send the value then.
- The trigger shows the chosen option's icon and label (or the count with multiple) and updates at once; it carries wire:ignore, so a Livewire render never clears it.
- The panel is anchored to its trigger (or the event's anchor) and teleported to the page end, so a scrolling or clipping row container never cuts it off; it is measured at its settled size, so near a viewport edge it shifts inside instead of ending past it.
- property-picker.option takes value (its type is kept; an empty value stands for nothing chosen), label (null: the slot text), keywords (more words the search matches), shortcut (a digit), disabled and disabledReason, and for options inside <template x-for> value-expr and label-expr; its icon slot also shows in the trigger.
- property-picker.trigger (in the trigger slot) takes variant property|button|icon; a slot inside it replaces the value display with server-rendered content for a host that renders again after each change.
- An `actions` row (arrow keys and Enter reach it, like an option) never changes the value: the root dispatches a bubbling, cancelable property-picker-action { value: the row value, query: the typed text, context? } (the shape of combobox-action) and the panel closes and returns focus; preventDefault() keeps the panel open. The typed text is also `query` in the rows' Alpine scope.
- 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
Choose one option from a small visible set.
Use when
- Use for a small set of mutually exclusive options that users should compare visibly before choosing.
- A row or a detail page changes one property of a record (state, priority, assignee, labels) in place, by click or by a key.
- The choice needs search, icons and a visible check on the current value, in less space than a select field.
Avoid when
- Do not hide a small option set in a dropdown when recognition and comparison matter.
- A form field with a label and validation text; use combobox or select.
- Running commands rather than setting a value; use command.
- A status pill whose menu previews each status as a pill; use status-select.
Use instead
- Select or combobox for long option sets
Anti-patterns
- Hiding a small comparable set in a dropdown
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Tab
- Focus
managed
- The trigger is a button with aria-expanded and aria-haspopup; its name reads "<label>: <value>", also in the icon variant.
- The search field is a combobox with aria-activedescendant; the listbox reports aria-multiselectable with multiple, and each option's aria-selected is its chosen state, not the keyboard position.
- Digit shortcuts are announced through aria-keyshortcuts on the option; the visible digit is decorative.
- The check mark and the multiple-choice box are decorative; aria-selected carries the state.
- An external anchor is a real button: the issue-row property triggers carry aria-haspopup="dialog" and get aria-expanded while their picker is open.
- 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 wire:ignore to the component root because its behavior owns rendered DOM.
<div wire:ignore>
{{-- The properties of one task, each a picker: state, priority, assignee
and labels. Click a property, or focus the row and press S, P, A or L. --}}
<div
x-data="{ state: 'unstarted', priority: 'medium', assignee: null, labels: ['feature', 'design'] }"
data-hotkey-scope="task"
tabindex="0"
class="flex w-full max-w-xl flex-wrap items-center gap-1 rounded-md border border-border p-2 focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
>
<span class="me-2 min-w-0 flex-1 truncate text-sm font-medium text-foreground">{{ __('Draft the launch checklist') }}</span>
<x-ui.property-picker x-model="state" :label="__('State')" :placeholder="__('Change state to…')" hotkey="s">
@foreach ([['backlog', __('Backlog')], ['unstarted', __('Todo')], ['started', __('In progress')], ['completed', __('Done')], ['cancelled', __('Cancelled')]] as [$value, $name])
<x-ui.property-picker.option :value="$value" :label="$name" :shortcut="(string) $loop->iteration">
<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 x-model="priority" variant="icon" :label="__('Priority')" :placeholder="__('Set priority to…')" hotkey="p">
@foreach ([['none', __('No priority')], ['urgent', __('Urgent')], ['high', __('High')], ['medium', __('Medium')], ['low', __('Low')]] as [$value, $name])
<x-ui.property-picker.option :value="$value" :label="$name" :shortcut="(string) $loop->index">
<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 x-model="assignee" variant="icon" :label="__('Assignee')" :placeholder="__('Assign to…')" hotkey="a" :empty-label="__('No assignee')">
@foreach ([[1, 'Ada Lovelace', 'AL'], [2, 'Grace Hopper', 'GH']] as [$id, $person, $initials])
<x-ui.property-picker.option :value="$id" :label="$person">
<x-slot:icon><x-ui.avatar size="xs" :fallback="$initials" class="!size-5 text-2xs" /></x-slot:icon>
</x-ui.property-picker.option>
@endforeach
</x-ui.property-picker>
<x-ui.property-picker x-model="labels" multiple :label="__('Labels')" :placeholder="__('Add labels…')" hotkey="l" :empty-label="__('Add label')" :count-label="__(':count labels')">
@foreach ([['bug', __('Bug')], ['feature', __('Feature')], ['design', __('Design')]] as [$value, $name])
<x-ui.property-picker.option :value="$value" :label="$name">
<x-slot:icon><span class="size-2 rounded-full bg-muted-foreground"></span></x-slot:icon>
</x-ui.property-picker.option>
@endforeach
</x-ui.property-picker>
</div>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:property-picker
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
// The chosen value (a string or number), or with `multiple` an array.
// Later values arrive through wire:model / x-model.
'value' => null,
// Choose any number of options; the picker then stays open on a pick.
'multiple' => false,
// The property's name ("Priority"). It names the panel and the list, and
// the trigger reads "<label>: <value>" to assistive technology.
'label' => null,
// Placeholder of the search field. Null: the translated "Filter…".
'placeholder' => null,
// Row shown when no option matches the search. Null: the translated default.
'emptyText' => null,
// Trigger text while nothing is chosen ("No priority"). Null: "None".
'emptyLabel' => null,
// Trigger text for two or more chosen values. Null: ":count selected".
'countLabel' => null,
// Look of the default trigger: property (a quiet row property), button
// (an outlined button) or icon (the icon only, the text for screen readers).
'variant' => 'property',
// Key that opens the picker through the hotkey registry ("p"). Inside an
// element with data-hotkey-scope the key belongs to that scope, so the
// focused row's picker opens.
'hotkey' => null,
'hotkeyDescription' => null,
'hotkeyGroup' => null,
// Close after a pick. Null: close for a single value, stay open with `multiple`.
'closeOnSelect' => null,
// Form field name; the chosen value(s) go in hidden inputs (name[] with `multiple`).
'name' => null,
'disabled' => false,
// Server mode of the inner command: the host supplies the options for the
// typed text through `property-picker-search` (see command `server`).
'server' => false,
'debounce' => 200,
// Preferred side and alignment of the panel.
'side' => 'bottom',
'align' => 'start',
// No trigger of its own: the picker opens only from
// `property-picker-open` { id, anchor } (or its hotkey), anchored to that
// element, so one picker can serve every row of a list.
'detached' => false,
// Name of the `actions` footer group. Null: the command's "Actions".
'actionsLabel' => null,
])
@php
// Accept a string, a backed enum or a Stringable for `variant`.
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant);
$label = filled($label) ? (string) $label : __('Options');
$config = [
'multiple' => (bool) $multiple,
'closeOnSelect' => $closeOnSelect === null ? ! $multiple : (bool) $closeOnSelect,
'disabled' => (bool) $disabled,
'detached' => (bool) $detached,
'texts' => [
'empty' => filled($emptyLabel) ? (string) $emptyLabel : __('None'),
'count' => filled($countLabel) ? (string) $countLabel : __(':count selected'),
],
];
if (filled($hotkey)) {
$config += [
'hotkey' => (string) $hotkey,
'hotkeyDescription' => filled($hotkeyDescription) ? (string) $hotkeyDescription : __('Change :property', ['property' => $label]),
'hotkeyGroup' => filled($hotkeyGroup) ? (string) $hotkeyGroup : __('Properties'),
];
}
$initial = $multiple
? array_values(array_filter((array) $value, fn ($item) => $item !== null && $item !== ''))
: (is_array($value) ? ($value[0] ?? null) : $value);
@endphp
{{--
Property picker (Linear-style): a compact popover that changes one
property of a record. It composes popover (anchored placement, outside
click, Escape and focus return) and command (search field, filtering,
arrow keys, Enter); this root holds the chosen value(s) for wire:model and
x-model and dispatches `change` { value, context? }. Any element can open
it: dispatch `property-picker-open` { id, anchor, value?, context? } and
the panel is placed against `anchor` and returns focus to it on close. The first value sits in a data
attribute, so a Livewire render never starts the component again.
The `actions` slot is the command list's footer: command.item rows
(usually `persistent`, such as "Create label “<query>”"; the typed text
is `query` in Alpine scope). Picking one never changes the value: the
picker dispatches `property-picker-action` { value, query, context? }
and closes, unless a listener calls preventDefault().
--}}
<div
x-data="uiPropertyPicker(@js($config))"
x-modelable="model"
data-slot="property-picker"
data-value="{{ json_encode($initial) }}"
data-multiple="{{ $multiple ? 'true' : 'false' }}"
@if ($disabled) data-disabled="true" @endif
@if ($detached) data-detached="true" @endif
x-on:property-picker-open.window="openFromEvent($event)"
{{-- Detached, the root has no box: its panel is teleported and opens beside the anchor. --}}
{{ $attributes->merge(['class' => $detached ? 'hidden' : 'inline-flex min-w-0 max-w-full']) }}
>
@if (filled($name))
<template x-for="item in picked" :key="String(item)">
<input type="hidden" name="{{ $multiple ? $name.'[]' : $name }}" x-bind:value="item" />
</template>
@endif
<x-ui.popover anchored class="min-w-0 max-w-full" x-effect="onPickerToggle(open)">
@if ($detached)
{{-- Detached: the panel opens from the element named in property-picker-open. --}}
@elseif (isset($trigger))
{{ $trigger }}
@else
<x-ui.property-picker.trigger :variant="$variant" />
@endif
<x-ui.popover.content :side="$side" :align="$align" :aria-label="$label" data-property-picker-panel class="!w-64 overflow-hidden !p-0">
<x-ui.command
:server="$server"
:debounce="$debounce"
:clear-on-escape="false"
class="!rounded-none !border-0 !bg-transparent"
x-on:command-select="pickFromCommand($event)"
x-on:command-search="forwardSearch($event)"
x-on:keydown="onPickerKeydown($event)"
>
<x-ui.command.input :placeholder="filled($placeholder) ? $placeholder : __('Filter…')" :aria-label="$label" />
<x-ui.command.list :aria-label="$label" :aria-multiselectable="$multiple ? 'true' : null" :actions-label="$actionsLabel">
<x-ui.command.empty>{{ filled($emptyText) ? $emptyText : __('No options match.') }}</x-ui.command.empty>
{{ $slot }}
@isset($actions)
<x-slot:actions>{{ $actions }}</x-slot:actions>
@endisset
</x-ui.command.list>
</x-ui.command>
</x-ui.popover.content>
</x-ui.popover>
</div>
@aware([
'label' => null,
'disabled' => false,
])
@props([
// property: a quiet row property. button: an outlined button. icon: the
// icon only; the value stays readable to screen readers.
'variant' => 'property',
])
@php
// Accept a string, a backed enum or a Stringable for `variant`.
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant);
$variant = in_array($variant, ['property', 'button', 'icon'], true) ? $variant : 'property';
$shared = 'min-w-0 gap-2 text-sm text-foreground transition-colors hover:bg-accent hover:text-accent-foreground aria-expanded:bg-accent focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent motion-reduce:transition-none';
$variants = [
'property' => 'h-8 max-w-full !justify-start rounded-sm px-2',
'button' => 'h-8 max-w-full !justify-start rounded-md border border-input bg-background px-3 shadow-sm',
'icon' => 'size-8 rounded-sm',
];
$name = filled($label) ? (string) $label : __('Options');
@endphp
{{-- The trigger of a property picker. With no slot it shows the chosen
value's icon and label (or the count), kept current in the browser; a
slot replaces that with server-rendered content, for a host that renders
again after each change (wire:model.live). --}}
<x-ui.popover.trigger
data-property-picker-trigger
data-variant="{{ $variant }}"
aria-haspopup="dialog"
:disabled="(bool) $disabled"
{{ $attributes->merge(['class' => $shared.' '.$variants[$variant]]) }}
>
<span class="sr-only">{{ $name }}:</span>
@if ($slot->isEmpty())
<span data-slot="property-picker-value" wire:ignore class="inline-flex min-w-0 items-center gap-2" x-effect="renderPickerValue($el, @js($variant === 'icon' ? 'icon' : 'full'))"></span>
@else
<span data-slot="property-picker-value" class="inline-flex min-w-0 items-center gap-2">{{ $slot }}</span>
@endif
</x-ui.popover.trigger>
@aware([
'multiple' => false,
])
@props([
// The option's value; its type (a string or a number) is kept for wire:model.
'value' => null,
// The option's name. Null: the text of the slot.
'label' => null,
// More words the search matches ("p1 urgent").
'keywords' => null,
// A digit that picks the option while the search field is empty ("1").
'shortcut' => null,
'disabled' => false,
'disabledReason' => null,
// Client data: Alpine expressions for the value and the name, for an
// option rendered inside <template x-for> (value-expr="person.id",
// label-expr="person.name"). They replace value and label.
'valueExpr' => null,
'labelExpr' => null,
])
@php
$dynamic = filled($valueExpr);
$text = filled($label) ? (string) $label : html_entity_decode(trim(strip_tags((string) $slot)), ENT_QUOTES);
$valueJs = $dynamic ? '('.$valueExpr.')' : \Illuminate\Support\Js::from($value)->toHtml();
$labelJs = $dynamic ? '('.(filled($labelExpr) ? $labelExpr : $valueExpr).')' : \Illuminate\Support\Js::from($text)->toHtml();
$shortcutText = $shortcut === null || $shortcut === '' ? null : (string) $shortcut;
$keywordsExpr = $dynamic ? (filled($keywords) ? $labelJs.' + '.\Illuminate\Support\Js::from(' '.$keywords)->toHtml() : $labelJs) : null;
@endphp
{{-- One option of a property picker: a command item whose aria-selected
reports the chosen value(s); the icon slot also shows in the trigger. --}}
<x-ui.command.item
:value="$dynamic ? null : $value"
:keywords="$dynamic ? null : trim($text.' '.$keywords)"
:value-expr="$dynamic ? $valueExpr : null"
:keywords-expr="$keywordsExpr"
:disabled="$disabled"
:disabled-reason="$disabledReason"
:selected-expr="'isPicked('.$valueJs.')'"
:aria-keyshortcuts="$shortcutText"
data-property-picker-option
{{ $attributes }}
>
@if ($multiple)
<span aria-hidden="true" class="flex size-4 shrink-0 items-center justify-center rounded-xs border border-input text-background" x-bind:class="isPicked({{ $valueJs }}) ? 'border-foreground bg-foreground' : 'bg-background'">
<svg x-show="isPicked({{ $valueJs }})" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="size-3">
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
@endif
@isset($icon)
<span data-slot="property-picker-icon" class="inline-flex shrink-0 items-center">{{ $icon }}</span>
@endisset
<span
data-slot="property-picker-label"
class="min-w-0 flex-1 truncate"
x-init="registerPickerOption({{ $valueJs }}, {{ $labelJs }}, @js($shortcutText), $el.closest('[data-slot=command-item]'), @js(! $disabled))"
@if ($dynamic && $slot->isEmpty()) x-text="{{ $labelJs }}" @endif
>@unless ($dynamic && $slot->isEmpty()){{ $slot->isEmpty() ? $text : $slot }}@endunless</span>
@unless ($multiple)
<svg aria-hidden="true" x-show="isPicked({{ $valueJs }})" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 text-foreground">
<path d="M20 6 9 17l-5-5" />
</svg>
@endunless
@if ($shortcutText !== null)
<x-ui.command.shortcut class="w-4 text-center">{{ $shortcutText }}</x-ui.command.shortcut>
@endif
</x-ui.command.item>
/**
* Property picker behavior: a compact, Linear-style picker for one property
* of a record (state, priority, assignee, labels).
*
* It composes two items and adds only the selection model:
* - `popover` (anchored) owns placement, outside click, Escape and focus
* return to the trigger;
* - `command` owns the search field, filtering, the active option
* (aria-activedescendant) and Arrow/Home/End/Enter;
* - this component owns the chosen value(s): x-modelable (`wire:model`,
* `x-model`), the `change` event, the check marks (aria-selected through
* command.item's selected-expr), digit shortcuts, the hotkey that opens
* the picker and the trigger's value display.
*
* Any element can open the picker: `property-picker-open` on window with
* { id, anchor, value?, context? } opens the picker with that id against
* `anchor` (popover's showFrom()), so the panel sits beside that element and
* focus returns to it on close. `value` sets the chosen value(s) first
* without a change event, and `context` (a row id) comes back in every
* `change` detail until the next open, so one picker can serve a whole list.
* With `detached` the picker has no trigger of its own.
*
* The panel is teleported to <body>, so events from inside it do not bubble
* through the picker root. Handlers inside the panel run in the picker's
* scope and dispatch on the stored root element instead.
*
* Methods here run with `this` bound to the scope of the calling element,
* which can be the command or the popover inside the picker; the picker's
* own fields therefore have names that no inner component uses.
*/
import './command.js';
import './popover.js';
import './hotkeys.js';
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiPropertyPicker', (config = {}) => ({
pickerMultiple: config.multiple ?? false,
pickerClosesOnSelect: config.closeOnSelect ?? !(config.multiple ?? false),
pickerTexts: config.texts ?? {},
// The chosen values, with the types the options gave them.
picked: [],
// [{ value, label, shortcut, enabled, el }] in registration order;
// `el` is the option's command item.
pickerOptions: [],
pickerRoot: null,
pickerWasOpen: false,
pickerHotkey: null,
// The `context` of the last property-picker-open (a row id), handed
// back in the change detail. Null: not sent.
pickerContext: null,
init() {
this.pickerRoot = this.$el;
let value = null;
try {
value = JSON.parse(this.$el.dataset.value ?? 'null');
} catch {
value = null;
}
this.picked = this.normalizePicked(value);
if (config.hotkey) {
this.pickerHotkey = this.$hotkeys.register(config.hotkey, () => {
this.pickerContext = null;
this.openPicker();
}, {
description: config.hotkeyDescription ?? '',
group: config.hotkeyGroup ?? '',
enabled: () => !config.disabled,
visible: true,
});
}
},
destroy() {
this.pickerHotkey?.unregister();
this.pickerHotkey = null;
},
/**
* Modelable value: the chosen value (null when none) or, with
* `multiple`, the array of chosen values.
*/
get model() {
return this.pickerMultiple ? [...this.picked] : (this.picked[0] ?? null);
},
set model(value) {
const next = this.normalizePicked(value);
if (JSON.stringify(next.map(String)) === JSON.stringify(this.picked.map(String))) return;
this.picked = next;
},
normalizePicked(value) {
const list = Array.isArray(value) ? value : (value == null || value === '' ? [] : [value]);
return this.pickerMultiple ? list.filter((item) => item != null && item !== '') : list.slice(0, 1);
},
/** An empty option value ("No assignee") stands for "nothing chosen". */
isPicked(value) {
if (!this.pickerMultiple && (value === '' || value == null)) return this.picked.length === 0;
return this.picked.some((item) => String(item) === String(value));
},
/** An option registers its value, label, digit and element. */
registerPickerOption(value, label, shortcut, el, enabled = true) {
const entry = { value, label: String(label ?? value), shortcut: shortcut == null ? null : String(shortcut), enabled, el };
const index = this.pickerOptions.findIndex((option) => option.el === el);
if (index === -1) {
this.pickerOptions.push(entry);
if (el && typeof window.Alpine?.onElRemoved === 'function') {
window.Alpine.onElRemoved(el, () => {
this.pickerOptions = this.pickerOptions.filter((option) => option.el !== el);
});
}
} else {
this.pickerOptions.splice(index, 1, entry);
}
},
/**
* command-select hands over the option key as text; map it back to the
* option's own value, so a numeric id stays a number for wire:model.
*/
pick(key) {
const option = this.pickerOptions.find((item) => String(item.value) === String(key));
if (option && !option.enabled) return;
const value = option ? option.value : key;
if (this.pickerMultiple) {
this.picked = this.isPicked(value)
? this.picked.filter((item) => String(item) !== String(value))
: [...this.picked, value];
} else {
this.picked = value === '' || value == null ? [] : [value];
}
const detail = this.pickerContext === null ? { value: this.model } : { value: this.model, context: this.pickerContext };
this.pickerRoot.dispatchEvent(new CustomEvent('change', { bubbles: true, detail }));
if (this.pickerClosesOnSelect) this.closePicker();
},
/**
* command-select from the inner command. A row of the list's `actions`
* footer ("Create label …") is not a value: it dispatches
* `property-picker-action` { value, query, context? } (as combobox-action) from the picker
* root and closes the panel, unless a listener prevents the default.
* Any other row picks its value.
*/
pickFromCommand(event) {
const key = event.detail?.value;
const command = event.currentTarget instanceof Element ? event.currentTarget : null;
const action = command?.querySelector(`[data-slot="command-actions"] [data-slot="command-item"][data-value="${CSS.escape(String(key))}"]`);
if (!action) {
this.pick(key);
return;
}
const query = String(window.Alpine.$data(command)?.query ?? '');
const detail = this.pickerContext === null ? { value: key, query } : { value: key, query, context: this.pickerContext };
const proceed = this.pickerRoot.dispatchEvent(new CustomEvent('property-picker-action', { bubbles: true, cancelable: true, detail }));
if (proceed) this.closePicker();
},
/** The popover component inside the picker. */
pickerPopover() {
const el = this.pickerRoot?.querySelector('[data-slot="popover"]');
return el ? window.Alpine.$data(el) : null;
},
/**
* Open the panel, against `anchor` when given. A detached picker with
* no anchor opens against the focused element, so it still has a
* place and a focus return.
*/
openPicker(anchor = null) {
if (config.disabled) return;
const popover = this.pickerPopover();
if (!popover) return;
let from = anchor instanceof Element ? anchor : null;
if (!from && config.detached && document.activeElement instanceof Element && document.activeElement !== document.body) {
from = document.activeElement;
}
if (from) {
popover.showFrom(from);
} else if (!popover.open) {
popover.show();
}
},
closePicker() {
this.pickerPopover()?.closeAndFocus();
},
/**
* `property-picker-open` on window with { id, anchor?, value?,
* context? } opens the picker with that id. The same anchor while the
* panel is open closes it again (a toggle); another anchor moves it.
*/
openFromEvent(event) {
const detail = event.detail ?? {};
if (!detail.id || detail.id !== this.pickerRoot.id || config.disabled) return;
const popover = this.pickerPopover();
const anchor = detail.anchor instanceof Element ? detail.anchor : null;
if (popover?.open && anchor && popover.anchorEl?.() === anchor) {
this.closePicker();
return;
}
if (Object.prototype.hasOwnProperty.call(detail, 'value')) this.model = detail.value;
this.pickerContext = detail.context ?? null;
this.openPicker(anchor);
// A panel that moves to another row stays open, so the toggle
// below does not run: mark the new row's value here as well.
this.$nextTick(() => this.activatePicked());
},
/** The command component inside the (teleported) panel. */
pickerCommand() {
const panel = this.pickerPopover()?.$refs?.panel;
const el = panel?.querySelector('[data-slot="command"]');
return el ? window.Alpine.$data(el) : null;
},
/**
* Make the (first) chosen option the command's active option and
* scroll it into view, so Enter keeps the value and the arrow keys
* start from it. With nothing chosen, or a chosen option that is
* hidden or disabled, the first option is active. Typing a query then
* makes the first match active, as the command always does.
*/
activatePicked() {
const command = this.pickerCommand();
if (!command || typeof command.visible !== 'function') return;
const chosen = command.visible().find((item) => item.el && this.pickerOptions.some((option) => option.el === item.el && this.isPicked(option.value)));
if (chosen) {
command.active = chosen.value;
} else {
command.reset();
}
command.scrollActiveIntoView?.();
},
/**
* Runs from the popover root (x-effect) with its open state. Opening
* makes the chosen option the active one (activatePicked()). Closing
* dispatches a `blur` on the picker root, so wire:model.blur and
* wire:model.live.blur send the value when the picker closes.
*/
onPickerToggle(open) {
if (this.pickerWasOpen && !open) {
this.pickerRoot.dispatchEvent(new FocusEvent('blur'));
}
if (open && !this.pickerWasOpen) {
this.$nextTick(() => this.activatePicked());
}
this.pickerWasOpen = open;
},
/**
* Keys in the panel: a digit picks the option that shows it while the
* search field is empty; Tab closes the picker and returns focus to
* the trigger, as the panel sits at the end of the page.
*/
onPickerKeydown(event) {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
if (event.key === 'Tab') {
event.preventDefault();
this.closePicker();
return;
}
if (!/^[0-9]$/.test(event.key) || String(this.query ?? '') !== '') return;
const option = this.pickerOptions.find((item) => item.shortcut === event.key && item.enabled && item.el?.isConnected);
if (!option) return;
event.preventDefault();
this.pick(option.value);
},
/** Server mode: the command's search event, re-dispatched from the picker root. */
forwardSearch(event) {
event.stopPropagation();
this.pickerRoot.dispatchEvent(new CustomEvent('property-picker-search', { bubbles: true, detail: event.detail }));
},
/**
* Draw the chosen value(s) into the trigger: the option icons (cloned
* inside an x-ignore wrapper, without ids) and the label, or the
* count with `multiple`. Text goes in through textContent only.
*/
renderPickerValue(el, display = 'full') {
const chosen = this.picked.map((value) => this.pickerOptions.find((option) => String(option.value) === String(value))
?? { value, label: String(value), el: null });
el.replaceChildren();
el.dataset.empty = chosen.length === 0 ? 'true' : 'false';
const icons = document.createElement('span');
icons.setAttribute('x-ignore', '');
icons.setAttribute('aria-hidden', 'true');
icons.className = 'inline-flex shrink-0 items-center -space-x-1 rtl:space-x-reverse';
for (const option of chosen.slice(0, 3)) {
const icon = option.el?.querySelector('[data-slot="property-picker-icon"]');
if (!icon) continue;
const copy = icon.cloneNode(true);
copy.removeAttribute('data-slot');
for (const node of [copy, ...copy.querySelectorAll('[id]')]) node.removeAttribute('id');
icons.append(copy);
}
const text = document.createElement('span');
text.className = 'min-w-0 truncate';
if (chosen.length === 0) {
text.textContent = this.pickerTexts.empty ?? '';
text.classList.add('text-muted-foreground');
} else if (chosen.length === 1) {
text.textContent = chosen[0].label;
} else {
text.textContent = String(this.pickerTexts.count ?? ':count').replace(':count', String(chosen.length));
}
if (display === 'icon' && icons.childElementCount > 0) text.classList.add('sr-only');
if (icons.childElementCount > 0) el.append(icons);
el.append(text);
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.2.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