Forms
Transfer List code
Copy the implementation or inspect every file that belongs to this component unit.
resources/views/components/ui/transfer-list.blade.php
@props([
'options' => [],
'selected' => [],
'name' => null,
'availableLabel' => 'Available',
'selectedLabel' => 'Selected',
'searchable' => true,
'maxHeight' => '14rem',
])
@php
/*
| Dual-list transfer control. Click rows to mark them, then use the middle
| buttons to move them between Available and Selected. The chosen values are
| submitted as hidden `name[]` inputs.
|
| options: [value => label] or [['value' => ..., 'label' => ...], ...]
| selected: array of values that start in the right-hand list
|
| Usage:
| <x-ui.transfer-list name="permissions" :options="$permissions" :selected="['site:deploy']" />
*/
$normalize = fn ($items) => collect($items)->map(fn ($item, $key) => is_array($item)
? ['value' => (string) ($item['value'] ?? $key), 'label' => (string) ($item['label'] ?? $item['value'] ?? $key)]
: ['value' => (string) $key, 'label' => (string) $item]
)->values();
$all = $normalize($options);
$selectedValues = collect($selected)->map(fn ($v) => (string) $v);
$chosen = $all->filter(fn ($item) => $selectedValues->contains($item['value']))->values();
$available = $all->reject(fn ($item) => $selectedValues->contains($item['value']))->values();
@endphp
<div
x-data="{
available: @js($available),
chosen: @js($chosen),
leftMarked: [],
rightMarked: [],
leftQuery: '',
rightQuery: '',
matches(item, query) {
return item.label.toLowerCase().includes(query.toLowerCase());
},
get leftVisible() {
return this.available.filter((i) => this.matches(i, this.leftQuery));
},
get rightVisible() {
return this.chosen.filter((i) => this.matches(i, this.rightQuery));
},
toggle(list, value) {
const marked = list === 'left' ? this.leftMarked : this.rightMarked;
const index = marked.indexOf(value);
if (index === -1) { marked.push(value); } else { marked.splice(index, 1); }
},
isMarked(list, value) {
return (list === 'left' ? this.leftMarked : this.rightMarked).includes(value);
},
move(direction, all = false) {
if (direction === 'right') {
const values = all ? this.available.map((i) => i.value) : this.leftMarked;
this.chosen = this.chosen.concat(this.available.filter((i) => values.includes(i.value)));
this.available = this.available.filter((i) => ! values.includes(i.value));
this.leftMarked = [];
} else {
const values = all ? this.chosen.map((i) => i.value) : this.rightMarked;
this.available = this.available.concat(this.chosen.filter((i) => values.includes(i.value)));
this.chosen = this.chosen.filter((i) => ! values.includes(i.value));
this.rightMarked = [];
}
},
}"
{{ $attributes->merge(['class' => 'grid gap-3 sm:grid-cols-[1fr_auto_1fr]']) }}
>
@if ($name)
<template x-for="item in chosen" :key="item.value">
<input type="hidden" name="{{ $name }}[]" :value="item.value">
</template>
@endif
@foreach ([['side' => 'left', 'label' => $availableLabel, 'query' => 'leftQuery', 'visible' => 'leftVisible'], ['side' => 'right', 'label' => $selectedLabel, 'query' => 'rightQuery', 'visible' => 'rightVisible']] as $panel)
<div class="rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<div class="flex items-center justify-between gap-2 border-b border-gray-200 px-3.5 py-2.5 dark:border-gray-800">
<span class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ $panel['label'] }}</span>
<span class="text-xs text-gray-400 dark:text-gray-500" x-text="({{ $panel['side'] === 'left' ? 'available' : 'chosen' }}.length) + ' items'"></span>
</div>
@if ($searchable)
<div class="border-b border-gray-200 px-3 py-2 dark:border-gray-800">
<input
type="text"
x-model="{{ $panel['query'] }}"
placeholder="Filter…"
class="block w-full rounded-md border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition focus:border-brand-500 focus:ring-1 focus:ring-brand-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500"
>
</div>
@endif
<div class="overflow-y-auto p-1.5" style="max-height: {{ $maxHeight }}">
<template x-for="item in {{ $panel['visible'] }}" :key="item.value">
<button
type="button"
@click="toggle('{{ $panel['side'] }}', item.value)"
@dblclick="move('{{ $panel['side'] === 'left' ? 'right' : 'left' }}', false)"
:class="isMarked('{{ $panel['side'] }}', item.value)
? '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"
:aria-pressed="isMarked('{{ $panel['side'] }}', item.value).toString()"
>
<span class="min-w-0 truncate" x-text="item.label"></span>
<x-ui.icon name="check" weight="bold" size="sm" x-show="isMarked('{{ $panel['side'] }}', item.value)" class="shrink-0 text-brand-500" />
</button>
</template>
<p x-show="{{ $panel['visible'] }}.length === 0" class="px-3 py-6 text-center text-sm text-gray-400 dark:text-gray-500" x-cloak>No items</p>
</div>
</div>
@if ($loop->first)
<div class="flex items-center justify-center gap-2 sm:flex-col">
<x-ui.icon-button icon="caret-right" label="Move selected to {{ strtolower($selectedLabel) }}" variant="outline" size="sm" @click="move('right')" ::disabled="leftMarked.length === 0" />
<x-ui.icon-button icon="caret-double-right" label="Move all to {{ strtolower($selectedLabel) }}" variant="outline" size="sm" @click="move('right', true)" />
<x-ui.icon-button icon="caret-double-left" label="Move all to {{ strtolower($availableLabel) }}" variant="outline" size="sm" @click="move('left', true)" />
<x-ui.icon-button icon="caret-left" label="Move selected to {{ strtolower($availableLabel) }}" variant="outline" size="sm" @click="move('left')" ::disabled="rightMarked.length === 0" />
</div>
@endif
@endforeach
</div>