People Picker
A search field that chooses people (members, contacts) with their avatar and type: chips with remove buttons, grouping by type, full keyboard, wire:model and a server search mode.
Preview
{{-- The attendees of a meeting: members and client contacts with their
avatar and type. Type to filter, Enter picks, Backspace removes the last
chip. Each chosen id posts as attendees[]. --}}
@php
$people = [
['id' => 1, 'name' => 'Ada Lovelace', 'type' => __('Team'), 'email' => 'ada@example.com'],
['id' => 2, 'name' => 'Grace Hopper', 'type' => __('Team'), 'email' => 'grace@example.com'],
['id' => 3, 'name' => 'Alan Turing', 'type' => __('Team'), 'email' => 'alan@example.com'],
['id' => 4, 'name' => 'Katherine Johnson', 'type' => __('Contact'), 'email' => 'katherine@northwind.example'],
['id' => 5, 'name' => 'Margaret Hamilton', 'type' => __('Contact'), 'email' => 'margaret@northwind.example'],
['id' => 6, 'name' => 'Dorothy Vaughan', 'type' => __('Contact'), 'email' => 'dorothy@contoso.example'],
];
@endphp
<div class="w-full max-w-md space-y-2">
<x-ui.label for="attendees-input">{{ __('Attendees') }}</x-ui.label>
<x-ui.people-picker name="attendees" input-id="attendees-input" :people="$people" :value="[2, 4]" />
</div>
Installation
php artisan ui:add people-picker
Note
This component ships an Alpine behavior module at
resources/js/ui/people-picker.js. Import it once from your bundle so it registers on alpine:init:
import './people-picker.js';
Registry contract
php artisan ui:add people-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/people-picker.blade.php -
resources/js/ui/people-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: People Picker (`people-picker`)
A search field that chooses people (members, contacts) with their avatar and type: chips with remove buttons, grouping by type, full keyboard, wire:model and a server search mode.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add people-picker
```
## Usage
```blade
{{-- The attendees of a meeting: members and client contacts with their
avatar and type. Type to filter, Enter picks, Backspace removes the last
chip. Each chosen id posts as attendees[]. --}}
@php
$people = [
['id' => 1, 'name' => 'Ada Lovelace', 'type' => __('Team'), 'email' => '[email protected]'],
['id' => 2, 'name' => 'Grace Hopper', 'type' => __('Team'), 'email' => '[email protected]'],
['id' => 3, 'name' => 'Alan Turing', 'type' => __('Team'), 'email' => '[email protected]'],
['id' => 4, 'name' => 'Katherine Johnson', 'type' => __('Contact'), 'email' => '[email protected]'],
['id' => 5, 'name' => 'Margaret Hamilton', 'type' => __('Contact'), 'email' => '[email protected]'],
['id' => 6, 'name' => 'Dorothy Vaughan', 'type' => __('Contact'), 'email' => '[email protected]'],
];
@endphp
<div class="w-full max-w-md space-y-2">
<x-ui.label for="attendees-input">{{ __('Attendees') }}</x-ui.label>
<x-ui.people-picker name="attendees" input-id="attendees-input" :people="$people" :value="[2, 4]" />
</div>
```
## Props
- `name` (string|null, default `null`) — Form field name; inherited from a wrapping field via @aware or set directly. Each chosen id posts as name[] (name with :multiple="false"); an error for the name or name.N marks the picker invalid.
- `value` (array|string|int|null, default `[]`) — The chosen ids (one id with :multiple="false"). old() replaces it after a redirect-back. 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.
- `people` (array, default `[]`) — The people to choose from: a list of ['id', 'name', 'type', 'email', 'avatar', 'initials']. id and name are required, entries without them are dropped; initials default to the first letters of the name. A Livewire render that changes it updates the list.
- `selectedPeople` (array, default `[]`) — Details of chosen people that people does not list (in server mode), so their chips still show a name and an avatar.
- `multiple` (bool, default `true`) — Choose any number of people; each pick toggles a person and the list stays open. false keeps one person: a pick replaces it and closes the list.
- `grouped` (bool, default `false`) — Groups the list under a heading per type (Team, Contact) in the order the types first appear; a group hides when a search leaves it empty, and the rows leave out their type label.
- `label` (string|null, default `null`) — Accessible name of the list, and of the search field when no <label for> points at it. Null: the translated "People".
- `inputId` (string|null, default `null`) — The id of the search field, so a <label for> names it. Inside a named x-ui.field it defaults to the field name.
- `placeholder` (string|null, default `null`) — Placeholder of the search field. Null: the translated "Search people…".
- `emptyText` (string|null, default `null`) — Row shown when no person matches. Null: the translated "No people found.".
- `disabled` (bool, default `false`) — Disables the search field and hides the remove buttons; the chips still show the chosen people.
- `server` (bool, default `false`) — Server mode of the inner command: typing dispatches people-picker-search { query, sequence, done(people), fail(message), isCurrent() } after debounce ms. done() takes the matching people, or nothing after a Livewire render changed people; until then the list is busy with a loading row and a polite status, and an older answer never shows.
- `debounce` (int, default `200`) — Milliseconds between the last key and people-picker-search in server mode.
- `loadingText` (string|null, default `null`) — Loading row and status text in server mode. Null: the translated "Searching…".
- `errorText` (string|null, default `null`) — Row shown when the host calls fail() without a message. Null: the translated default.
## Use when
- Use for a small set of mutually exclusive options that users should compare visibly before choosing.
- A form chooses one or more known people (account members, client contacts) by name, such as the attendees of a meeting or the reviewers of a document, and each person needs an avatar and a type.
- The list of people is long or lives on the server: type to filter, or answer a debounced search with the matching people.
## Avoid when
- Do not hide a small option set in a dropdown when recognition and comparison matter.
- The entries are free text or email addresses that nobody chose from a list; use tags-input with type="email".
- Changing the assignee of a record in place from a row or a detail page; use property-picker.
- The choice is a short fixed list of plain values; use combobox with multiple or checkbox.
## Anti-patterns
- Hiding a small comparable set in a dropdown
## Rules
- Use the `<brok:people-picker>` tag (or `<x-ui.people-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/people-picker
- Registry JSON (files, props, contract): https://brokui.dev/r/open/people-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
{{-- disabled: the chips still show who is chosen, without remove buttons,
and the search field takes no input. --}}
@php
$people = [
['id' => 1, 'name' => 'Ada Lovelace', 'type' => __('Team'), 'email' => 'ada@example.com'],
['id' => 2, 'name' => 'Grace Hopper', 'type' => __('Team'), 'email' => 'grace@example.com'],
['id' => 3, 'name' => 'Alan Turing', 'type' => __('Team'), 'email' => 'alan@example.com'],
['id' => 4, 'name' => 'Katherine Johnson', 'type' => __('Contact'), 'email' => 'katherine@northwind.example'],
['id' => 5, 'name' => 'Margaret Hamilton', 'type' => __('Contact'), 'email' => 'margaret@northwind.example'],
['id' => 6, 'name' => 'Dorothy Vaughan', 'type' => __('Contact'), 'email' => 'dorothy@contoso.example'],
];
@endphp
<div class="w-full max-w-md space-y-2">
<x-ui.label for="locked-input">{{ __('Attendees') }}</x-ui.label>
<x-ui.people-picker name="attendees" input-id="locked-input" disabled :people="$people" :value="[1, 4]" />
</div>
{{-- No people yet: the list opens with the empty row, and the slot under
the list offers the way to add someone. --}}
<div class="w-full max-w-md space-y-2">
<x-ui.label for="nobody-input">{{ __('Attendees') }}</x-ui.label>
<x-ui.people-picker name="attendees" input-id="nobody-input" :people="[]" :empty-text="__('Nobody to choose yet.')">
<x-ui.button variant="ghost" size="sm" class="w-full justify-start">{{ __('Invite a member') }}</x-ui.button>
</x-ui.people-picker>
</div>
{{-- Error: the host calls fail(message) when a search cannot be answered.
The list shows the error row, the status region reads it out and the
chosen people stay. This preview's fake server fails every search. --}}
<div
x-data="{
timers: [],
search(detail) {
this.timers.push(setTimeout(() => detail.fail(@js(__('Could not load people. Try again.'))), 300));
},
destroy() { this.timers.forEach(clearTimeout) },
}"
class="w-full max-w-md space-y-2"
>
<x-ui.label for="failing-people-input">{{ __('Attendees') }}</x-ui.label>
<x-ui.people-picker
server
name="attendees"
input-id="failing-people-input"
:people="[]"
:value="[1]"
:selected-people="[['id' => 1, 'name' => 'Ada Lovelace', 'type' => __('Team')]]"
x-on:people-picker-search="search($event.detail)"
/>
</div>
{{-- grouped: one heading per type, in the order the types first appear.
A search that leaves a group empty hides its heading. --}}
@php
$people = [
['id' => 1, 'name' => 'Ada Lovelace', 'type' => __('Team'), 'email' => 'ada@example.com'],
['id' => 2, 'name' => 'Grace Hopper', 'type' => __('Team'), 'email' => 'grace@example.com'],
['id' => 3, 'name' => 'Alan Turing', 'type' => __('Team'), 'email' => 'alan@example.com'],
['id' => 4, 'name' => 'Katherine Johnson', 'type' => __('Contact'), 'email' => 'katherine@northwind.example'],
['id' => 5, 'name' => 'Margaret Hamilton', 'type' => __('Contact'), 'email' => 'margaret@northwind.example'],
['id' => 6, 'name' => 'Dorothy Vaughan', 'type' => __('Contact'), 'email' => 'dorothy@contoso.example'],
];
@endphp
<div class="w-full max-w-md space-y-2">
<x-ui.label for="reviewers-input">{{ __('Reviewers') }}</x-ui.label>
<x-ui.people-picker name="reviewers" input-id="reviewers-input" grouped :people="$people" />
</div>
{{-- Server search: the picker dispatches `people-picker-search` and the host
answers with done(people) or fail(). This preview answers from a fake
server after 400 ms; while it waits the list is busy and shows a loading
row. A chosen person that the answer leaves out keeps its chip. --}}
<div
x-data="{
directory: [
{ id: 1, name: 'Ada Lovelace', type: @js(__('Team')), email: 'ada@example.com' },
{ id: 2, name: 'Grace Hopper', type: @js(__('Team')), email: 'grace@example.com' },
{ id: 4, name: 'Katherine Johnson', type: @js(__('Contact')), email: 'katherine@northwind.example' },
{ id: 5, name: 'Margaret Hamilton', type: @js(__('Contact')), email: 'margaret@northwind.example' },
],
attendees: [4],
timers: [],
search(detail) {
const query = detail.query.trim().toLowerCase();
this.timers.push(setTimeout(() => {
detail.done(this.directory.filter((person) => person.name.toLowerCase().includes(query)));
}, 400));
},
destroy() { this.timers.forEach(clearTimeout) },
}"
class="w-full max-w-md space-y-2"
>
<x-ui.label for="search-people-input">{{ __('Attendees') }}</x-ui.label>
<x-ui.people-picker
server
grouped
input-id="search-people-input"
x-model="attendees"
:people="[['id' => 1, 'name' => 'Ada Lovelace', 'type' => __('Team'), 'email' => 'ada@example.com']]"
:selected-people="[['id' => 4, 'name' => 'Katherine Johnson', 'type' => __('Contact')]]"
x-on:people-picker-search="search($event.detail)"
/>
<p class="text-sm text-muted-foreground">{{ __('Chosen ids:') }} <span data-testid="attendee-ids" x-text="attendees.join(', ')"></span></p>
</div>
Long Content
{{-- A narrow column with long names and addresses: chips truncate and wrap
onto more lines, and the rows truncate the email before the name. --}}
@php
$people = [
['id' => 1, 'name' => 'Maximiliane Oppenheimer-Wijnberg van der Heijden', 'type' => __('Team'), 'email' => 'maximiliane.oppenheimer-wijnberg@example.com'],
['id' => 2, 'name' => 'Grace Hopper', 'type' => __('Team'), 'email' => 'grace@example.com'],
['id' => 3, 'name' => 'Procurement Department Regional Distribution', 'type' => __('Contact'), 'email' => 'procurement.regional.distribution@northwind-traders.example'],
['id' => 4, 'name' => 'Katherine Johnson', 'type' => __('Contact'), 'email' => 'katherine@northwind.example'],
['id' => 5, 'name' => 'Margaret Hamilton', 'type' => __('Contact')],
];
@endphp
<div class="w-full max-w-xs space-y-2">
<x-ui.label for="long-input">{{ __('Attendees') }}</x-ui.label>
<x-ui.people-picker name="attendees" input-id="long-input" :people="$people" :value="[1, 2, 3, 4, 5]" />
</div>
{{-- :multiple="false" keeps one person: a pick replaces the chip and
closes the list, and the id posts as owner. --}}
@php
$people = [
['id' => 1, 'name' => 'Ada Lovelace', 'type' => __('Team'), 'email' => 'ada@example.com'],
['id' => 2, 'name' => 'Grace Hopper', 'type' => __('Team'), 'email' => 'grace@example.com'],
['id' => 3, 'name' => 'Alan Turing', 'type' => __('Team'), 'email' => 'alan@example.com'],
['id' => 4, 'name' => 'Katherine Johnson', 'type' => __('Contact'), 'email' => 'katherine@northwind.example'],
['id' => 5, 'name' => 'Margaret Hamilton', 'type' => __('Contact'), 'email' => 'margaret@northwind.example'],
['id' => 6, 'name' => 'Dorothy Vaughan', 'type' => __('Contact'), 'email' => 'dorothy@contoso.example'],
];
@endphp
<div class="w-full max-w-md space-y-2">
<x-ui.label for="owner-input">{{ __('Owner') }}</x-ui.label>
<x-ui.people-picker name="owner" input-id="owner-input" :multiple="false" grouped :people="$people" :value="1" />
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| name | string | null | null | Form field name; inherited from a wrapping field via @aware or set directly. Each chosen id posts as name[] (name with :multiple="false"); an error for the name or name.N marks the picker invalid. |
| value | array | string | int | null | [] | The chosen ids (one id with :multiple="false"). old() replaces it after a redirect-back. 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. |
| people | array | [] | The people to choose from: a list of ['id', 'name', 'type', 'email', 'avatar', 'initials']. id and name are required, entries without them are dropped; initials default to the first letters of the name. A Livewire render that changes it updates the list. |
| selectedPeople | array | [] | Details of chosen people that people does not list (in server mode), so their chips still show a name and an avatar. |
| multiple | bool | true | Choose any number of people; each pick toggles a person and the list stays open. false keeps one person: a pick replaces it and closes the list. |
| grouped | bool | false | Groups the list under a heading per type (Team, Contact) in the order the types first appear; a group hides when a search leaves it empty, and the rows leave out their type label. |
| label | string | null | null | Accessible name of the list, and of the search field when no <label for> points at it. Null: the translated "People". |
| inputId | string | null | null | The id of the search field, so a <label for> names it. Inside a named x-ui.field it defaults to the field name. |
| placeholder | string | null | null | Placeholder of the search field. Null: the translated "Search people…". |
| emptyText | string | null | null | Row shown when no person matches. Null: the translated "No people found.". |
| disabled | bool | false | Disables the search field and hides the remove buttons; the chips still show the chosen people. |
| server | bool | false | Server mode of the inner command: typing dispatches people-picker-search { query, sequence, done(people), fail(message), isCurrent() } after debounce ms. done() takes the matching people, or nothing after a Livewire render changed people; until then the list is busy with a loading row and a polite status, and an older answer never shows. |
| debounce | int | 200 | Milliseconds between the last key and people-picker-search in server mode. |
| loadingText | string | null | null | Loading row and status text in server mode. Null: the translated "Searching…". |
| errorText | string | null | null | Row shown when the host calls fail() without a message. Null: the translated default. |
Slots
default— Footer under the list inside the panel, for an action such as an "Invite a member" button or link. The panel keeps focus in the search field on a mouse press, so a click there does not close the list.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Click or type in the search field, or press ArrowDown, to open the list under the field. Typing filters by name, email and type (the host filters in server mode); ArrowUp/Down, Home and End move the active person and Enter picks it.
- With multiple each pick toggles a person, clears the typed text and keeps the list open; without it a pick replaces the person and closes the list. Every pick and removal is announced ("Added Ada Lovelace").
- Chosen people show as chips with an avatar, the name and a remove button before the text. Backspace in an empty field removes the last chip; ArrowLeft (ArrowRight in RTL) at the start of the field focuses the last chip's remove button, the arrows walk the chips and back to the field, and Backspace or Delete removes the focused chip.
- Escape closes the list and a second Escape clears the text; Tab, a click outside and focus leaving close it and clear the text.
- Every change sets the modelable value (wire:model with any modifier, x-model) and dispatches a bubbling change { value } from the root; focus leaving dispatches blur on the root, so wire:model.blur and .live.blur send the value then. Hidden inputs carry the ids for a plain form post.
- Alpine owns the chips and the list (wire:ignore); a Livewire render updates a hidden server carrier with people and selectedPeople, and the list follows it.
- In server mode people-picker-search carries done(people): pass the matching people, or call done() after a Livewire render put them in people. fail(message) shows the error row.
- 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 form chooses one or more known people (account members, client contacts) by name, such as the attendees of a meeting or the reviewers of a document, and each person needs an avatar and a type.
- The list of people is long or lives on the server: type to filter, or answer a debounced search with the matching people.
Avoid when
- Do not hide a small option set in a dropdown when recognition and comparison matter.
- The entries are free text or email addresses that nobody chose from a list; use tags-input with type="email".
- Changing the assignee of a record in place from a row or a detail page; use property-picker.
- The choice is a short fixed list of plain values; use combobox with multiple or checkbox.
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 Enter Escape ArrowUp ArrowDown ArrowLeft ArrowRight Home End
- Focus
managed
- The search field is a combobox with aria-expanded, aria-controls and aria-activedescendant; the list is a listbox (aria-multiselectable with multiple) whose options report aria-selected for the chosen people, not the keyboard position.
- Groups are role=group, named by their visible heading.
- Each chip's remove button is named "Remove <name>"; the chips are a list named "Chosen people". Picks and removals are announced once through a polite role=status region, and server mode announces loading, the result count and a failure.
- Avatars and the check boxes are decorative; the name and aria-selected carry the meaning. A field error sets aria-invalid and links the field's message through aria-describedby.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="people-picker-{{ $record->id }}">
{{-- The attendees of a meeting: members and client contacts with their
avatar and type. Type to filter, Enter picks, Backspace removes the last
chip. Each chosen id posts as attendees[]. --}}
@php
$people = [
['id' => 1, 'name' => 'Ada Lovelace', 'type' => __('Team'), 'email' => 'ada@example.com'],
['id' => 2, 'name' => 'Grace Hopper', 'type' => __('Team'), 'email' => 'grace@example.com'],
['id' => 3, 'name' => 'Alan Turing', 'type' => __('Team'), 'email' => 'alan@example.com'],
['id' => 4, 'name' => 'Katherine Johnson', 'type' => __('Contact'), 'email' => 'katherine@northwind.example'],
['id' => 5, 'name' => 'Margaret Hamilton', 'type' => __('Contact'), 'email' => 'margaret@northwind.example'],
['id' => 6, 'name' => 'Dorothy Vaughan', 'type' => __('Contact'), 'email' => 'dorothy@contoso.example'],
];
@endphp
<div class="w-full max-w-md space-y-2">
<x-ui.label for="attendees-input">{{ __('Attendees') }}</x-ui.label>
<x-ui.people-picker name="attendees" input-id="attendees-input" :people="$people" :value="[2, 4]" />
</div>
</div>
Validation
Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:people-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.
{{-- A wrapping <x-ui.field name="…"> shares its name down via @aware: the
field's label points at the search field, and an error for the name (or
any name.N) marks the picker invalid and links the field's message. --}}
@aware([
'name' => null,
])
@props([
'name' => null,
// The chosen ids: an array, or one id with :multiple="false". Later
// values arrive through wire:model / x-model; the value is kept out of
// x-data, so a Livewire render never starts the picker again.
'value' => [],
// The people to choose from: [['id' => 1, 'name' => 'Ada Lovelace',
// 'type' => 'Team', 'email' => 'ada@example.com', 'avatar' => '/a.png',
// 'initials' => 'AL']]. id and name are required; the rest is optional.
'people' => [],
// Details of chosen people that `people` does not list (server mode),
// so their chips still show a name and an avatar.
'selectedPeople' => [],
'multiple' => true,
// Group the list under a heading per type (Team, Contact), in the order
// the types first appear. The row then leaves out its type label.
'grouped' => false,
// Accessible name of the search field and the list. Null: "People".
'label' => null,
// The id of the search field, so a <label for="…"> names it. Inside a
// named x-ui.field it defaults to the field name.
'inputId' => null,
'placeholder' => null,
'emptyText' => null,
'disabled' => false,
// Server mode: typing dispatches `people-picker-search` { query,
// sequence, done(people), fail(message), isCurrent() } after `debounce`
// ms. done() takes the matching people (or nothing, after a Livewire
// render changed `people`); until then the list is busy.
'server' => false,
'debounce' => 200,
'loadingText' => null,
'errorText' => null,
])
@php
$awareName = $name ?? null;
$fieldName = $awareName ?? $attributes->get('name');
$multiple = filter_var($multiple, FILTER_VALIDATE_BOOLEAN);
$grouped = filter_var($grouped, FILTER_VALIDATE_BOOLEAN);
$disabled = filter_var($disabled, FILTER_VALIDATE_BOOLEAN);
$server = filter_var($server, FILTER_VALIDATE_BOOLEAN);
$bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
$hasBagError = $bag && filled($fieldName) && ($bag->has($fieldName) || $bag->has($fieldName.'.*'));
$describedBy = trim(implode(' ', array_filter([
(string) $attributes->get('aria-describedby', ''),
(filled($awareName) && $hasBagError) ? $fieldName.'-error' : '',
]))) ?: null;
// Only entries with an id and a name are people; the rest are dropped.
$clean = static fn (mixed $list): array => array_values(array_filter(
is_iterable($list) ? collect($list)->all() : [],
static fn (mixed $person): bool => is_array($person)
&& isset($person['id']) && is_scalar($person['id']) && (string) $person['id'] !== ''
&& isset($person['name']) && is_scalar($person['name']) && (string) $person['name'] !== '',
));
$people = $clean($people);
$selectedPeople = $clean($selectedPeople);
$chosen = filled($fieldName) ? old($fieldName, $value) : $value;
$chosen = array_values(array_filter(
is_array($chosen) ? $chosen : [$chosen],
static fn (mixed $item): bool => is_scalar($item) && (string) $item !== '',
));
if (! $multiple) {
$chosen = array_slice($chosen, 0, 1);
}
$label = filled($label) ? (string) $label : __('People');
$inputId = filled($inputId) ? (string) $inputId : (filled($awareName) ? (string) $awareName : null);
$inputName = filled($attributes->get('aria-label')) ? (string) $attributes->get('aria-label') : (filled($inputId) ? null : $label);
$placeholder = filled($placeholder) ? (string) $placeholder : __('Search people…');
$emptyText = filled($emptyText) ? (string) $emptyText : __('No people found.');
$config = [
'multiple' => $multiple,
'grouped' => $grouped,
'disabled' => $disabled,
'texts' => [
'added' => __('Added :name'),
'removed' => __('Removed :name'),
'remove' => __('Remove :name'),
],
];
$fieldState = $hasBagError ? 'border-destructive' : 'border-input';
@endphp
{{--
People Picker: choose people (members, contacts) by name with their avatar
and type. The search field is a combobox on the command keyboard model
(field mode): typing filters, ArrowUp/Down, Home and End move the active
person, Enter picks. Chosen people show as chips before the text; each
has a remove button. Backspace in an empty field removes the last chip;
ArrowLeft (ArrowRight in RTL) at the start walks into the chips, where
Backspace or Delete removes the focused one.
--}}
<div
x-data="uiPeoplePicker(@js($config))"
x-modelable="model"
x-id="['people-picker']"
data-slot="people-picker"
data-value="{{ json_encode($chosen) }}"
data-multiple="{{ $multiple ? 'true' : 'false' }}"
@if ($grouped) data-grouped="true" @endif
@if ($disabled) data-disabled="true" @endif
@if ($hasBagError) data-invalid="true" @endif
x-on:click.outside="closePeople()"
x-on:focusout="onPeopleFocusOut()"
{{ $attributes->except(['name', 'aria-label', 'aria-labelledby', 'aria-describedby'])->merge(['class' => 'relative w-full min-w-0']) }}
>
{{-- Server carrier: the people of the latest render. A morph updates
these attributes and the script observes them. --}}
<span hidden data-slot="people-picker-server" data-people="{{ json_encode($people) }}" data-selected-people="{{ json_encode($selectedPeople) }}"></span>
<div wire:ignore>
@if (filled($fieldName))
<template x-for="id in peoplePicked" :key="'input-' + String(id)">
<input type="hidden" name="{{ $multiple ? $fieldName.'[]' : $fieldName }}" x-bind:value="id" />
</template>
@endif
<x-ui.command
field
:server="$server"
:debounce="$debounce"
:filter="! $server"
:clear-on-escape="false"
:loading-text="$loadingText"
:error-text="$errorText"
x-on:command-select="pickPerson($event.detail.value)"
x-on:command-search="forwardPeopleSearch($event)"
>
<div
data-slot="people-picker-field"
x-on:mousedown="if ($event.target === $el) { $event.preventDefault(); focusPeopleInput() }"
class="flex min-h-control-h-md w-full min-w-0 flex-wrap items-center gap-1 rounded-md border {{ $fieldState }} bg-background px-1 py-1 text-sm text-foreground shadow-xs transition-colors focus-within:ring-[length:var(--ring-width)] focus-within:ring-ring focus-within:ring-offset-[length:var(--ring-offset-width)] focus-within:ring-offset-background motion-reduce:transition-none {{ $disabled ? 'cursor-not-allowed opacity-50' : '' }}"
>
<ul role="list" data-slot="people-picker-chips" aria-label="{{ __('Chosen people') }}" class="contents">
<template x-for="(person, index) in chosenPeople" :key="'chip-' + String(person.value)">
<li data-slot="people-picker-chip" class="inline-flex h-7 min-w-0 max-w-full items-center gap-1 rounded-sm border border-border bg-muted ps-0.5 pe-1 text-xs font-medium text-foreground">
<x-ui.avatar size="xs" class="shrink-0">
<img x-show="person.avatar" x-bind:src="person.avatar || null" alt="" class="absolute inset-0 size-full object-cover" />
<span x-show="! person.avatar" x-text="person.initials"></span>
</x-ui.avatar>
<span data-slot="people-picker-chip-name" class="min-w-0 truncate" x-text="person.name"></span>
@unless ($disabled)
<button
type="button"
data-slot="people-picker-remove"
x-on:click="removePerson(index, 'input')"
x-on:keydown.backspace.prevent="removePerson(index, 'before')"
x-on:keydown.delete.prevent="removePerson(index, 'here')"
x-on:keydown.left.prevent="onPeopleChipArrow(index, 'left')"
x-on:keydown.right.prevent="onPeopleChipArrow(index, 'right')"
x-bind:aria-label="removePersonLabel(person)"
class="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
@endunless
</li>
</template>
</ul>
<input
type="text"
data-slot="people-picker-input"
role="combobox"
@if (filled($inputId)) id="{{ $inputId }}" @endif
@if (filled($inputName)) aria-label="{{ $inputName }}" @endif
@if (filled($attributes->get('aria-labelledby'))) aria-labelledby="{{ $attributes->get('aria-labelledby') }}" @endif
aria-autocomplete="list"
aria-expanded="false"
x-bind:aria-expanded="peopleOpen ? 'true' : 'false'"
x-bind:aria-controls="$id('command-list')"
x-bind:aria-activedescendant="peopleOpen ? activeId() : null"
@if ($hasBagError) aria-invalid="true" @endif
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
x-model="query"
x-on:input="reset(); showPeople()"
x-on:click="showPeople()"
x-on:keydown="onPeopleKeydown($event)"
placeholder="{{ $placeholder }}"
autocomplete="off"
@disabled($disabled)
class="h-7 min-w-24 flex-1 bg-transparent px-1 text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
/>
</div>
<div
data-slot="people-picker-panel"
x-show="peopleOpen"
x-cloak
x-on:mousedown.prevent
x-transition:enter="motion-safe:transition motion-safe:duration-100 motion-safe:ease-out"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="absolute inset-x-0 top-full z-popover mt-1 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md motion-reduce:transition-none"
>
<x-ui.command.list :aria-label="$label" :aria-multiselectable="$multiple ? 'true' : null">
<x-ui.command.empty>{{ $emptyText }}</x-ui.command.empty>
<div data-slot="people-picker-results" @if ($server) data-sequenced @endif>
@if ($server)
<span data-slot="command-sequence" hidden x-bind:data-sequence="peopleSequence"></span>
@endif
<template x-for="(group, groupIndex) in peopleGroups" :key="group.key">
<div
data-slot="people-picker-group"
x-bind:role="group.heading ? 'group' : null"
x-bind:aria-labelledby="group.heading ? $id('people-picker') + '-group-' + groupIndex : null"
x-show="hasItemsIn($el)"
>
<div
data-slot="people-picker-group-heading"
x-show="group.heading"
x-bind:id="$id('people-picker') + '-group-' + groupIndex"
x-text="group.heading"
class="px-2 py-2 text-xs font-medium text-muted-foreground select-none"
></div>
<template x-for="person in group.people" :key="group.key + '-' + String(person.value)">
<x-ui.command.item
value-expr="person.value"
keywords-expr="person.search"
selected-expr="isPicked(person.value)"
data-slot="people-picker-option"
>
@if ($multiple)
<span aria-hidden="true" class="flex size-4 shrink-0 items-center justify-center rounded-xs border text-primary-foreground" x-bind:class="isPicked(person.value) ? 'border-primary bg-primary' : 'border-input bg-background'">
<svg x-show="isPicked(person.value)" 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
<x-ui.avatar size="xs" class="shrink-0">
<img x-show="person.avatar" x-bind:src="person.avatar || null" alt="" class="absolute inset-0 size-full object-cover" />
<span x-show="! person.avatar" x-text="person.initials"></span>
</x-ui.avatar>
<span data-slot="people-picker-name" class="min-w-0 truncate" x-text="person.name"></span>
<span data-slot="people-picker-email" x-show="person.email" class="min-w-0 flex-1 truncate text-muted-foreground" x-text="person.email"></span>
<span x-show="! person.email" class="flex-1" aria-hidden="true"></span>
<span data-slot="people-picker-type" x-show="person.type && ! peopleGrouped" class="shrink-0 text-xs text-muted-foreground" x-text="person.type"></span>
@unless ($multiple)
<svg aria-hidden="true" x-show="isPicked(person.value)" 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
</x-ui.command.item>
</template>
</div>
</template>
</div>
</x-ui.command.list>
@if ($slot->isNotEmpty())
{{-- Composition slot: an action under the list, such as "Invite a member". --}}
<div data-slot="people-picker-footer" class="border-t border-border p-1">
{{ $slot }}
</div>
@endif
</div>
</x-ui.command>
<span data-slot="people-picker-status" role="status" class="sr-only" x-text="peopleAnnouncement"></span>
</div>
</div>
/**
* People Picker behavior: choose people (members, contacts) by name.
*
* It composes `command` in field mode and adds the selection model:
* - `command` owns the query, filtering, the active option
* (aria-activedescendant), ArrowUp/Down, Home, End, Enter and, with
* `server`, the debounced search with sequence numbers, the busy state and
* the polite result status;
* - this component owns the chosen ids (x-modelable for wire:model and
* x-model, a bubbling `change` { value } and hidden inputs), the chips and
* their keys, the open state of the list, the people (from the server
* carrier's data attributes, and from done(people) in server mode) and the
* grouping by type.
*
* Methods run with `this` bound to the scope of the calling element, which is
* often inside the command; the picker's own fields therefore have names no
* inner component uses (`people*`), and command fields (`query`, `reset()`,
* `activeId()`) are reached through that scope or through peopleCommand().
* A chip's handler runs in the scope of an element its removal takes out of
* the page, so the root element is kept from init instead of read from $root.
*/
import './command.js';
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiPeoplePicker', (config = {}) => {
const multiple = config.multiple !== false;
const texts = config.texts ?? {};
let rootEl = null;
let observer = null;
let focusOutTimer = null;
// True while a removal moves focus to the next chip: the removed
// button's focusout is not a leave.
let moving = false;
const parse = (text, fallback) => {
try {
const value = JSON.parse(text ?? '');
return value ?? fallback;
} catch {
return fallback;
}
};
const isPerson = (person) => person !== null && typeof person === 'object'
&& person.id !== undefined && person.id !== null && String(person.id) !== ''
&& typeof person.name !== 'undefined' && String(person.name) !== '';
const initialsOf = (name) => {
const words = String(name).trim().split(/\s+/).filter(Boolean);
const letters = words.length > 1 ? [words[0], words[words.length - 1]] : words;
return letters.map((word) => Array.from(word)[0] ?? '').join('').toUpperCase();
};
const normalize = (person) => {
const name = String(person.name);
const email = person.email ? String(person.email) : '';
const type = person.type ? String(person.type) : '';
return {
value: person.id,
name,
email,
type,
avatar: person.avatar ? String(person.avatar) : '',
initials: person.initials ? String(person.initials) : initialsOf(name),
search: [name, email, type].join(' '),
};
};
const normalizePicked = (value) => {
const list = Array.isArray(value) ? value : (value === null || value === undefined || value === '' ? [] : [value]);
const clean = list.filter((item) => item !== null && item !== undefined && item !== '');
return multiple ? clean : clean.slice(0, 1);
};
return {
peoplePicked: [],
peopleList: [],
peopleDirectory: {},
peopleSequence: 0,
peopleSearchSequence: 0,
peopleOpen: false,
peopleGrouped: Boolean(config.grouped),
peopleAnnouncement: '',
init() {
rootEl = this.$el;
this.peoplePicked = normalizePicked(parse(rootEl.dataset.value, []));
this.readPeopleCarrier(false);
const carrier = rootEl.querySelector('[data-slot="people-picker-server"]');
if (carrier) {
observer = new MutationObserver(() => this.readPeopleCarrier(true));
observer.observe(carrier, { attributes: true, attributeFilter: ['data-people', 'data-selected-people'] });
}
},
destroy() {
observer?.disconnect();
observer = null;
clearTimeout(focusOutTimer);
},
/** Modelable value: the chosen ids, or one id (null when none) with multiple off. */
get model() {
return multiple ? [...this.peoplePicked] : (this.peoplePicked[0] ?? null);
},
set model(value) {
const next = normalizePicked(value);
if (JSON.stringify(next.map(String)) === JSON.stringify(this.peoplePicked.map(String))) return;
this.peoplePicked = next;
},
/** The chosen people, in the order they were chosen; an unknown id shows as text. */
get chosenPeople() {
return this.peoplePicked.map((value) => this.peopleDirectory[String(value)] ?? normalize({ id: value, name: String(value) }));
},
/** One unnamed group, or a group per type in the order the types first appear. */
get peopleGroups() {
if (!this.peopleGrouped) return [{ key: 'all', heading: '', people: this.peopleList }];
const groups = new Map();
for (const person of this.peopleList) {
if (!groups.has(person.type)) groups.set(person.type, { key: `type-${person.type}`, heading: person.type, people: [] });
groups.get(person.type).people.push(person);
}
return [...groups.values()];
},
isPicked(value) {
return this.peoplePicked.some((item) => String(item) === String(value));
},
rememberPeople(list) {
const directory = { ...this.peopleDirectory };
for (const person of list) directory[String(person.value)] = person;
this.peopleDirectory = directory;
},
/** The people of the latest render (and the details of chosen people it does not list). */
readPeopleCarrier(fromRender) {
const carrier = rootEl?.querySelector('[data-slot="people-picker-server"]');
if (!carrier) return;
const selected = parse(carrier.dataset.selectedPeople, []);
const people = parse(carrier.dataset.people, []);
this.rememberPeople((Array.isArray(selected) ? selected : []).filter(isPerson).map(normalize));
const list = (Array.isArray(people) ? people : []).filter(isPerson).map(normalize);
this.rememberPeople(list);
this.peopleList = list;
// A render that answers a search belongs to the latest query.
if (fromRender) this.peopleSequence = this.peopleSearchSequence;
},
/** The command inside the picker, for calls from the root's own scope. */
peopleCommand() {
const el = rootEl?.querySelector('[data-slot="command"]');
return el ? window.Alpine.$data(el) : null;
},
peopleInput() {
return rootEl?.querySelector('[data-slot="people-picker-input"]') ?? null;
},
showPeople() {
if (config.disabled) return;
this.peopleOpen = true;
const command = this.peopleCommand();
if (command && !command.visibleValues().includes(command.active)) command.reset();
},
/** Closes the list; `clearQuery` also drops the typed text (focus left, a click outside). */
closePeople(clearQuery = true) {
this.peopleOpen = false;
const command = this.peopleCommand();
if (clearQuery && command && command.query !== '') {
command.query = '';
command.reset();
}
},
focusPeopleInput() {
if (config.disabled) return;
this.peopleInput()?.focus();
this.showPeople();
},
/**
* command-select hands over the option key; map it back to the
* person's own id, so a numeric id stays a number for wire:model.
*/
pickPerson(key) {
if (config.disabled) return;
const person = this.peopleList.find((item) => String(item.value) === String(key)) ?? this.peopleDirectory[String(key)];
const value = person ? person.value : key;
const name = person ? person.name : String(key);
if (!multiple) {
this.peoplePicked = [value];
this.peopleAnnounce(texts.added, name);
this.closePeople(true);
} else if (this.isPicked(value)) {
this.peoplePicked = this.peoplePicked.filter((item) => String(item) !== String(value));
this.peopleAnnounce(texts.removed, name);
} else {
this.peoplePicked = [...this.peoplePicked, value];
this.peopleAnnounce(texts.added, name);
}
this.peopleChanged();
},
/**
* Removes chip `index` and announces it. `focus`: 'input' (a click
* on ×), 'before' (Backspace on a chip), 'here' (Delete on a chip:
* the chip now in its place), or null to leave focus alone.
*/
removePerson(index, focus = null) {
if (config.disabled) return;
const person = this.chosenPeople[index];
if (!person) return;
this.peoplePicked = this.peoplePicked.filter((_, position) => position !== index);
this.peopleAnnounce(texts.removed, person.name);
this.peopleChanged();
if (focus === null) return;
let target = null;
if (focus === 'before') target = this.peoplePicked.length === 0 ? null : Math.max(0, index - 1);
if (focus === 'here') target = index < this.peoplePicked.length ? index : null;
moving = true;
this.$nextTick(() => {
this.focusPeopleChip(target);
moving = false;
});
},
removePersonLabel(person) {
return String(texts.remove ?? '').replace(':name', () => person.name);
},
/** Focuses the remove button of chip `index`, or the search field when there is none. */
focusPeopleChip(index) {
const button = index === null ? null : rootEl.querySelectorAll('[data-slot="people-picker-remove"]')[index];
(button ?? this.peopleInput())?.focus();
},
/** 'back' for the arrow that points to the first chip (left, or right in RTL). */
peopleDirection(key) {
const back = getComputedStyle(rootEl).direction === 'rtl' ? 'right' : 'left';
return key === back ? 'back' : 'forward';
},
onPeopleChipArrow(index, key) {
if (this.peopleDirection(key) === 'back') {
if (index > 0) this.focusPeopleChip(index - 1);
return;
}
this.focusPeopleChip(index + 1 < this.peoplePicked.length ? index + 1 : null);
},
/**
* Keys in the search field before the command sees them: the
* arrows open a closed list (without moving), Enter and Home/End
* act only on an open list, Escape closes and then clears,
* Backspace and the back arrow reach the chips.
*/
onPeopleKeydown(event) {
if (event.isComposing) return;
const input = event.target;
const atStart = input.selectionStart === 0 && input.selectionEnd === 0;
switch (event.key) {
case 'ArrowDown':
case 'ArrowUp':
if (!this.peopleOpen) {
event.preventDefault();
event.stopPropagation();
this.showPeople();
}
break;
case 'Enter':
if (this.peopleOpen) event.preventDefault();
else event.stopPropagation();
break;
case 'Home':
case 'End':
if (!this.peopleOpen) event.stopPropagation();
break;
case 'Escape':
if (this.peopleOpen) {
event.preventDefault();
event.stopPropagation();
this.closePeople(false);
} else if (input.value !== '') {
event.preventDefault();
event.stopPropagation();
this.closePeople(true);
}
break;
case 'Tab':
this.closePeople(true);
break;
case 'Backspace':
if (input.value === '' && this.peoplePicked.length > 0) {
event.preventDefault();
this.removePerson(this.peoplePicked.length - 1);
}
break;
case 'ArrowLeft':
case 'ArrowRight':
if (atStart && this.peoplePicked.length > 0 && this.peopleDirection(event.key === 'ArrowLeft' ? 'left' : 'right') === 'back') {
event.preventDefault();
this.closePeople(false);
this.focusPeopleChip(this.peoplePicked.length - 1);
}
break;
default:
break;
}
},
/** Server mode: the command's search, re-dispatched from the picker root. */
forwardPeopleSearch(event) {
event.stopPropagation();
const detail = event.detail;
this.peopleSearchSequence = detail.sequence;
rootEl.dispatchEvent(new CustomEvent('people-picker-search', {
bubbles: true,
detail: {
query: detail.query,
sequence: detail.sequence,
isCurrent: detail.isCurrent,
fail: detail.fail,
done: (people) => {
if (!detail.isCurrent()) return;
if (Array.isArray(people)) {
const list = people.filter(isPerson).map(normalize);
this.rememberPeople(list);
this.peopleList = list;
} else {
this.readPeopleCarrier(false);
}
this.peopleSequence = detail.sequence;
detail.done();
},
},
}));
},
/** Focus left the picker: close the list and tell wire:model.blur and .live.blur. */
onPeopleFocusOut() {
if (moving) return;
clearTimeout(focusOutTimer);
focusOutTimer = setTimeout(() => {
if (rootEl.contains(document.activeElement)) return;
this.closePeople(true);
rootEl.dispatchEvent(new FocusEvent('blur'));
}, 0);
},
peopleChanged() {
rootEl.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: multiple ? [...this.peoplePicked] : (this.peoplePicked[0] ?? null) } }));
},
/** Announces once through the polite status region. */
peopleAnnounce(message, name) {
this.peopleAnnouncement = '';
if (!message) return;
const text = String(message).replace(':name', () => name);
this.$nextTick(() => {
this.peopleAnnouncement = text;
});
},
};
});
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.0 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0