# Select

Displays a list of options for the user to pick from, triggered by a button.

- Install: `php artisan blatui:add select`
- Source: https://blatui.remix-it.com/r/select.json
- Composer: `composer require mallardduck/blade-lucide-icons gehrisandro/tailwind-merge-laravel`

## resources/views/components/ui/select.blade.php

```blade
{{--
    Select root.
      native  false (default) -> the BlatUI JS listbox (rich, custom options).
              true            -> a bare, BlatUI-styled native <select> for no-JS form
                                 layers (submits without JS, name-bound). Put <option>s in
                                 the slot and mark the selected one with `selected`.
      size    sm | default | lg  (native only; height)
      multiple false (default) -> single pick. true -> pick many; selected render as
              removable chips in the trigger and the list stays open. Submits as `name[]`.
      options [value => label] shorthand — auto-composes trigger/content/items (or <option>s
              when native). Omit it to use the compositional API via the slot.
      placeholder  trigger text when nothing is selected (pass a translated string; '' by default
                   so no English ever leaks).
--}}
@props([
    'name' => null,
    'value' => '',
    'native' => false,
    'size' => 'default',
    'multiple' => false,
    'options' => null,
    'placeholder' => '',
    'color' => null,
    'indicator' => 'check',   // check | checkbox | radio — how a selected option is marked (custom listbox only)
])

@php
    $hasOptions = is_array($options) && count($options) > 0;
    // Normalise to [value => label]: associative keys are values; a plain list uses value === label.
    $normalized = [];
    if ($hasOptions) {
        foreach ($options as $k => $v) {
            if (is_int($k)) {
                $normalized[(string) $v] = (string) $v;
            } else {
                $normalized[(string) $k] = (string) $v;
            }
        }
    }

    // Multiple seeds an array of values; single keeps the scalar string.
    $selectedValues = collect(is_array($value) ? $value : (($value === '' || $value === null) ? [] : [$value]))
        ->map(fn ($v) => (string) $v)->values();
    $initialValue = $multiple ? $selectedValues : (string) $value;

    // `color` brands the trigger's focus ring locally (overrides the ring/primary tokens).
    $colorStyle = $color ? "--ring: {$color}; --primary: {$color}; --primary-foreground: #ffffff;" : '';
    $userStyle = (string) $attributes->get('style', '');
    $style = trim($colorStyle.($colorStyle && $userStyle ? ' ' : '').$userStyle);
    $attributes = $attributes->except('style');

    // Livewire bridge — the native <select> binds wire:model directly; the custom listbox
    // entangles its Alpine value instead. No-op without Livewire.
    $wireModel = \Illuminate\View\ComponentAttributeBag::hasMacro('wire') ? $attributes->wire('model') : null;
    $hasWire = $wireModel && is_string($wireModel->value()) && $wireModel->value() !== '';
    if (! $native && $hasWire) { $attributes = $attributes->whereDoesntStartWith('wire:model'); }
@endphp

@if ($native)
    @php
        $nativeSizes = ['sm' => 'h-8 text-sm', 'default' => 'h-9', 'lg' => 'h-10'];
        $nativeSize = $nativeSizes[$size] ?? $nativeSizes['default'];
    @endphp
    <select
        @if ($name) name="{{ $name }}{{ $multiple ? '[]' : '' }}" @endif
        @if ($multiple) multiple @endif
        data-slot="select"
        data-size="{{ $size }}"
        @if ($style) style="{{ $style }}" @endif
        {{ $attributes->twMerge('blat-select '.($multiple ? 'h-auto min-h-9 py-1' : $nativeSize)) }}
    >
        @if ($hasOptions)
            @if (! $multiple && $placeholder !== '')
                <option value="" disabled @selected($value === '')>{{ $placeholder }}</option>
            @endif
            @foreach ($normalized as $val => $lab)
                <option value="{{ $val }}" @selected($selectedValues->contains((string) $val))>{{ $lab }}</option>
            @endforeach
        @else
            {{ $slot }}
        @endif
    </select>
@else
    <div
        data-slot="select"
        x-data="blatSelect({ value: @if ($hasWire)@entangle($wireModel)@else @js($initialValue)@endif, multiple: @js((bool) $multiple)@if ($hasWire), entangled: true @endif })"
        x-id="['blat-listbox']"
        @if ($style) style="{{ $style }}" @endif
        {{ $attributes->twMerge('relative') }}
    >
        @if ($name)
            @if ($multiple)
                <template x-for="v in value" :key="v">
                    <input type="hidden" name="{{ $name }}[]" :value="v">
                </template>
            @else
                <input type="hidden" name="{{ $name }}" :value="value">
            @endif
        @endif
        @if ($hasOptions)
            <x-ui.select-trigger :class="'w-full'.($multiple ? ' data-[size=default]:h-auto min-h-9 py-1' : '')" :ariaLabel="$placeholder !== '' ? $placeholder : 'Select option'">
                <x-ui.select-value :placeholder="$placeholder" />
            </x-ui.select-trigger>
            <x-ui.select-content :indicator="$indicator">
                @foreach ($normalized as $val => $lab)
                    <x-ui.select-item :value="$val">{{ $lab }}</x-ui.select-item>
                @endforeach
            </x-ui.select-content>
        @else
            {{ $slot }}
        @endif
    </div>
@endif
```

## resources/views/components/ui/select-content.blade.php

```blade
@props([
    'align' => 'start',
    'side' => 'bottom',
    'sideOffset' => 4,
    'indicator' => 'check',   // check | checkbox | radio — cascades to child select-item options
])

@php
    $placement = $side.($align === 'center' ? '' : '-'.$align);
    $anchorAttr = 'x-blat-anchor.'.$placement.'.offset.'.$sideOffset.'="$refs.trigger"';
@endphp

<template x-teleport="body" wire:ignore>
    <div
        x-blat-dialog-layer
        x-show="open"
        x-cloak
        x-init="_list = $el"
        {!! $anchorAttr !!}
        @click.outside="close(false)"
        @keydown.escape.prevent.stop="close()"
        @keydown.tab.prevent.stop="close()"
        @keydown.enter.prevent.stop="document.activeElement?.click()"
        @keydown.space.prevent.stop="document.activeElement?.click()"
        @keydown="$blatNav($event, { selector: '[role=option]' }); $blatType($event, '[role=option]')"
        :id="$id('blat-listbox')"
        role="listbox"
        tabindex="-1"
        data-slot="select-content"
        :data-state="open ? 'open' : 'closed'"
        x-transition:enter="transition ease-out duration-150"
        x-transition:enter-start="opacity-0 scale-95"
        x-transition:enter-end="opacity-100 scale-100"
        x-transition:leave="transition ease-in duration-100"
        x-transition:leave-start="opacity-100 scale-100"
        x-transition:leave-end="opacity-0 scale-95"
        {{ $attributes->twMerge('bg-popover text-popover-foreground fixed z-50 max-h-96 min-w-[8rem] origin-top overflow-x-hidden overflow-y-auto rounded-md border shadow-md') }}
    >
        <div data-slot="select-viewport" class="p-1">
            {{ $slot }}
        </div>
    </div>
</template>
```

## resources/views/components/ui/select-group.blade.php

```blade
<div role="group" data-slot="select-group" x-blat-labelledby="{ label: ':scope > [data-slot=select-label]' }" {{ $attributes }}>
    {{ $slot }}
</div>
```

## resources/views/components/ui/select-item.blade.php

```blade
@aware(['indicator' => 'check'])

@props([
    'value' => '',
    'disabled' => false,
    'indicator' => 'check',   // check | checkbox | radio — usually set once on the select or select-content wrapper
])

@php
    $indicator = in_array($indicator, ['check', 'checkbox', 'radio'], true) ? $indicator : 'check';
    $jsVal = \Illuminate\Support\Js::from((string) $value)->toHtml();
@endphp

<div
    role="option"
    tabindex="-1"
    data-slot="select-item"
    data-value="{{ $value }}"
    @if ($disabled) data-disabled aria-disabled="true" @endif
    @if (! $disabled)
        @click="selectOption(@js((string) $value), $el.querySelector('[data-slot=select-item-label]').textContent.trim())"
    @endif
    x-init="seedSelected(@js((string) $value), $el.querySelector('[data-slot=select-item-label]').textContent.trim())"
    :aria-selected="isSelected(@js((string) $value))"
    :data-state="isSelected(@js((string) $value)) ? 'checked' : 'unchecked'"
    {{ $attributes->twMerge("hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4") }}
>
    @switch($indicator)
        @case('checkbox')
            <span class="absolute right-2 flex items-center justify-center">
                <span class="border-input flex size-4 items-center justify-center rounded-[4px] border transition-colors" :class="isSelected({!! $jsVal !!}) && 'bg-primary border-primary text-primary-foreground'">
                    <x-lucide-check class="size-3" x-bind:class="isSelected({!! $jsVal !!}) ? 'opacity-100' : 'opacity-0'" aria-hidden="true" />
                </span>
            </span>
            @break

        @case('radio')
            <span class="absolute right-2 flex items-center justify-center">
                <span class="border-input flex size-4 items-center justify-center rounded-full border transition-colors" :class="isSelected({!! $jsVal !!}) && 'border-primary'">
                    <span class="bg-primary size-2 rounded-full transition-opacity" :class="isSelected({!! $jsVal !!}) ? 'opacity-100' : 'opacity-0'"></span>
                </span>
            </span>
            @break

        @default
            <span class="absolute right-2 flex size-3.5 items-center justify-center">
                <x-lucide-check class="size-4" x-show="isSelected({!! $jsVal !!})" x-cloak aria-hidden="true" />
            </span>
    @endswitch
    <span data-slot="select-item-label" class="flex items-center gap-2">{{ $slot }}</span>
</div>
```

## resources/views/components/ui/select-label.blade.php

```blade
<div data-slot="select-label" {{ $attributes->twMerge('text-muted-foreground px-2 py-1.5 text-xs') }}>
    {{ $slot }}
</div>
```

## resources/views/components/ui/select-separator.blade.php

```blade
<div data-slot="select-separator" role="separator" aria-orientation="horizontal" {{ $attributes->twMerge('bg-border pointer-events-none -mx-1 my-1 h-px') }}></div>
```

## resources/views/components/ui/select-trigger.blade.php

```blade
@props(['size' => 'default', 'ariaLabel' => null])

<button
    type="button"
    x-ref="trigger"
    x-init="_trigger = $el"
    @if ($ariaLabel) aria-label="{{ $ariaLabel }}" @endif
    @click="toggleList()"
    @keydown.down.prevent.stop="openList()"
    @keydown.up.prevent.stop="openList()"
    @keydown.enter.prevent.stop="openList()"
    @keydown.space.prevent.stop="openList()"
    role="combobox"
    aria-haspopup="listbox"
    :aria-controls="$id('blat-listbox')"
    :aria-expanded="open"
    :data-state="open ? 'open' : 'closed'"
    :data-placeholder="label ? null : true"
    data-slot="select-trigger"
    data-size="{{ $size }}"
    {{ $attributes->twMerge("border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 data-[size=lg]:h-10 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4") }}
>
    {{ $slot }}
    <x-lucide-chevron-down class="size-4 opacity-50" aria-hidden="true" />
</button>
```

## resources/views/components/ui/select-value.blade.php

```blade
@props(['placeholder' => ''])

<span
    data-slot="select-value"
    {{ $attributes->twMerge('pointer-events-none flex flex-1 flex-wrap items-center gap-1') }}
>
    {{-- single-select label --}}
    <span x-show="!multiple" x-text="label || @js($placeholder)" :class="{ 'text-muted-foreground': !label }"></span>

    {{-- multi-select: placeholder when empty, else removable chips --}}
    <span x-show="multiple && !selected.length" class="text-muted-foreground">{{ $placeholder }}</span>
    <template x-for="o in selected" :key="o.value">
        <span x-show="multiple" class="bg-secondary text-secondary-foreground inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium">
            <span x-text="o.label"></span>
            <span role="button" tabindex="-1" :aria-label="'Remove ' + o.label" @click.stop.prevent="remove(o.value)"
                class="hover:text-foreground/70 pointer-events-auto inline-flex cursor-pointer items-center rounded-sm outline-none">
                <x-lucide-x class="size-3" aria-hidden="true" />
            </span>
        </span>
    </template>
</span>
```
