Skip to main content

Forms

Cascader code

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

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

@props([
    'options' => [],
    'name' => null,
    'value' => null,
    'placeholder' => 'Select…',
    'size' => 'base',
    'invalid' => false,
    'disabled' => false,
    'separator' => ' / ',
    'clearable' => true,
])

@php
    /*
    | Multi-level cascading select. `options` is a nested tree of
    | ['value' => ..., 'label' => ..., 'children' => [...]]; clicking a parent
    | opens the next column, clicking a leaf selects the full path. The leaf
    | value is submitted via a hidden input bound to `name`.
    |
    | Usage:
    |   <x-ui.cascader name="category" :options="$tree" value="frameworks" />
    */
    $sizes = [
        'sm' => 'text-sm py-1.5',
        'base' => 'text-sm py-2.5',
        'lg' => 'text-base py-3',
    ];

    $ring = $invalid
        ? 'border-red-400 dark:border-red-500 focus:border-red-500 focus:ring-red-500'
        : 'border-gray-300 dark:border-gray-700 focus:border-brand-500 focus:ring-brand-500';

    $classes = implode(' ', [
        'flex w-full items-center justify-between gap-2 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 shadow-sm ps-3.5 pe-3 text-left transition focus:ring-1 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer',
        $sizes[$size] ?? $sizes['base'],
        $ring,
    ]);
@endphp

<div
    x-data="{
        open: false,
        options: @js(array_values($options)),
        path: [],
        levels: [],
        selected: null,
        findPath(nodes, value, trail = []) {
            for (const node of nodes) {
                const next = trail.concat([node]);
                if (node.value === value) { return next; }
                if (node.children) {
                    const found = this.findPath(node.children, value, next);
                    if (found) { return found; }
                }
            }
            return null;
        },
        init() {
            this.levels = [this.options];
            const initial = @js($value);
            if (initial) {
                const trail = this.findPath(this.options, initial);
                if (trail) {
                    this.path = trail;
                    trail.forEach((node) => {
                        if (node.children && node.children.length) { this.levels.push(node.children); }
                    });
                    const leaf = trail[trail.length - 1];
                    if (! leaf.children || ! leaf.children.length) { this.selected = leaf; }
                }
            }
        },
        get label() {
            return this.selected ? this.path.map((i) => i.label).join(@js($separator)) : '';
        },
        choose(item, depth) {
            this.path = this.path.slice(0, depth).concat([item]);
            if (item.children && item.children.length) {
                this.levels = this.levels.slice(0, depth + 1).concat([item.children]);
                this.selected = null;
            } else {
                this.levels = this.levels.slice(0, depth + 1);
                this.selected = item;
                this.open = false;
                this.$dispatch('cascader-selected', { value: item.value, path: this.path.map((i) => i.value) });
            }
        },
        clear() {
            this.selected = null;
            this.path = [];
            this.levels = [this.options];
        },
        isInPath(item, depth) {
            return !! (this.path[depth] && this.path[depth].value === item.value);
        },
    }"
    @click.outside="open = false"
    @keydown.escape.window="open = false"
    {{ $attributes->except(['class', 'id'])->merge(['class' => 'relative '.$attributes->get('class', '')]) }}
>
    @if ($name)
        <input type="hidden" name="{{ $name }}" :value="selected ? selected.value : ''">
    @endif

    <button
        type="button"
        @disabled($disabled)
        @if ($attributes->get('id')) id="{{ $attributes->get('id') }}" @endif
        @if ($invalid) aria-invalid="true" @endif
        @click="open = ! open"
        :aria-expanded="open.toString()"
        aria-haspopup="listbox"
        class="{{ $classes }}"
    >
        <span x-show="label" x-text="label" class="truncate"></span>
        <span x-show="! label" class="text-gray-400 dark:text-gray-500">{{ $placeholder }}</span>
        <span class="flex items-center gap-1">
            @if ($clearable)
                <span
                    x-show="selected"
                    x-cloak
                    @click.stop="clear()"
                    class="rounded p-0.5 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 cursor-pointer"
                    role="button"
                    aria-label="Clear selection"
                >
                    <x-ui.icon name="x" weight="bold" size="sm" />
                </span>
            @endif
            <x-ui.icon name="caret-down" weight="bold" size="sm" class="text-gray-400 transition-transform" x-bind:class="open && 'rotate-180'" />
        </span>
    </button>

    <div
        x-show="open" x-cloak
        x-transition:enter="transition ease-out duration-150"
        x-transition:enter-start="opacity-0 scale-95"
        x-transition:enter-end="opacity-100 scale-100"
        class="absolute start-0 top-full z-50 mt-2 flex divide-x divide-gray-200 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg ring-1 ring-black/5 dark:divide-gray-800 dark:border-gray-800 dark:bg-gray-900"
        role="listbox"
    >
        <template x-for="(level, depth) in levels" :key="depth">
            <div class="max-h-60 w-48 overflow-y-auto p-1.5">
                <template x-for="item in level" :key="item.value">
                    <button
                        type="button"
                        @click="choose(item, depth)"
                        :class="isInPath(item, depth)
                            ? 'bg-brand-50 text-brand-700 dark:bg-brand-950/40 dark:text-brand-300'
                            : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800'"
                        class="flex w-full items-center justify-between gap-2 rounded-lg px-3 py-2 text-start text-sm font-medium transition cursor-pointer"
                        role="option"
                        :aria-selected="isInPath(item, depth).toString()"
                    >
                        <span class="min-w-0 truncate" x-text="item.label"></span>
                        <x-ui.icon x-show="item.children && item.children.length" name="caret-right" weight="bold" size="xs" class="shrink-0 text-gray-400" />
                        <x-ui.icon x-show="(! item.children || ! item.children.length) && selected && selected.value === item.value" name="check" weight="bold" size="sm" class="shrink-0 text-brand-500" />
                    </button>
                </template>
            </div>
        </template>
    </div>
</div>