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>