Skip to content

Williamug/searchable-select

Repository files navigation

Livewire Searchable Select

Latest Version on Packagist run-tests GitHub Code Style Action Status Total Downloads License

A powerful, feature-rich searchable dropdown component for Laravel Livewire 3 & 4 applications. Built with Alpine.js and styled with Tailwind CSS.

Table of Contents

Features

  • Real-time search - Client-side filtering as you type
  • Multi-select support - Select multiple options with visual tags/badges
  • Grouped options - Organize options into labeled categories
  • Clear button - Quickly clear selections
  • Dark mode support - Automatically adapts to your theme
  • 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
  • Customizable - Override styles and behavior
  • Zero config - Works immediately after installation

Screenshots

Requirements

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

Installation

Install the package via Composer:

composer require williamug/searchable-select

The package will automatically register its service provider. You're ready to use it immediately!

You can publish the view files if you need to customize the component HTML:

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

Tailwind CSS Setup

1. Ensure Tailwind is installed in your project:

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init

2. Add the package views to your tailwind.config.js:

export default {
  content: [
    './resources/**/*.blade.php',
    './resources/**/*.js',
    './vendor/williamug/searchable-select/resources/views/**/*.blade.php',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

3. Build your CSS:

npm run build

That's it! The component will use Tailwind classes and support dark mode automatically.

Quick Start

Basic Usage

Step 1: Create a Livewire Component

php artisan make:livewire ContactForm

Step 2: Set up your 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()
    {
        // Load all countries
        $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 for="country" class="block mb-2">Country</label>

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

    @error('country_id')
        <span class="text-red-500 text-sm mt-1">{{ $message }}</span>
    @enderror

    <button wire:click="save" class="mt-4">Save</button>
</div>

That's it! You now have a fully functional searchable dropdown. The component automatically syncs with your Livewire property via wire:model — no extra :selected-value prop needed.

Component Props Reference

Comprehensive list of all available props:

Prop Type Default Required Description
options Array/Collection [] Yes The list of options to display in the dropdown
placeholder String 'Select option' No Placeholder text shown when nothing is selected
searchPlaceholder String 'Search...' No Placeholder for the search input field
disabled Boolean false No Whether the dropdown is disabled
emptyMessage String 'No options available' No Message shown when the options array is empty
optionValue String 'id' No The key/property to use as the option value
optionLabel String 'name' No The key/property to use as the option display label
multiple Boolean false No Enable multi-select mode (allows selecting multiple options)
clearable Boolean true No Show/hide the clear button
grouped Boolean false No Enable grouped/categorized options mode
groupLabel String 'label' No Key for group labels (when grouped is true)
groupOptions String 'options' No Key for group options array (when grouped is true)

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

Props Explanation

Core Props

  • options: The data source for your dropdown. Can be:

    • Eloquent Collection: Country::all()
    • Array of objects: [['id' => 1, 'name' => 'USA'], ...]
    • Array of arrays: See above
  • wire:model: The Livewire property to bind to. The component uses $wire.entangle() internally to keep the selected value in sync automatically.

Labeling Props

  • placeholder: Shows when no option is selected
  • searchPlaceholder: Shows in the search input
  • emptyMessage: Shows when options array is empty

Data Mapping Props

  • optionValue: Which property to use as the value (saved to wire:model)
  • optionLabel: Which property to display to users

Example:

// If your model has 'code' and 'country_name' fields
$countries = Country::all(); // [['code' => 'US', 'country_name' => 'United States'], ...]
<x-searchable-select
    wire:model="country_code"
    :options="$countries"
    option-value="code"
    option-label="country_name"
/>

Feature Flags

  • multiple: Enables multi-select mode with visual tags
  • clearable: Shows/hides the × button to clear selection
  • disabled: Grays out the component and prevents interaction
  • grouped: Enables category headers in the dropdown

Usage Examples

Basic Single Select

The most common use case - a simple searchable dropdown:

<?php

namespace App\Livewire;

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

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

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

    public function render()
    {
        return view('livewire.user-profile');
    }
}
<x-searchable-select
    wire:model="country_id"
    :options="$countries"
    placeholder="Select your country"
    search-placeholder="Search countries..."
/>

Multi-Select

Select multiple options with visual tags/badges:

<?php

namespace App\Livewire;

use App\Models\Skill;
use Livewire\Component;

class UserSkills extends Component
{
    public $skills;
    public $selected_skills = []; // Array to hold multiple selections

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

    public function render()
    {
        return view('livewire.user-skills');
    }
}
<x-searchable-select
    wire:model="selected_skills"
    :options="$skills"
    :multiple="true"
    placeholder="Select your skills"
    search-placeholder="Search skills..."
/>

{{-- Display selected skills --}}
@if(!empty($selected_skills))
    <div class="mt-2">
        <p>Selected: {{ count($selected_skills) }} skills</p>
    </div>
@endif

Selected items show as blue badges with × remove buttons.

Dependent/Cascading Dropdowns

Create related dropdowns where child options depend on parent selections (e.g., Country → Region → City):

<?php

namespace App\Livewire;

use App\Models\{Country, Region, City};
use Livewire\Component;

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

    // Selected values
    public $country_id;
    public $region_id;
    public $city_id;

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

    public function updatedCountryId($value)
    {
        // When country changes, load its regions
        $this->regions = Region::where('country_id', $value)
            ->orderBy('name')
            ->get();

        // Reset child selections
        $this->region_id = null;
        $this->city_id = null;
        $this->cities = [];
    }

    public function updatedRegionId($value)
    {
        // When region changes, load its cities
        $this->cities = City::where('region_id', $value)
            ->orderBy('name')
            ->get();

        // Reset city selection
        $this->city_id = null;
    }

    public function render()
    {
        return view('livewire.location-selector');
    }
}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
    <!-- Country Dropdown -->
    <div>
        <label class="block mb-2 font-medium">Country</label>
        <x-searchable-select
            wire:model="country_id"
            :options="$countries"
            placeholder="Select Country"
            search-placeholder="Search countries..."
        />
    </div>

    <!-- Region Dropdown (disabled until country is selected) -->
    <div>
        <label class="block mb-2 font-medium">Region</label>
        <x-searchable-select
            wire:model="region_id"
            :options="$regions"
            :placeholder="empty($regions) ? 'First select a country' : 'Select Region'"
            :disabled="!$country_id"
        />
    </div>

    <!-- City Dropdown (disabled until region is selected) -->
    <div>
        <label class="block mb-2 font-medium">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>

Key points:

  • Use updatedPropertyName() methods in your Livewire component to react to changes — $wire.set() inside the component triggers these automatically on every selection
  • Reset child values when parent changes
  • Use :disabled prop to prevent selecting child before parent

Grouped Options

Organize options into labeled categories:

<?php

namespace App\Livewire;

use Livewire\Component;

class CountrySelector extends Component
{
    public $country_id;

    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'],
                ['id' => 7, 'name' => 'Spain'],
                ['id' => 8, 'name' => 'Italy'],
            ]
        ],
        [
            'label' => 'Asia',
            'options' => [
                ['id' => 9, 'name' => 'Japan'],
                ['id' => 10, 'name' => 'China'],
                ['id' => 11, 'name' => 'India'],
                ['id' => 12, 'name' => 'South Korea'],
            ]
        ],
    ];

    public function render()
    {
        return view('livewire.country-selector');
    }
}
<x-searchable-select
    wire:model="country_id"
    :options="$locations"
    :grouped="true"
    placeholder="Select a country"
    search-placeholder="Search countries..."
/>

Custom group keys:

If your data structure uses different keys:

public $categories = [
    [
        'category_name' => 'Fruits',      // Custom group label key
        'items' => [                       // Custom options key
            ['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

When your data uses different property names:

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

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

With Validation

Integrate with Laravel's validation:

<?php

namespace App\Livewire;

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

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

    protected $rules = [
        'country_id' => 'required|exists:countries,id',
        'city_id' => 'required|exists:cities,id',
    ];

    protected $messages = [
        'country_id.required' => 'Please select a country.',
        'city_id.required' => 'Please select a city.',
    ];

    public function mount()
    {
        $this->countries = Country::all();
    }

    public function save()
    {
        $validated = $this->validate();

        // Use validated data...
    }

    public function render()
    {
        return view('livewire.contact-form');
    }
}
<div>
    <label>Country *</label>
    <x-searchable-select
        wire:model="country_id"
        :options="$countries"
    />
    @error('country_id')
        <span class="text-red-500 text-sm">{{ $message }}</span>
    @enderror
</div>

<div class="mt-4">
    <label>City *</label>
    <x-searchable-select
        wire:model="city_id"
        :options="$cities"
    />
    @error('city_id')
        <span class="text-red-500 text-sm">{{ $message }}</span>
    @enderror
</div>

<button wire:click="save" class="mt-4">Save</button>

Real-time validation:

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

Disabled State

Conditionally disable the dropdown:

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

Without Clear Button

Hide the clear (×) button:

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

Using Arrays Instead of Models

You don't need Eloquent models - plain arrays work too:

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

Advanced Features

Custom Styling with CSS Classes

Add custom classes to the component wrapper:

<x-searchable-select
    wire:model="country_id"
    :options="$countries"
    class="border-2 border-blue-500 rounded-xl shadow-lg"
/>

Creating Specialized Components

Build reusable components for common patterns:

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:

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

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

Customization Guide

Publishing Views

If you need to customize the component HTML, publish the view file:

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

This copies the view to resources/views/vendor/searchable-select/searchable-select.blade.php. Laravel will use your copy instead of the package default.

Dark Mode Support

The component automatically supports dark mode via Tailwind's dark: classes:

<html class="dark">
    <!-- Component automatically uses dark:bg-zinc-800, dark:text-white, etc. -->
</html>

Customizing Search Behavior

The component uses client-side filtering by default. To customize:

  1. Case sensitivity: Modify the Alpine.js searchTerm filtering logic in the published view
  2. Search multiple fields: Adjust the filter to check multiple properties
  3. Server-side search: Use the updatedSearchTerm Livewire pattern shown in Advanced Features

Troubleshooting

Common Issues and Solutions

Dropdown doesn't open / Click doesn't work

Causes:

  • Alpine.js not loaded
  • JavaScript conflicts
  • Multiple Alpine.js instances

Solutions:

  1. Verify Alpine.js is loaded (it comes with Livewire 3+):
@livewireScripts {{-- This includes Alpine.js --}}
  1. Check browser console for JavaScript errors (F12 → Console)

  2. Ensure you're not loading Alpine.js separately if 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>
  1. Try clearing browser cache and hard refresh (Ctrl+Shift+R / Cmd+Shift+R)

Selected value not displaying

Causes:

  • Value mismatch between the Livewire property and options
  • Wrong optionValue key
  • Value not in options array

Solutions:

  1. Verify the selected value exists in your options:
// ✅ Correct
$this->country_id = 1;
$this->countries = Country::all(); // Contains id=1

// ❌ Incorrect
$this->country_id = 999; // ID doesn't exist in countries
  1. Check optionValue matches your data structure:
// 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"  {{-- Must specify 'code' --}}
/>
  1. Use browser DevTools to inspect the component's Alpine.js data

Styling issues (Tailwind)

Causes:

  • Package views not included in Tailwind purge paths
  • Tailwind not built
  • CSS not loading

Solutions:

  1. Add package views to tailwind.config.js:
export default {
  content: [
    './resources/**/*.blade.php',
    './vendor/williamug/searchable-select/resources/views/**/*.blade.php', // Add this
  ],
}
  1. Rebuild Tailwind CSS:
npm run build
# or for development
npm run dev
  1. Clear Laravel view cache:
php artisan view:clear
  1. Check that your CSS is loading in browser DevTools (Network tab)

Options not updating / Stale data

Causes:

  • Missing wire:key on components in loops
  • Livewire not detecting changes

Solutions:

  1. 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
  1. Every selection calls $wire.set() immediately. Ensure your Livewire component has a matching updated{PropertyName}() method if you need to react to the change server-side.

Multi-select not working

Causes:

  • Property not defined as array
  • Missing :multiple="true"

Solutions:

  1. Initialize property as array:
// ✅ Correct
public $selected_items = [];

// ❌ Incorrect
public $selected_items; // null, not an array
  1. Enable multiple mode:
<x-searchable-select
    :multiple="true"  {{-- Required for multi-select --}}
    wire:model="selected_items"
    :options="$items"
/>

Validation errors not showing

Causes:

  • Missing @error directive
  • Wrong property name in validation

Solutions:

  1. Add error display:
<x-searchable-select wire:model="country_id" :options="$countries" />
@error('country_id')
    <span class="text-red-500 text-sm">{{ $message }}</span>
@enderror
  1. Verify property name matches:
// Component
public $country_id; // Property name

protected $rules = [
    'country_id' => 'required', // Must match property name
];

Performance issues with large datasets

Causes:

  • Too many options loaded at once
  • Client-side filtering thousands of items

Solutions:

  1. Use Livewire server-side search for large datasets:
public $searchTerm = '';
public $results = [];

public function updatedSearchTerm($value)
{
    $this->results = Product::where('name', 'like', "%{$value}%")
        ->limit(50)
        ->get();
}
  1. Select only needed columns:
// ❌ Bad - loads all columns
$this->users = User::all();

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

Performance Optimization

Dataset Size Guidelines

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

Optimization Techniques

1. Server-Side Search:

// Livewire Component
public $searchTerm = '';
public $products = [];

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

2. Caching Options:

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

3. Select Only Needed Columns:

// ❌ Bad - loads all columns
$this->users = User::all();

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

Testing

The package includes a comprehensive test suite covering all features.

Running Tests

# Run all tests
composer test

# Run with coverage
composer test -- --coverage

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

# Run tests in parallel
./vendor/bin/pest --parallel

Test Coverage

The package tests include:

  • Component rendering
  • Single-select functionality
  • Multi-select with badges/tags
  • Grouped options rendering
  • Service provider and component registration
  • View namespace resolution

17 tests, 33 assertions - all passing

Demo Application

The package includes a full-featured demo application showcasing all features.

Running the Demo

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

Visit http://localhost:8000

Note: The demo's composer.json references the local package via a VCS repository pointing to ../. No Packagist fetch needed — it installs directly from the local source.

Demo Features

The demo is a single consolidated page at / showcasing:

  • Basic single-select
  • Multi-select with badges
  • Grouped options
  • Preselected values
  • Disabled state

Demo Source Code

Check the demo Livewire component in demo/app/Livewire/DemoPage.php for implementation examples.

Frequently Asked Questions

How do I implement country → state → city dropdowns?

See the Dependent/Cascading Dropdowns section for a complete example.

Can I customize the component HTML?

Yes! Publish the view file and edit your copy:

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

Your copy lands in resources/views/vendor/searchable-select/searchable-select.blade.php.

Does it work with Livewire 3 and 4?

Yes, fully compatible with both Livewire 3.x and 4.x.

How do I search across multiple fields?

Use a Livewire server-side search with a custom query:

public function updatedSearchTerm($value)
{
    $this->users = User::where('name', 'like', "%{$value}%")
        ->orWhere('email', 'like', "%{$value}%")
        ->orWhere('phone', 'like', "%{$value}%")
        ->get();
}

Can I pre-select multiple values?

Yes, initialize your property as an array:

public $selected_items = [1, 3, 5]; // Pre-selected IDs

Does it support dark mode?

Yes, the component automatically supports dark mode using Tailwind's dark: classes.

How do I disable specific options?

This feature is not built-in, but you can publish the view and add a disabled property check in the options loop.

Can I use it with Inertia.js?

The component is designed for Livewire. For Inertia.js, consider using a Vue/React select component instead.

How do I add icons to options?

Publish the view and customize the option rendering to include icons:

<div>
    <img src="{{ $option->flag }}" class="w-4 h-4 inline mr-2">
    {{ $option->name }}
</div>

Contributing

We welcome contributions! Here's how to get started:

Development Setup

  1. Fork the repository

    git clone https://github.com/YOUR-USERNAME/searchable-select.git
    cd searchable-select
  2. Install dependencies

    composer install
  3. Run tests

    composer test

Contribution Workflow

  1. Create a feature branch

    git checkout -b feature/amazing-feature
  2. Make your changes

    • Add tests for new features
    • Update documentation if needed
    • Follow PSR-12 coding standards
  3. Run tests and code style checks

    composer test
    composer format  # Fix code style
  4. Commit your changes

    git commit -m 'Add amazing feature'
  5. Push to your fork

    git push origin feature/amazing-feature
  6. Open a Pull Request

    • Describe what your PR does
    • Reference any related issues
    • Ensure all tests pass

Code Style

The project uses:

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

Run before committing:

composer format    # Fix code style
composer test      # Run test suite

Reporting Bugs

Found a bug? Please open an issue with:

  • Laravel version
  • Livewire version
  • PHP version
  • Steps to reproduce
  • Expected vs actual behavior

Suggesting Features

Have an idea? Open a feature request describing:

  • The use case
  • How it would work
  • Why it's useful
  • Any implementation ideas

Changelog

Please see CHANGELOG for recent changes.

Security

If you discover any security vulnerabilities, please email the maintainer instead of using the issue tracker.

Credits

Author

Built With

Inspiration

Inspired by the need for a simple, searchable select component for Laravel Livewire applications.

License

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

Support the Project

If this package saved you time and effort:

  • Star the repository on GitHub
  • 🐦 Share it on social media
  • 🤝 Contribute code or documentation
  • 🐛 Report bugs to help improve it
  • 💡 Suggest features you'd like to see

Your support helps maintain and improve this package!

Links


Made with ❤️ for the Laravel community

If this package helped you, please ⭐ star the repository!

Report Bug · Request Feature · Contribute

About

A powerful, feature-rich searchable dropdown component for Laravel Livewire 3 & 4 applications. Built with Alpine.js and styled with Tailwind CSS.

Topics

Resources

License

Stars

Watchers

Forks

Sponsor this project

Packages

 
 
 

Contributors