Skip to content
Brok UI

Loading…

No results

Status Select

Open source

A status pill that is itself the trigger for a menu changing that status, with each option previewed as the pill it would become.

Version
v1.3.1
Stability
stable
License
MIT
Related
Badge
Dropdown Menu
Row Actions

Preview

previews.components.status-select.default.blade.php 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.') }}"
/>

Installation

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

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

  • blade resources/views/components/ui/status-select.blade.php
Registry dependencies
badge dropdown
Packages
composer: jml/brok:^0.2

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.

status-select.md
# 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.blade.php Blade
<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

manifest knowledge + registry-derived coverage

Props

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

status-select

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

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.
  • 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
status-select
Theming hooks
status-select

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-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:key

Add a stable wire:key when Livewire can reorder this interactive component.

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

resources/views/components/ui/status-select.blade.php Blade
{{--
    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