Ryan Chandler

Building a custom color palette field in Filament - Part 2

3 min read

This post was published 2 years ago. Some of the information might be outdated!

Time to start changing the state on the form! But first, some base knowledge.

All fields in a Filament form have a unique "state path". The state path is the location on the Livewire component that contains your form where the current value / state of your field can be found.

The state path for a form field can be retrieved using the getStatePath() method. This can be called from the field class itself or inside of a Blade view by invoking the $getStatePath variable.

Let's start by making the color buttons update the state of the field. We'll be using Alpine to do this instead of Livewire's own wire: directives.

<x-forms::field-wrapper
    :id="$getId()"
    :label="$getLabel()"
    :label-sr-only="$isLabelHidden()"
    :helper-text="$getHelperText()"
    :hint="$getHint()"
    :hint-icon="$getHintIcon()"
    :required="$isRequired()"
    :state-path="$getStatePath()"
>
    <div
        x-data="{ state: $wire.entangle('{{ $getStatePath() }}') }"
        class="flex items-center space-x-4"
    >
        @foreach($getOptions() as $color => $label)
            <button
                type="button"
                x-on:click="state = @js($color)"
                class="rounded-full w-8 h-8 border border-gray-500"
                style="background: {{ $color }}" title="{{ $label }}"
            >
                <span class="sr-only">
                    {{ $label }}
                </span>
            </button>
        @endforeach
    </div>
</x-forms::field-wrapper>

On my test form component, I've added an updatedColor hook that will get called when the state is updated.

public function updatedColor()
{
    dd($this->color);
}

And this is the result...

The dd() is being reached and the property at the state path is being updated. Nifty!

Writing super optimal fields is important in Filament since any unnecessary state updates will result in back and forth Livewire requests and multiple re-renders. If I choose an option, 1 request is going to be sent to update the state. If I then realise that it's the wrong option and choose another, a second request will be sent to update the state again.

These duplicated requests aren't always necessary and Filament fields generally follow a defer-first approach. If you're not familiar with Livewire's deferred binding system, read the documentation.

Fields in Filament are responsible for their own state binding method (reactive, deferred or lazy). To get the current binding modifier, we can use the $applyStateBindingModifiers() function.

This function accepts a single string which will be the method or directive you'd like to apply the modifiers to. In our case, it's going to be the entangle() method on $wire.

Updating the code inside of the Blade view looks something like this:

<x-forms::field-wrapper
    :id="$getId()"
    :label="$getLabel()"
    :label-sr-only="$isLabelHidden()"
    :helper-text="$getHelperText()"
    :hint="$getHint()"
    :hint-icon="$getHintIcon()"
    :required="$isRequired()"
    :state-path="$getStatePath()"
>
    <div x-data="{ state: $wire.{{ $applyStateBindingModifiers('entangle(\'' . $getStatePath() . '\')') }} }" class="flex items-center space-x-4">
        @foreach($getOptions() as $color => $label)
            <button
                type="button"
                x-on:click="state = @js($color)"
                class="rounded-full w-8 h-8 border border-gray-500"
                style="background: {{ $color }}" title="{{ $label }}"
            >
                <span class="sr-only">
                    {{ $label }}
                </span>
            </button>
        @endforeach
    </div>
</x-forms::field-wrapper>

Admittedly this code is a little hard to read at first with the string concatenation and escaping of single quotes. All it's doing is building up the same entangle('{{ $getStatePath() }}') string as before and sending it through $applyStateBindingModifiers().

On a simple ColorPalette::make() call, this will result in the .defer modifier being applied to the $wire.entangle() call meaning any state changes are deferred until the next Livewire action is invoked, normally saving the form or changing the state of a reactive field.

To make the field reactive again, you just need to call ->reactive() on the field itself, i.e.

ColorPalette::make('color')
    ->reactive(),

Visual display of selected option

Functionally speaking the field is working. Visually though, it's impossible to tell which option is selected. Let's change this by applying a ring to the selected option as well as a checkmark.

We can use Alpine again to conditionally apply classes to the button, since we already have a reference to the current state.

<x-forms::field-wrapper
    :id="$getId()"
    :label="$getLabel()"
    :label-sr-only="$isLabelHidden()"
    :helper-text="$getHelperText()"
    :hint="$getHint()"
    :hint-icon="$getHintIcon()"
    :required="$isRequired()"
    :state-path="$getStatePath()"
>
    <div x-data="{ state: $wire.{{ $applyStateBindingModifiers('entangle(\'' . $getStatePath() . '\')') }} }" class="flex items-center space-x-4">
        @foreach($getOptions() as $color => $label)
            <button
                type="button"
                x-on:click="state = @js($color)"
                class="rounded-full w-8 h-8 border border-gray-300 appearance-none inline-flex items-center justify-center"
                x-bind:class="{
                    'ring-2 ring-gray-300 ring-offset-2': state === @js($color),
                }"
                style="background: {{ $color }}" title="{{ $label }}"
            >
                <span class="sr-only">
                    {{ $label }}
                </span>

                <span x-show="state === @js($color)" x-cloak>
                    <x-heroicon-o-check class="w-4 h-4 text-gray-400" />
                </span>
            </button>
        @endforeach
    </div>
</x-forms::field-wrapper>

Using x-bind:class and x-show we can conditionally add classes to the button and toggle a checkmark icon from Heroicons.

The final result looks like this:

In the next and final part of this series, we'll look at some extra methods we can add to ColorPalette to make it more developer friendly.

Hello, Twitter!