Skip to main content

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>