ajjitech/searchable-select

A powerful, Bootstrap 5 searchable dropdown component for Laravel Livewire 3 & 4 applications. Built with Alpine.js — no jQuery, no extra JS dependencies.

Maintainers

Package info

github.com/ajjitech/searchable-select

Language:Blade

pkg:composer/ajjitech/searchable-select

Fund package maintenance!

Williamug

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

v4.0.1 2026-05-26 07:56 UTC

README

Latest Version on Packagist run-tests Total Downloads License

A powerful, feature-rich searchable dropdown component for Laravel Livewire 3 & 4 applications. Styled with Bootstrap 5 and powered by Alpine.js (bundled with Livewire) — no jQuery, no extra JavaScript dependencies.

Table of Contents

Features

  • Real-time search — client-side filtering as you type
  • Multi-select support — select multiple options with visual badge tags
  • Grouped options — organize options into labeled categories
  • Clear button — one-click to clear all selections
  • Dark mode support — automatic via Bootstrap 5.3 CSS variables (data-bs-theme="dark")
  • Accessible — full keyboard navigation and ARIA attributes
  • Livewire 3 & 4 compatible — works seamlessly with both versions
  • Responsive — mobile-friendly and touch-optimized
  • Disabled state — conditional disabling support
  • Flexible data — works with Eloquent models, arrays, collections
  • Dependent dropdowns — perfect for cascading country → region → city selects
  • Bootstrap-native — uses Bootstrap CSS variables, no extra CSS framework required
  • Zero extra JS — Alpine.js is bundled with Livewire; no jQuery, no CDN scripts

Requirements

  • PHP: 8.2 or higher
  • Laravel: 11.x, 12.x, or 13.x
  • Livewire: 3.x or 4.x
  • Bootstrap: 5.3+
  • Alpine.js: bundled with Livewire (no separate install needed)

Installation

Install the package via Composer:

composer require ajjitech/searchable-select

The package automatically registers its service provider. You can start using it immediately.

To customize the component HTML, publish the view:

php artisan vendor:publish --tag=searchable-select-views

This copies the view to resources/views/vendor/searchable-select/searchable-select.blade.php.

Bootstrap Setup

The component is styled entirely with Bootstrap 5.3 CSS variables and scoped .ss-* custom classes embedded in the view. You do not need to configure any content scanning or build step for the component styles — they are injected inline via @once automatically.

You simply need Bootstrap 5.3+ loaded in your application.

Option A — npm (recommended)

npm install bootstrap

Import in your resources/js/app.js:

import 'bootstrap/dist/css/bootstrap.min.css';
// Bootstrap JS is optional — the component uses Alpine.js for all interactivity

Or in your resources/css/app.css:

@import "bootstrap/dist/css/bootstrap.min.css";

Option B — CDN

Add Bootstrap's CSS to your layout <head>:

<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
  integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
  crossorigin="anonymous"
>

Note: Bootstrap JS (bootstrap.bundle.min.js) is not required. The component manages all its own interactivity through Alpine.js.

Quick Start

Basic Usage

Step 1: Create a Livewire component

php artisan make:livewire ContactForm

Step 2: Set up the component class

<?php

namespace App\Livewire;

use App\Models\Country;
use Livewire\Component;

class ContactForm extends Component
{
    public $countries;
    public $country_id;

    public function mount()
    {
        $this->countries = Country::orderBy('name')->get();
    }

    public function save()
    {
        $this->validate([
            'country_id' => 'required|exists:countries,id',
        ]);
        // Save your data...
    }

    public function render()
    {
        return view('livewire.contact-form');
    }
}

Step 3: Use the component in your Blade view

<div>
    <label class="form-label">Country</label>

    <x-searchable-select
        wire:model="country_id"
        :options="$countries"
        placeholder="Select a country"
        search-placeholder="Type to search countries..."
    />

    @error('country_id')
        <div class="invalid-feedback d-block">{{ $message }}</div>
    @enderror

    <button wire:click="save" class="btn btn-primary mt-3">Save</button>
</div>

That's it. The component syncs with your Livewire property via wire:model automatically.

Component Props Reference

Prop Type Default Required Description
options Array/Collection [] Yes Options data source
placeholder String 'Select option' No Text shown when nothing is selected
searchPlaceholder String 'Search...' No Placeholder for the search input
emptyMessage String 'No options available' No Message shown when the list is empty
optionValue String 'id' No Key to use as the option value
optionLabel String 'name' No Key to use as the display label
multiple Boolean false No Enable multi-select mode
clearable Boolean true No Show/hide the × clear button
disabled Boolean false No Disable the dropdown
grouped Boolean false No Enable grouped options mode
groupLabel String 'label' No Key for group label (when grouped is true)
groupOptions String 'options' No Key for group items array (when grouped is true)
teleport Boolean true No Teleport the dropdown panel to <body> to avoid clipping by parent containers. Set to false inside Bootstrap modals.

wire:model is a standard Livewire directive. Pass it as wire:model="propertyName" and the component handles two-way binding automatically.

Usage Examples

Basic Single Select

class UserProfile extends Component
{
    public $countries;
    public $country_id;

    public function mount()
    {
        $this->countries = Country::orderBy('name')->get();
    }
}
<x-searchable-select
    wire:model="country_id"
    :options="$countries"
    placeholder="Select your country"
    search-placeholder="Search countries..."
/>

Multi-Select

Select multiple options displayed as removable badge tags:

class UserSkills extends Component
{
    public $skills;
    public array $selected_skills = [];

    public function mount()
    {
        $this->skills = Skill::orderBy('name')->get();
    }
}
<x-searchable-select
    wire:model="selected_skills"
    :options="$skills"
    :multiple="true"
    placeholder="Select your skills"
/>

@if (!empty($selected_skills))
    <p class="mt-2 text-muted small">{{ count($selected_skills) }} skills selected</p>
@endif

Selected items appear as Bootstrap-styled badge tags with × remove buttons.

Dependent/Cascading Dropdowns

class LocationSelector extends Component
{
    public $countries;
    public $regions = [];
    public $cities  = [];

    public $country_id;
    public $region_id;
    public $city_id;

    public function mount()
    {
        $this->countries = Country::orderBy('name')->get();
    }

    public function updatedCountryId($value)
    {
        $this->regions   = Region::where('country_id', $value)->orderBy('name')->get();
        $this->region_id = null;
        $this->city_id   = null;
        $this->cities    = [];
    }

    public function updatedRegionId($value)
    {
        $this->cities  = City::where('region_id', $value)->orderBy('name')->get();
        $this->city_id = null;
    }
}
<div class="row g-3">
    <div class="col-md-4">
        <label class="form-label">Country</label>
        <x-searchable-select wire:model="country_id" :options="$countries" placeholder="Select Country" />
    </div>

    <div class="col-md-4">
        <label class="form-label">Region</label>
        <x-searchable-select
            wire:model="region_id"
            :options="$regions"
            :placeholder="empty($regions) ? 'First select a country' : 'Select Region'"
            :disabled="!$country_id"
        />
    </div>

    <div class="col-md-4">
        <label class="form-label">City</label>
        <x-searchable-select
            wire:model="city_id"
            :options="$cities"
            :placeholder="empty($cities) ? 'First select a region' : 'Select City'"
            :disabled="!$region_id"
        />
    </div>
</div>

Grouped Options

public $locations = [
    [
        'label'   => 'North America',
        'options' => [
            ['id' => 1, 'name' => 'United States'],
            ['id' => 2, 'name' => 'Canada'],
            ['id' => 3, 'name' => 'Mexico'],
        ],
    ],
    [
        'label'   => 'Europe',
        'options' => [
            ['id' => 4, 'name' => 'United Kingdom'],
            ['id' => 5, 'name' => 'France'],
            ['id' => 6, 'name' => 'Germany'],
        ],
    ],
];
<x-searchable-select
    wire:model="country_id"
    :options="$locations"
    :grouped="true"
    placeholder="Select a country"
/>

Custom group keys:

public $categories = [
    [
        'category_name' => 'Fruits',
        'items' => [
            ['code' => 'APL', 'title' => 'Apple'],
            ['code' => 'BAN', 'title' => 'Banana'],
        ],
    ],
];
<x-searchable-select
    wire:model="selected_item"
    :options="$categories"
    :grouped="true"
    group-label="category_name"
    group-options="items"
    option-value="code"
    option-label="title"
/>

Custom Keys

public $products = [
    ['sku' => 'PROD-001', 'product_name' => 'Laptop'],
    ['sku' => 'PROD-002', 'product_name' => 'Mouse'],
];

public $selected_sku;
<x-searchable-select
    wire:model="selected_sku"
    :options="$products"
    option-value="sku"
    option-label="product_name"
    placeholder="Select a product"
/>

Bootstrap Modal

When using the component inside a Bootstrap modal, disable teleporting so the dropdown stays inside the modal's DOM and follows Bootstrap's focus and stacking context correctly.

<div class="modal fade" id="demoModal" tabindex="-1" aria-hidden="true" wire:ignore.self>
    <div class="modal-dialog modal-lg">
        <div class="modal-content">
            <div class="modal-body">
                <label class="form-label">Country Inside Modal</label>

                <x-searchable-select
                    wire:model.live="selectedCountry"
                    :options="$countries"
                    option-label="name"
                    option-value="id"
                    placeholder="Search country..."
                    :teleport="false"
                />
            </div>
        </div>
    </div>
</div>

With Validation

public function save()
{
    $this->validate([
        'country_id' => 'required|exists:countries,id',
    ]);
}
<div class="mb-3">
    <label class="form-label">Country <span class="text-danger">*</span></label>
    <x-searchable-select wire:model="country_id" :options="$countries" />
    @error('country_id')
        <div class="invalid-feedback d-block">{{ $message }}</div>
    @enderror
</div>

<button wire:click="save" class="btn btn-primary">Save</button>

Real-time validation:

public function updated($propertyName)
{
    $this->validateOnly($propertyName);
}

Disabled State

<x-searchable-select
    wire:model="region_id"
    :options="$regions"
    :disabled="!$country_id"
    placeholder="First select a country"
/>

Without Clear Button

<x-searchable-select
    wire:model="country_id"
    :options="$countries"
    :clearable="false"
/>

Using Arrays Instead of Models

public $statuses = [
    ['id' => 'draft',     'name' => 'Draft'],
    ['id' => 'published', 'name' => 'Published'],
    ['id' => 'archived',  'name' => 'Archived'],
];
<x-searchable-select wire:model="status" :options="$statuses" />

Advanced Features

Adding Extra CSS Classes

Extra attributes (including class) are forwarded to the trigger element:

<x-searchable-select
    wire:model="country_id"
    :options="$countries"
    class="border-primary"
/>

Creating Specialized Components

resources/views/components/country-select.blade.php:

@props(['wireModel'])

<x-searchable-select
    wire:model="{{ $wireModel }}"
    :options="\App\Models\Country::orderBy('name')->get()"
    placeholder="Select a country"
    search-placeholder="Search countries..."
    {{ $attributes }}
/>

Usage:

<x-country-select wire-model="country_id" />

Server-Side Search (Large Datasets)

For thousands of records, implement server-side search in Livewire:

public $searchTerm = '';
public $countries  = [];

public function updatedSearchTerm($value)
{
    $this->countries = Country::where('name', 'like', "%{$value}%")
        ->limit(50)
        ->get();
}

Customization Guide

Publishing Views

php artisan vendor:publish --tag=searchable-select-views

Your copy lands in resources/views/vendor/searchable-select/searchable-select.blade.php. Laravel uses your copy instead of the package default.

All component styles are scoped under .ss-* CSS classes defined in the @once style block at the top of the view. You can override any of them after publishing.

Overriding Individual Styles

After publishing the view, you can add custom CSS in your app.css:

/* Make the trigger taller */
.ss-trigger {
    min-height: 48px;
}

/* Change tag badge color */
.ss-tag {
    background-color: var(--bs-success-bg-subtle);
    color: var(--bs-success-text-emphasis);
}

/* Change selected option highlight */
.ss-option.ss-selected {
    background-color: var(--bs-success-bg-subtle);
    color: var(--bs-success-text-emphasis);
}

Dark Mode

Dark mode works automatically via Bootstrap 5.3's CSS variable system. Add data-bs-theme="dark" to your <html> element:

<html data-bs-theme="dark">

Bootstrap's CSS variables (--bs-body-bg, --bs-body-color, --bs-border-color, --bs-primary-bg-subtle, etc.) adjust automatically, and the component adapts with them. No additional configuration needed.

Troubleshooting

Dropdown doesn't open / Click doesn't work

  1. Verify Alpine.js is loaded (it comes bundled with Livewire 3+):

    @livewireScripts {{-- This includes Alpine.js --}}
  2. Ensure you are not loading Alpine.js separately when using Livewire 3+:

    <!-- Remove this if you have Livewire 3+ -->
    <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
  3. Check the browser console for JavaScript errors (F12 → Console).

Component appears unstyled

  1. Ensure Bootstrap 5.3+ CSS is loaded before your page content renders.
  2. Verify Bootstrap's dist/css/bootstrap.min.css is included, not just Bootstrap JS.
  3. Clear the Laravel view cache:
    php artisan view:clear

Options not updating / Stale data

Use wire:key when rendering multiple components in loops:

@foreach($forms as $form)
    <x-searchable-select
        wire:key="country-{{ $form->id }}"
        wire:model="forms.{{ $loop->index }}.country_id"
        :options="$countries"
    />
@endforeach

Multi-select not working

Initialize the Livewire property as an array:

// Correct
public array $selected_items = [];

// Wrong — null, not an array
public $selected_items;

Enable multiple mode in the blade:

<x-searchable-select :multiple="true" wire:model="selected_items" :options="$items" />

Selected value not displaying

Verify the selected value exists in your options and the optionValue key matches:

// If your data uses 'code' instead of 'id'
$countries = [['code' => 'US', 'name' => 'USA']];
<x-searchable-select
    wire:model="country_code"
    :options="$countries"
    option-value="code"
/>

Performance Optimization

Options Count Recommended Approach
< 100 Client-side filtering (default) — works perfectly
100 – 1,000 Client-side filtering with wire:key — still performant
1,000+ Server-side search with Livewire updated* hooks

Server-side search:

public $searchTerm = '';
public $products   = [];

public function updatedSearchTerm($value)
{
    $this->products = Product::where('name', 'like', "%{$value}%")
        ->limit(50)
        ->get();
}

Select only needed columns:

// Good — only id and name
$this->users = User::select('id', 'name')->get();

Cache static options:

public function mount()
{
    $this->countries = Cache::remember('countries', 3600, fn () =>
        Country::orderBy('name')->get()
    );
}

Testing

The package includes a comprehensive test suite.

# Run all tests
composer test

# Run with coverage
composer test -- --coverage

# Run specific test file
./vendor/bin/pest tests/Feature/ComponentTest.php

Demo Application

The package includes a full-featured demo application.

cd demo
composer install
cp .env.example .env
php artisan key:generate
php artisan migrate
php artisan serve

Visit http://localhost:8000

Contributing

  1. Fork the repository
  2. Create a feature branch: git checkout -b feature/amazing-feature
  3. Make your changes and add tests
  4. Run tests and code style checks:
    composer test
    composer format
  5. Commit and open a Pull Request

Code Style

  • Laravel Pint for PHP formatting
  • PSR-12 coding standard
  • Pest PHP for testing

Credits

Maintainer

Original Author

This package is a Bootstrap 5 port of williamug/searchable-select, originally created by William AsabaGitHub.

Built With

License

The MIT License (MIT). Please see License File for more information.

Links

Made with care for the Laravel community

Report Bug · Request Feature · Contribute