Enhanced select
This example uses Tom Select for country selection. PHP can validate or reset the value without replacing the widget.
Complete installation first. Copy the files below to the indicated application locations.
Assets
Install the library in your application and copy its browser assets:
npm install tom-select
mkdir -p webroot/vendor/tom-select
cp node_modules/tom-select/dist/js/tom-select.base.min.js webroot/vendor/tom-select/
cp node_modules/tom-select/dist/css/tom-select.default.css webroot/vendor/tom-select/In your layout's <head>, before Interact's script block:
<?= $this->Html->css('/vendor/tom-select/tom-select.default.css') ?>
<?= $this->Html->script('/vendor/tom-select/tom-select.base.min.js', ['defer' => true]) ?>
<?= $this->Html->script('CountryPicker', ['defer' => true]) ?>
<?= $this->fetch('script') ?>Keep Interact's automatic script enabled. The registration uses its existing window.Alpine; it does not rebuild or start another runtime.
Files
<?php
declare(strict_types=1);
namespace App\Island;
use Interact\Attribute\Action;
use Interact\Attribute\State;
use Interact\Island;
/**
* Validates a modelable country selection and supports a PHP-driven reset.
*
* Save as src/Island/CountryPickerIsland.php.
*/
class CountryPickerIsland extends Island
{
#[State(writable: true)]
public string $country = '';
#[State]
public bool $confirmed = false;
/**
* Check the choice on the server before confirming it.
*/
#[Action]
public function confirm(): void
{
$this->confirmed = false;
if (!in_array($this->country, ['BE', 'NL'], true)) {
$this->setErrors(['country' => ['Choose a country from the list.']]);
return;
}
$this->confirmed = true;
}
/**
* Clear the selected value and its confirmation.
*/
#[Action]
public function resetSelection(): void
{
$this->country = '';
$this->confirmed = false;
}
}<?php
declare(strict_types=1);
/**
* templates/island/country_picker.php
*
* @var \Interact\View\TemplateView $this
* @var string $country
* @var bool $confirmed
*/
?>
<?= $this->Form->create(null, ['url' => false, 'x-interact:submit' => '$server.call("confirm")']) ?>
<div x-data="countryPicker" x-modelable="selection" x-interact:model.change="country">
<div x-interact:ignore>
<?= $this->Form->control('country', [
'id' => 'picker-country',
'label' => 'Country',
'options' => ['BE' => 'Belgium', 'NL' => 'Netherlands'],
'empty' => 'Choose a country',
'value' => $country,
]) ?>
</div>
</div>
<ul role="alert">
<template x-for="(message, index) in ($server.errors.country || [])" :key="index">
<li x-text="message"></li>
</template>
</ul>
<?= $this->Form->button('Confirm country') ?>
<button type="button" @click="$server.call('resetSelection')">Reset selection</button>
<?= $this->Form->end() ?>
<?php if ($confirmed): ?>
<p role="status" x-show="!$server.dirty">Country confirmed.</p>
<?php endif; ?>
<p role="alert" x-text="$server.error ? $server.error.message : ''"></p>// webroot/js/country-picker.js; load after Tom Select and before Interact's runtime.
document.addEventListener('alpine:init', () => {
window.Alpine.data('countryPicker', () => {
let widget;
return {
selection: '',
init() {
widget = new window.TomSelect(this.$el.querySelector('select'), {
create: false,
onChange: (value) => {
this.selection = value;
},
});
this.$watch('selection', (value) => {
if (widget.getValue() !== value) widget.setValue(value, true);
});
},
destroy() {
widget?.destroy();
},
};
});
});<?= $this->Interact->render('CountryPicker') ?>Behavior
x-modelable exposes selection; x-interact:model.change connects it to PHP. The ignored region contains the library's markup. Error messages remain outside it. PHP reset updates the widget silently, avoiding a request loop.
The library instance stays outside reactive state. destroy() releases it when the wrapper disappears. Production styling should also associate field messages with the widget's generated accessible control, as shown by the broader demo.