Skip to content
Brok UI

Loading…

No results

Property Picker

Open source

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.

Version
v1.2.2
Stability
stable
License
MIT
Related
Command
Popover
Status Select
Issue Row
Combobox
Hotkeys

Preview

Draft the launch checklist
Variant
Disabled
Side
Align
previews.components.property-picker.default.blade.php 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>

Variant options

Property Current
Button Current
Icon Current

Side options

Top Current
Right Current
Bottom Current
Left Current

Align options

Start Current
Center Current
End Current

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/property-picker.blade.php
  • blade resources/views/components/ui/property-picker/trigger.blade.php
  • blade resources/views/components/ui/property-picker/option.blade.php
  • js resources/js/ui/property-picker.js
Registry dependencies
command popover hotkeys
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

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

property-picker.md
# 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

actions.blade.php Blade
{{-- 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>
anchored.blade.php Blade
{{-- 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.blade.php Blade
{{-- 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>
disabled.blade.php Blade
{{-- 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>
empty.blade.php Blade
{{-- 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.blade.php Blade
{{-- 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.blade.php Blade
{{-- 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>
loading.blade.php Blade
{{-- 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.blade.php Blade
{{-- 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.blade.php Blade
{{-- 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.blade.php Blade
{{-- 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

property-picker property-picker-icon property-picker-label property-picker-value

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

Visible exclusive selection

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
root trigger value panel search list option option-icon option-check option-shortcut empty
Theming hooks
property picker trigger (bg-accent on hover and while open) option check (text-foreground) multiple-choice box (border-input, bg-foreground with a text-background check when chosen; neutral, so the primary accent stays free for the one primary action of a view)

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/property-picker.blade.php Blade
@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>
resources/views/components/ui/property-picker/trigger.blade.php Blade
@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>
resources/views/components/ui/property-picker/option.blade.php Blade
@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>
resources/js/ui/property-picker.js JS
/**
 * 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