Skip to main content

Overlays

Segmented code

Copy the implementation or inspect every file that belongs to this component unit.

resources/views/components/ui/segmented.blade.php

@props([
    'name' => null,
    'options' => [],
    'value' => null,
    'size' => 'base',
])

@php
    /*
    | Segmented control — a compact single-select toggle between a few options.
    | Pass `options` as [value => label]. Submits the active value via a hidden
    | input when `name` is set.
    |
    | size: sm, base
    |
    | Usage:
    |   <x-ui.segmented name="view" :options="['list' => 'List', 'grid' => 'Grid']" value="list" />
    */
    $first = $value ?? array_key_first($options);

    $sizes = [
        'sm' => 'text-xs px-2.5 py-1',
        'base' => 'text-sm px-3.5 py-1.5',
    ];
    $btnSize = $sizes[$size] ?? $sizes['base'];
    $optionKeys = array_map(fn ($key) => (string) $key, array_keys($options));
    $defaultIndex = array_search((string) $first, $optionKeys, true);
    $defaultIndex = $defaultIndex === false ? 0 : $defaultIndex;
    $groupLabel = $name ? Str::headline(str_replace(['-', '_'], ' ', str_replace(['[', ']'], '', (string) $name))) : null;
@endphp

<div
    x-data="{
        active: @js((string) $first),
        keys: @js($optionKeys),
        focusIndex: @js($defaultIndex),
        activate(key) {
            const index = this.keys.indexOf(key);

            if (index === -1) {
                return;
            }

            this.active = key;
            this.focusIndex = index;
        },
        focusKey(key) {
            this.$nextTick(() => this.$refs['option-' + key]?.focus());
        },
        move(direction) {
            if (this.keys.length === 0) {
                return;
            }

            const next = (this.focusIndex + direction + this.keys.length) % this.keys.length;
            const key = this.keys[next];

            this.activate(key);
            this.focusKey(key);
        },
    }"
    {{ $attributes->merge(['class' => 'inline-flex items-center gap-1 rounded-xl bg-gray-100 dark:bg-gray-800 p-1']) }}
    role="radiogroup"
    @if ($groupLabel) aria-label="{{ $groupLabel }}" @endif
>
    @if ($name)
        <input type="hidden" name="{{ $name }}" :value="active">
    @endif

    @foreach ($options as $key => $label)
        <button
            type="button"
            x-ref="option-{{ $key }}"
            role="radio"
            :aria-checked="(active === @js((string) $key)).toString()"
            :tabindex="active === @js((string) $key) ? '0' : '-1'"
            @click="activate(@js((string) $key))"
            @keydown.arrow-right.prevent="move(1)"
            @keydown.arrow-down.prevent="move(1)"
            @keydown.arrow-left.prevent="move(-1)"
            @keydown.arrow-up.prevent="move(-1)"
            @keydown.home.prevent="activate(keys[0]); focusKey(keys[0])"
            @keydown.end.prevent="activate(keys[keys.length - 1]); focusKey(keys[keys.length - 1])"
            :class="active === @js((string) $key) ? 'bg-white dark:bg-gray-900 text-gray-900 dark:text-white shadow-sm' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'"
            class="rounded-lg font-medium transition cursor-pointer {{ $btnSize }}"
        >{{ $label }}</button>
    @endforeach
</div>