Search by

xylo / multiselect-two

QusayHomady

A Filament two-sided multiselect field with search, barcode scan, descriptions, and a natural accent theme. Compatible with Filament 4 and 5.

v1.3.0 2026-09-17 05:53 UTC

This package is not auto-updated.

Last update: 2026-10-15 06:15:57 UTC


README

A Filament form field for transferring items between two lists — available and selected — with search, barcode scanning, descriptions, count badges, and a natural green accent.

Compatible with Filament 4.x and 5.x.

Installation

composer require xylo/multiselect-two

If you install from a local path:

{
  "repositories": [
    {
      "type": "path",
      "url": "../xylo-multiselect-two"
    }
  ]
}
composer require xylo/multiselect-two:@dev

Publish assets (recommended after install / update):

php artisan filament:assets

Optional translations:

php artisan vendor:publish --tag="xylo-multiselect-two-translations"

Usage

use Xylo\MultiselectTwo\Forms\Components\XyloMultiselectTwo;

XyloMultiselectTwo::make('menu_item_ids')
    // Optional — omit both (and use hiddenLabel) if you want no title/subtitle
    ->label('STEP 2 · Choose the items in this program')
    ->helperText('A punch is earned when a customer buys any item on the right.')
    ->selectableLabel('Available items')
    ->selectedLabel('In this program')
    ->options([
        '1' => 'Cheese Burger 6"',
        '2' => 'Beef Burger',
        '3' => 'Chicken Burger',
        '4' => 'Veggie Burger',
        '5' => 'Fish Burger',
        '6' => 'Double Cheese',
        '7' => 'Classic Fries',
        '8' => 'Onion Rings',
    ])
    ->descriptions([
        '1' => '$7.99',
        '2' => '$8.99',
        '3' => '$7.49',
        '4' => '$6.99',
        '5' => '$8.49',
        '6' => '$9.99',
        '7' => '$3.49',
        '8' => '$4.29',
    ])
    ->barcodes([
        '1' => '100100',
        '2' => '100200',
        '3' => '100300',
    ])
    ->skus([
        '1' => 'CH-06',
        '2' => 'BF-01',
    ])
    ->accentColor('#2f9e7a') // or 'natural' / 'primary' / any CSS color
    ->default(['2', '7']);

Without title / subtitle:

XyloMultiselectTwo::make('menu_item_ids')
    ->hiddenLabel()
    ->options($items)
    ->accentColor('#0ea5e9');

Rich options (label + description + barcode + sku in one array)

XyloMultiselectTwo::make('items')
    ->options([
        '1' => [
            'label' => 'Cheese Burger 6"',
            'description' => '$7.99',
            'barcode' => '100100',
            'sku' => 'CH-06',
        ],
        '2' => [
            'label' => 'Beef Burger',
            'description' => '$8.99',
            'barcode' => '100200',
        ],
    ]);

Barcode scanning

The barcode input takes whatever a keyboard-wedge scanner types, then Enter. Matching happens entirely in the browser against the options already on the page — a scanned code is never sent to the server.

A scan is resolved in three tiers, stopping at the first one that hits:

  1. Exact — the code as typed, against barcodes(), skus() and option values.
  2. Normalized — case and every separator (spaces, dashes, dots) removed from both sides.
  3. GTIN-14 — numeric codes left-padded to 14 digits.

Tier 3 is what makes catalogs work in practice: a label printed as UPC-A 012546011112 scans into a product stored as EAN-13 0012546011112. Tiers 2 and 3 apply to barcodes() and skus() only — an option value (usually a record id) is matched exactly or not at all, so an id can never shadow a real barcode.

A scan never guesses. If a code matches more than one option, nothing is selected and the component reports the ambiguity so a human decides. Every outcome is shown inline under the input:

Outcome When
added Exactly one match, allowed, not selected yet
already_added The matched option is already in the selected panel
not_allowed The matched option is excluded by limitTo()
not_found No option matches
ambiguous More than one option matches

Instant or Enter-to-scan

By default the code is resolved when the operator presses Enter, which is what a keyboard-wedge scanner sends after the digits. instantScan() resolves while typing instead:

XyloMultiselectTwo::make('items')
    ->barcodeScanner()
    ->instantScan()                // no Enter needed
    ->instantScanDelay(120)        // idle ms before the typed code is evaluated
    ->instantScanMinLength(6)      // ignore anything shorter
    ->options($items);

Instant mode is deliberately quiet: while typing, only an unambiguous match is added and misses report nothing, because a half-typed code is not a failed scan. Enter still works and still reports every outcome.

The delay matters. Without it, a prefix of a long code could match some other product's shorter barcode mid-burst and add the wrong row — waiting for the input to settle is what prevents that. Lower it only if your scanner is slow; raise it if operators type by hand.

Other options

XyloMultiselectTwo::make('items')
    ->barcodeScanner()
    ->scanFeedbackDuration(0)      // keep the result visible until the next scan
    ->searchOnScanMiss()           // put an unresolved code into the search box
    ->barcodeStrict()              // exact matches only (pre-1.2 behaviour)
    ->options($items)
    ->barcodes($barcodes);

Opt into searchOnScanMiss() if you would rather have an unresolved code dropped into the available search box than left to the result line alone.

Messages live under the scan.* translation keys:

// lang/vendor/xylo-multiselect-two/en/xylo-multiselect-two.php
'scan' => [
    'added' => 'Added :label',
    'not_found' => 'No item matches :code',
    // ...
],

Search benefits from the same normalization: typing 0125-4601 1440 finds a product stored as 012546011440.

Features

  • Two-sided transfer UI (available ↔ selected)
  • Count badges on both panels
  • Barcode scan input (Enter to add) with GTIN-aware matching and an inline result line — see Barcode scanning
  • Search / filter on both sides (name, SKU, barcode, formatted codes) with 200ms debounce
  • Scroll windowing — renders pageSize rows at a time (default 80), loads more on scroll
  • Prebuilt option list + Set membership for fast filtering on large catalogs
  • Dynamic options remount via wire:key (safe for dependent fields like Free Reward ⊆ Products to Buy)
  • Item title + subtitle (e.g. price)
  • Chevron move actions + remove (X) on selected rows
  • Add all shown / Remove all (Add all adds every filtered available item, not only the first rendered page)
  • Soft accent color only on hover (items + bulk buttons)
  • Theme-aware colors: text/borders/surfaces follow Filament gray tokens + light/dark automatically
  • Accent: natural, Filament colors (primary, success, …), or any custom CSS color
  • Optional Filament label() / helperText() — nothing forced by the package
  • Works with Filament live(), disabled(), validation, defaults
  • English + Arabic translations
  • Multiple independent instances on the same form (e.g. Products to Buy + Free Reward)

Configuration API

Method Description
options() Available choices
descriptions() Subtitle under each label
barcodes() Values matched by the scanner
skus() Included in search + scan
selectableLabel() / selectedLabel() Panel titles
enableSearch() / searchable() Toggle search inputs
enableBarcode() / barcodeScanner() Toggle barcode input
barcodeStrict() Require byte-for-byte scan matches (disables the relaxed tiers)
instantScan() Add a match while typing instead of on Enter (default off)
instantScanDelay(120) / instantScanMinLength(6) Idle time and shortest code an instant scan acts on
scanFeedback() / scanFeedbackDuration(4000) Inline scan result line; 0 keeps it until the next scan
searchOnScanMiss() Put an unresolved scan into the available search box (default off)
showCounts() Toggle count badges
showBulkActions() Toggle footer buttons
listHeight(320) Scroll area height in px
pageSize(80) Rows rendered per chunk (scroll loads more); keep low for huge catalogs
limitTo('other_field') Client-side: only show options selected on another field (no Livewire wait)
accentColor(...) natural, primary/success/danger/warning/info/gray, or CSS color
hoverColor(...) Shared hover for items + both buttons
itemsHoverColor(...) Shared hover for both panels' rows
availableHoverColor(...) / selectedHoverColor(...) Hover per panel
buttonsHoverColor(...) Shared hover for Add all + Remove all
addAllHoverColor(...) / removeAllHoverColor(...) Hover per button
buttonsBackgroundColor(...) Shared default background for both buttons
addAllBackgroundColor(...) / removeAllBackgroundColor(...) Background per button (defaults kept if unset)
addAllLabel() / removeAllLabel() Footer button labels
availableSearchPlaceholder() / selectedSearchPlaceholder() / barcodePlaceholder() Input placeholders
label() / helperText() / hiddenLabel() Optional title & subtitle (Filament)

Color priority (most specific wins):

  1. Per-part (availableHoverColor, addAllHoverColor, …)
  2. Group (itemsHoverColor, buttonsHoverColor)
  3. Shared (hoverColor)
  4. Default from accentColor
// One color for everything on hover
->hoverColor('#dcfce7')

// Cards together, buttons together
->itemsHoverColor('#ecfdf5')
->buttonsHoverColor('#fef3c7')

// Fully separate
->availableHoverColor('#d1fae5')
->selectedHoverColor('#e0f2fe')
->addAllHoverColor('#bbf7d0')
->removeAllHoverColor('#fecaca')
->addAllBackgroundColor('#f3f4f6')   // optional — default gray if omitted
->removeAllBackgroundColor('#ffffff') // optional — default card white if omitted

Dependent fields (Products to Buy + Free Reward)

Use two separate fields. Keep Free Reward options as a subset of Products to Buy, and prune invalid rewards when qualifying changes:

XyloMultiselectTwo::make('punch_card.qualifying_product_ids')
    ->label('Products to Buy')
    ->live(debounce: 400)
    ->showBulkActions(true)
    ->listHeight(260)
    ->pageSize(80)
    ->accentColor('success')
    ->barcodeScanner(false)
    ->options(fn (): array => $this->productTransferOptions())
    ->afterStateUpdated(function ($state, Set $set, Get $get): void {
        $allowed = array_map('strval', $state ?? []);
        $rewards = array_map('strval', $get('punch_card.reward_product_ids') ?? []);
        $set('punch_card.reward_product_ids', array_values(array_intersect($rewards, $allowed)));
    }),

XyloMultiselectTwo::make('punch_card.reward_product_ids')
    ->label('Free Reward Products')
    ->live(debounce: 400)
    ->showBulkActions(false)
    ->listHeight(220)
    ->pageSize(80)
    ->barcodeScanner(false)
    ->disabled(fn (Get $get): bool => blank($get('punch_card.qualifying_product_ids')))
    ->options(fn (Get $get): array => $this->productTransferOptions(
        array_map('strval', $get('punch_card.qualifying_product_ids') ?? [])
    )),

The package remounts Alpine when option keys change (wire:key), so the Free Reward list refreshes when Products to Buy changes.

Example with labels from the screenshots

XyloMultiselectTwo::make('program_items')
    ->selectableLabel('Available items')
    ->selectedLabel('In this program')
    ->availableSearchPlaceholder('Search by name, SKU or barcode')
    ->selectedSearchPlaceholder('Filter included items')
    ->barcodePlaceholder('Scan barcode & press Enter')
    ->addAllLabel('Add all shown')
    ->removeAllLabel('Remove all')
    ->accentColor('#16a34a')
    ->options($items)
    ->descriptions($prices)
    ->barcodes($barcodes);

Accent examples:

->accentColor('natural')   // package soft green
->accentColor('primary')   // follows Filament panel primary theme
->accentColor('success')   // Filament success palette
->accentColor('#0ea5e9')   // any hex / CSS color

Structural colors (titles, prices, borders, cards, inputs) always use Filament --gray-* tokens and switch with light/dark mode — no extra config.

Version compatibility

Package Filament PHP
1.x 4.x / 5.x 8.2 / 8.3 / 8.4

Filament 5 keeps the Forms API from 4 and mainly upgrades Livewire; this package targets both.

Credits

  • Developer: Qusai Homadi
  • Package: Xylo (xylo/multiselect-two)

License

MIT