istvan-ujjmeszaros/bootstrap-duallistbox
Bootstrap Dual Listbox is a responsive jQuery widget that turns two select boxes into a searchable dual-list transfer control for Bootstrap. It adds live filtering, move/remove-all buttons, keyboard support, and customizable styling for building accessible, mobile-friendly selection interfaces in we...
This is not a Composer/PHP package — it's a jQuery plugin (Bootstrap 3–era, last tagged 3.0.7 in 2020) that progressively enhances a <select multiple> into a dual-listbox widget. There is no service provider, no vendor:publish, no Laravel integration to speak of. Assume you will wire it up manually.
Install the asset (pick one):
npm install bootstrap-duallistbox
{{-- or quick-and-dirty via CDN --}}
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/bootstrap-duallistbox.css">
<script src="https://unpkg.com/[email protected]/dist/jquery.bootstrap-duallistbox.min.js"></script>
Check the dist/ folder after install for exact filenames — the plugin JS is jquery.bootstrap-duallistbox.min.js / .js.
Where to look first:
moveOnSelect, filterTextClear, infoText, nonselectedlistlabel, selectedlistlabel, preserveSelectionOnMove, showFilterInputs, etc.).destroy / refresh methods — you will need them for any re-render.<select class="bootstrap-duallistbox-..."> elements + the original hidden <select>) — most debugging happens there.First use case — roles/permissions on a user form:
{{-- resources/views/admin/users/_form.blade.php --}}
<select name="roles[]" id="roles" multiple class="form-control">
@foreach ($roles as $id => $name)
<option value="{{ $id }}" @selected(in_array($id, old('roles', $user->roles->pluck('id')->all() ?? [])))>
{{ $name }}
</option>
@endforeach
</select>
// resources/js/app.js
import $ from 'jquery';
window.$ = window.jQuery = $;
import 'bootstrap-duallistbox/dist/jquery.bootstrap-duallistbox.min.js';
import 'bootstrap-duallistbox/dist/bootstrap-duallistbox.css';
$(function () {
$('#roles').bootstrapDualListbox({
nonselectedlistlabel: 'Available',
selectedlistlabel: 'Assigned',
filterPlaceHolder: 'Filter…',
moveOnSelect: false,
preserveSelectionOnMove: 'moved',
infoText: 'Showing all {0}',
infoTextFiltered: '<span class="badge bg-warning">{0}</span> / {1}',
infoTextEmpty: 'Empty list',
});
});
Server side, nothing changes: 'roles' => ['array'], 'roles.*' => ['integer', 'exists:roles,id'].
Vite + jQuery ordering. jQuery must be on window before the plugin script evaluates. The snippet above (assign then import) works because ES module imports are hoisted — if you get $ is not defined inside the plugin, switch to a dynamic import or configure optimizeDeps.include / use the jquery global via a vite.config.js alias. Breeze/Vite no longer ships jQuery; add it explicitly and pin the plugin version.
Reusable Blade component. Wrap the select in an anonymous component so the id, name, and selected values are consistent — the plugin relies on the id for init.
{{-- resources/views/components/dual-listbox.blade.php --}}
@props(['name', 'options', 'selected' => [], 'label' => null])
<select name="{{ $name }}[]" {{ $attributes->merge(['class' => 'form-control duallistbox']) }} multiple>
@foreach ($options as $value => $text)
<option value="{{ $value }}" @selected(in_array($value, (array) $selected))>{{ $text }}</option>
@endforeach
</select>
@push('scripts')
<script>document.addEventListener('DOMContentLoaded', () => $('.{{ $attributes->get('class') }}').bootstrapDualListbox());</script>
@endpush
Multiple instances on a page. The plugin only enhances the first match per call. Loop with .each() and pass per-element options (labels differ per field):
$('.duallistbox').each(function () {
$(this).bootstrapDualListbox({ moveOnSelect: false });
});
Pre-selection & ordering. The plugin reads selected attributes at init, and preserves order you add options to the selected side. For belongsToMany with pivot ordering, sort the incoming collection before rendering; the plugin will not sort for you.
Livewire / Alpine / Inertia re-renders. The plugin mutates DOM nodes your framework believes it owns. The reliable pattern is destroy → patch options → re-init:
function resetDualList(el) {
if (el.dataset.dlInit === '1') {
$(el).bootstrapDualListbox('destroy');
el.dataset.dlInit = '0';
}
$(el).bootstrapDualListbox(/* options */);
el.dataset.dlInit = '1';
}
document.addEventListener('livewire:navigated', () => {
document.querySelectorAll('select.duallistbox').forEach(resetDualList);
});
// Livewire 3: Livewire.hook('morph.updated', ({ el }) => { ...if (el.contains(el.closest('select.duallistbox'))) resetDualList(el.closest('select.duallistbox')); });
For an Inertia + Vue/React page, prefer mounting the <select> inside a ref div, init in onMounted, destroy in onBeforeUnmount. If the component re-renders often, this widget adds friction — consider a non-jQuery alternative.
Dynamic option lists (dependent dropdowns). There is a refresh command, but filter inputs and info text are only reliably rebuilt on destroy/re-init:
const $el = $('#permissions');
$el.bootstrapDualListbox('destroy');
$el.empty();
$.each(newOptions, (v, t) => $el.append(new Option(t, v)));
$el.bootstrapDualListbox({ moveOnSelect: false });
Form UX conventions that age well:
moveOnSelect: false + preserveSelectionOnMove: 'moved' — lets users ctrl-click a batch, then move; feels native for permission pickers.showFilterInputs: true whenever > ~20 options; the filter is this plugin's main advantage over a plain multi-select.__() in the Blade partial, not hard-coded strings.id per select and reuse the component — don't re-implement options inline in every view.Integration with server-side validation and old input. Because the original <select multiple> is merely hidden (not removed), old('roles', ...) in Blade and Laravel's array validation work untouched. Never build the options in JS only — always render server-side first for graceful degradation when JS fails.
composer require istvan-ujjmeszaros/..., that doesn't exist. This is a frontend asset only; budget for Vite/CDN wiring and your own Blade wrapper..btn-default, .list-group markup assumptions) and there is no official BS5 build. Expect CSS touch-ups: .bootstrap-duallistbox-container .btn overrides, filter input form-control styling, and box heights. For greenfield projects without a jQuery requirement, Tom Select / Choices.js / Slim Select are better-maintained.window.jQuery/window.$, and pin the plugin to an exact version ("bootstrap-duallistbox": "3.0.7") — the package is deprecated enough that ^3 may resolve to something unexpected later.select.bootstrap-duallistbox-nonselected-list_ and select.bootstrap-duallistbox-selected-list_ plus the original hidden select. Read form state from the original ($('#roles').val()), never from the clones. Any JS that grabs select[name="roles[]"] and iterates options may see duplicates.change on the original select when options move; if you have dependent UI (e.g. "count selected"), bind to .on('change', ...) on the original and test it — otherwise attach a manual trigger inside a setInterval-free mutation observer, or wrap your own move handlers via on... callbacks if exposed.('destroy') before a Livewire/Alpine/Turbo re-render leads to orphaned widget DOM, duplicated filter inputs, and doubled name="roles[]" submissions. Always track an data-dl-init flag.multiple? (3) Is the ID unique on the page — the plugin's internal selectors are class-based, and duplicate IDs break init? (4) Did you re-init after replacing options? (5) Does the hidden select still show the right val()? If yes, the problem is CSS, not the plugin.destroy/refresh, and options are strings for i18n. For anything richer than a multi-select (async search, create-on-the-fly, grouping), you're fighting the widget — swap packages rather than patching it.NOASSERTION on the registry side is a red flag for procurement; the GitHub repo carries a license file, so verify it matches the (WTFPL/MIT-ish) upstream before including in a client deliverable.How can I help you explore Laravel packages today?