Status Select
A status pill that is itself the trigger for a menu changing that status, with each option previewed as the pill it would become.
Preview
Preview only — nothing is saved until the server confirms it.
<x-ui.status-select
value="published"
:options="[
['value' => 'draft', 'label' => __('Draft'), 'tone' => 'soft-neutral'],
['value' => 'published', 'label' => __('Published'), 'tone' => 'soft-success'],
['value' => 'archived', 'label' => __('Archived'), 'tone' => 'soft-warning'],
]"
note="{{ __('Preview only — nothing is saved until the server confirms it.') }}"
/>
Installation
php artisan ui:add status-select
Note
This component ships an Alpine behavior module at
resources/js/ui/status-select.js. Import it once from your bundle so it registers on alpine:init:
import './status-select.js';
Registry contract
php artisan ui:add status-select
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/status-select.blade.php
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: Status Select (`status-select`)
A status pill that is itself the trigger for a menu changing that status, with each option previewed as the pill it would become.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add status-select
```
## Usage
```blade
<x-ui.status-select
value="published"
:options="[
['value' => 'draft', 'label' => __('Draft'), 'tone' => 'soft-neutral'],
['value' => 'published', 'label' => __('Published'), 'tone' => 'soft-success'],
['value' => 'archived', 'label' => __('Archived'), 'tone' => 'soft-warning'],
]"
note="{{ __('Preview only — nothing is saved until the server confirms it.') }}"
/>
```
## Props
- `value` (string|null, default `null`) — The current status value, matched against each option's value to render the trigger pill and mark the checked radio item.
- `options` (array, default `[]`) — Selectable statuses: each entry is ['value','label','tone','url','event','payload']. tone is a badge soft variant; an option with url posts a real form on click, otherwise it dispatches event (default status-select:change) with payload (default the whole option).
- `heading` (string|null, default `null`) — Menu heading and accessible group label. Defaults to a localized "Change status".
- `ariaLabel` (string|null, default `null`) — Accessible name for the trigger. Defaults to a localized "Change status, currently :status".
- `note` (string|null, default `null`) — Optional disclaimer rendered under the options, e.g. to say a preview does not persist until the server confirms it.
- `anchored` (bool, default `true`) — Forwards to the dropdown's anchored/teleported placement so a clipping table row never cuts the menu off.
## Use when
- Use for a small set of mutually exclusive options that users should compare visibly before choosing.
- Changing a record's status directly from a compact pill, without navigating to an edit screen.
- Every option has a fixed, small vocabulary that reads clearly as a coloured pill (active/inactive, published/draft, …).
## Avoid when
- Do not hide a small option set in a dropdown when recognition and comparison matter.
- The choice needs more than a label and a tone (a description, a date, a form field) — use a full edit form instead.
- There are more than a handful of options; a long radio list defeats the point of a compact pill trigger.
## Anti-patterns
- Hiding a small comparable set in a dropdown
## Rules
- Use the `<brok:status-select>` tag (or `<x-ui.status-select>`) 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/status-select
- Registry JSON (files, props, contract): https://brokui.dev/r/open/status-select.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
Long Content
<div class="max-w-xs">
<x-ui.status-select
value="backordered"
:options="[
['value' => 'in-stock', 'label' => __('In stock'), 'tone' => 'soft-success'],
[
'value' => 'backordered',
'label' => __('Backordered until further notice from the supplier'),
'tone' => 'soft-warning',
],
['value' => 'discontinued', 'label' => __('Discontinued'), 'tone' => 'soft-destructive'],
]"
/>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | null | null | The current status value, matched against each option's value to render the trigger pill and mark the checked radio item. |
| options | array | [] | Selectable statuses: each entry is ['value','label','tone','url','event','payload']. tone is a badge soft variant; an option with url posts a real form on click, otherwise it dispatches event (default status-select:change) with payload (default the whole option). |
| heading | string | null | null | Menu heading and accessible group label. Defaults to a localized "Change status". |
| ariaLabel | string | null | null | Accessible name for the trigger. Defaults to a localized "Change status, currently :status". |
| note | string | null | null | Optional disclaimer rendered under the options, e.g. to say a preview does not persist until the server confirms it. |
| anchored | bool | true | Forwards to the dropdown's anchored/teleported placement so a clipping table row never cuts the menu off. |
Slots
Default Blade slot only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- An option with a url submits a real POST form on click, so the change works without depending on a fetch layer.
- An option with no url dispatches a browser event for the host to react to: status-select:change with the chosen option by default, or a caller-chosen event/payload per option.
- Selecting any option closes the menu and returns focus to the trigger.
- Dispatches its change event from the component root rather than from inside the teleported menu, so a host that wraps the component receives it.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, 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.
- Changing a record's status directly from a compact pill, without navigating to an edit screen.
- Every option has a fixed, small vocabulary that reads clearly as a coloured pill (active/inactive, published/draft, …).
Avoid when
- Do not hide a small option set in a dropdown when recognition and comparison matter.
- The choice needs more than a label and a tone (a description, a date, a form field) — use a full edit form instead.
- There are more than a handful of options; a long radio list defeats the point of a compact pill trigger.
Use instead
- Select or combobox for long option sets
Anti-patterns
- Hiding a small comparable set in a dropdown
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
managed
- Meet the WCAG 2.2 AA target declared in meta.a11y.
- The trigger's accessible name states the current status, not just "Change status".
- 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="status-select-{{ $record->id }}">
<x-ui.status-select
value="published"
:options="[
['value' => 'draft', 'label' => __('Draft'), 'tone' => 'soft-neutral'],
['value' => 'published', 'label' => __('Published'), 'tone' => 'soft-success'],
['value' => 'archived', 'label' => __('Archived'), 'tone' => 'soft-warning'],
]"
note="{{ __('Preview only — nothing is saved until the server confirms it.') }}"
/>
</div>
Source
The exact, editable file ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{--
Status Select — a status pill that is itself the trigger for a menu that
changes that status.
The pill is the trigger. In the menu each option is a status row the way
Linear, GitHub and Notion draw one: a dot in the status colour, the label
as text, and a check at the end of the current row — one selection mark,
no pill inside a pill. Options with a
`url` submit a real POST form on click, so the change survives without
depending on a fetch layer; an option with no `url` dispatches an event
for the host to react to — `status-select:change` with the whole option,
by default, or a caller-chosen `event` (+ `payload`) per option so a
client-state list can name and shape its own event instead. Every option
here is a status the caller already chose to offer, so there is no
"inert" third state the way a removable filter chip has one — nothing
stops an operator selecting any listed option.
Generalised from `categories/state-pill.blade.php` and
`menus/state-pill.blade.php` (module copies of the same component, kept
in lockstep by hand). The domain vocabularies (CategoryStatus, per-store
visibility, menu/menu-item active state) are left out — callers pass
their own `options` tone/label pairs.
--}}
@props([
'value' => null,
// [['value','label','tone' => 'soft-success'|'soft-warning'|'soft-destructive'|'soft-info'|'soft-neutral',
// 'url' => null, 'event' => null, 'payload' => null]]
// An option with a url posts to it. Otherwise it dispatches: 'event' (default
// 'status-select:change') with 'payload' (default the whole option).
'options' => [],
'heading' => null,
'ariaLabel' => null,
// Optional disclaimer under the options, e.g. "Preview only — nothing is saved yet."
'note' => null,
'anchored' => true,
])
@php
$options = array_values(array_filter((array) $options, 'is_array'));
$current = collect($options)->firstWhere('value', $value) ?? ($options[0] ?? null);
$heading ??= __('Change status');
$ariaLabel ??= $current
? __('Change status, currently :status', ['status' => $current['label'] ?? ''])
: $heading;
// A stable id to dispatch FROM. `anchored` defaults to true, so the menu
// teleports to <body>: an Alpine `$dispatch` from an option bubbles to
// <body> and never reaches a host that wraps this component in the page.
// Dispatching from this component's own root, which stays where the caller
// put it, makes the event arrive wherever the host listens. The caller's
// own id wins when given, so two instances never collide.
$instanceId = (string) ($attributes->get('id') ?: 'status-select-'.\Illuminate\Support\Str::random(8));
// The payload is encoded with Js::from, never string-interpolated, so a
// label/value containing quotes can't break out of the JS expression.
$changeHandler = static function (array $option) use ($instanceId): string {
$event = (string) ($option['event'] ?? 'status-select:change');
$payload = $option['payload'] ?? $option;
return 'document.getElementById('.\Illuminate\Support\Js::from($instanceId).')'
.'?.dispatchEvent(new CustomEvent('.\Illuminate\Support\Js::from($event).', '
.'{ detail: '.\Illuminate\Support\Js::from($payload).', bubbles: true }))';
};
// The status dot in the menu takes its colour from the option's badge tone.
$dotClass = static fn (?string $tone): string => match ($tone) {
'soft-success' => 'bg-success',
'soft-warning' => 'bg-warning',
'soft-destructive' => 'bg-destructive',
'soft-info' => 'bg-info',
default => 'bg-muted-foreground',
};
// The radio item's own leading dot and ps-8 make room for a mark this row
// draws itself (dot at the start, check at the end), so both are overridden.
$rowClass = '!ps-2 [&>span:first-child]:hidden';
@endphp
{{-- `current` mirrors the chosen option so an event-only pick shows at once;
a url option posts and the page re-renders with the server's answer. One
badge per option keeps the pill a real <x-ui.badge> rather than a class map. --}}
<div
data-slot="status-select"
id="{{ $instanceId }}"
x-data="{ current: {{ Js::from($current['value'] ?? null) }} }"
{{ $attributes->except(['data-slot', 'id'])->merge(['class' => 'inline-block']) }}
>
<x-ui.dropdown :anchored="$anchored">
<x-ui.dropdown.trigger
:aria-label="$ariaLabel"
:title="$ariaLabel"
class="group/status rounded-full focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background"
>
@foreach ($options as $option)
<x-ui.badge
:variant="$option['tone'] ?? 'soft-neutral'"
size="status"
x-show="current === {{ Js::from($option['value'] ?? null) }}"
:style="($option['value'] ?? null) === ($current['value'] ?? null) ? null : 'display:none'"
class="gap-1 whitespace-nowrap transition-opacity group-hover/status:opacity-80 motion-reduce:transition-none"
>
{{ $option['label'] ?? '' }}
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="ms-1 inline-block size-3 align-[-2px] opacity-70 transition-transform group-aria-expanded/status:rotate-180 motion-reduce:transition-none"><path d="m6 9 6 6 6-6" /></svg>
</x-ui.badge>
@endforeach
@if ($current === null)
<x-ui.badge variant="soft-neutral" size="status" class="whitespace-nowrap">{{ __('Unknown') }}</x-ui.badge>
@endif
</x-ui.dropdown.trigger>
<x-ui.dropdown.content align="start" class="w-56">
<x-ui.dropdown.label>{{ $heading }}</x-ui.dropdown.label>
<x-ui.dropdown.radio-group :label="$heading">
@foreach ($options as $option)
@php $checked = ($option['value'] ?? null) === $value; @endphp
@if (! empty($option['url']))
<form method="POST" action="{{ $option['url'] }}" class="contents">
@csrf
<x-ui.dropdown.radio-item
:checked="$checked"
::aria-checked="(current === {{ Js::from($option['value'] ?? null) }}).toString()"
x-on:click="$el.closest('form').requestSubmit(); closeAndFocus()"
:class="$rowClass"
>
<span class="size-2 shrink-0 rounded-full {{ $dotClass($option['tone'] ?? null) }}" aria-hidden="true"></span>
<span class="min-w-0 flex-1 truncate">{{ $option['label'] ?? '' }}</span>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 opacity-0 group-aria-checked:opacity-100"><path d="M20 6 9 17l-5-5" /></svg>
</x-ui.dropdown.radio-item>
</form>
@else
<x-ui.dropdown.radio-item
:checked="$checked"
::aria-checked="(current === {{ Js::from($option['value'] ?? null) }}).toString()"
x-on:click="current = {{ Js::from($option['value'] ?? null) }}; {!! $changeHandler($option) !!}; closeAndFocus()"
:class="$rowClass"
>
<span class="size-2 shrink-0 rounded-full {{ $dotClass($option['tone'] ?? null) }}" aria-hidden="true"></span>
<span class="min-w-0 flex-1 truncate">{{ $option['label'] ?? '' }}</span>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 opacity-0 group-aria-checked:opacity-100"><path d="M20 6 9 17l-5-5" /></svg>
</x-ui.dropdown.radio-item>
@endif
@endforeach
</x-ui.dropdown.radio-group>
@if ($note)
<x-ui.dropdown.separator />
<p class="px-2 py-2 text-xs leading-4 text-pretty text-muted-foreground">{{ $note }}</p>
@endif
</x-ui.dropdown.content>
</x-ui.dropdown>
</div>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.3.1 - 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