Skip to content
LarawellUi

<x-widget.show-if>

Show if

Shows part of a form only while another field has a given value: a phone number when Phone is chosen, a second address when a box is ticked, a text box for Other. Hidden, its fields don't submit and their required can't block the form. Starts in the right state after a failed submit, and works inside Livewire components.

php artisan larawell:add show-if

Usage

Validation

Hidden fields don't submit, so validate them only when they apply. exclude_unless also drops them from the validated data.

PHP
$request->validate([
    'contact' => ['required', 'in:email,phone'],
    'phone' => ['exclude_unless:contact,phone', 'required', 'string'],
]);

Livewire

In a Livewire component it follows the field the same way and keeps up with renders. Bind the field, and pass the starting state from the property, since there's no old input. A plain @if with wire:model.live works too, and asks the server each time.

Blade
<x-widget.radio name="contact" wire:model="contact" label="How should we contact you?" inline :options="['email' => 'Email', 'phone' => 'Phone']" />
<x-widget.show-if field="contact" value="phone" :shown="$contact === 'phone'">
    <x-widget.phone name="phone" wire:model="phone" label="Phone number" />
</x-widget.show-if>

Examples

These examples also use Radio, Phone, Select, Text input, Checkbox and Textarea, which Show if doesn't need on its own. To use them as they are:

Terminal
php artisan larawell:add show-if radio phone select text-input checkbox textarea

Contact method

Each way of getting in touch shows its own field: an email address for Email, a number for Phone. A hidden one doesn't submit, and its required can't stop the form. Email is chosen to start with, so :shown says so from the server, with no flash before the script runs.

How should we contact you?
Show code
Blade
<form class="grid max-w-sm gap-6">
    <x-widget.radio name="contact" label="How should we contact you?" value="email" inline :options="['email' => 'Email', 'phone' => 'Phone']" />
    <x-widget.show-if field="contact" value="email" :shown="old('contact', 'email') === 'email'">
        <x-widget.text-input name="email" type="email" label="Email address" required />
    </x-widget.show-if>
    <x-widget.show-if field="contact" value="phone">
        <x-widget.phone name="phone" label="Phone number" required />
    </x-widget.show-if>
</form>

Other option

A list of values shows it for any of them; here An event or Other asks for the details.

Search engine
A friend
An event
Other
Show code
Blade
<form class="grid max-w-sm gap-6">
    <x-widget.select name="heard_from" label="How did you hear about us?" placeholder="Choose one" :options="['search' => 'Search engine', 'friend' => 'A friend', 'event' => 'An event', 'other' => 'Other']" />
    <x-widget.show-if field="heard_from" :value="['event', 'other']">
        <x-widget.text-input name="heard_from_details" label="Tell us more" required />
    </x-widget.show-if>
</form>

Different address

With no value, any value shows it: here, the box being ticked. Show-ifs nest, and an inner one hides with its outer.

Show code
Blade
<form class="grid max-w-sm gap-6">
    <x-widget.checkbox name="ship_elsewhere" label="Ship to a different address" />
    <x-widget.show-if field="ship_elsewhere" class="grid gap-6">
        <x-widget.text-input name="shipping[street]" label="Street" autocomplete="shipping street-address" required />
        <x-widget.checkbox name="shipping[gift]" label="It's a gift" />
        <x-widget.show-if field="shipping[gift]">
            <x-widget.textarea name="shipping[gift_message]" label="Gift message" />
        </x-widget.show-if>
    </x-widget.show-if>
</form>

Props

Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.

<x-widget.show-if>

Prop Default Description
field Required The name of the field it follows, as on that field: "contact", "addons[]", "address[country]". Looked for in the same form, or the whole page outside one.
value null The value, or a list of values, that shows it: "phone", or ['phone', 'sms']. Left out, any value shows it: a ticked checkbox, a chosen option, text typed in.
shown null Shown to start with. Left out, it follows the field's old input, so after a failed submit what the person chose is still open. Pass it when the field's value comes from elsewhere: :shown="$user->contact === 'phone'".

Accessibility

All 3 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.

Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.

Source

What larawell:add show-if writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from Field, and the theme and base CSS.

resources/views/components/widget/show-if/index.blade.php Show
index.blade.php
@props([
    // The name of the field it follows, as on that field: "contact", "addons[]", "address[country]". Looked for in the
    // same form, or the whole page outside one.
    'field',
    // The value, or a list of values, that shows it: "phone", or ['phone', 'sms']. Left out, any value shows it: a
    // ticked checkbox, a chosen option, text typed in.
    'value' => null,
    // Shown to start with. Left out, it follows the field's old input, so after a failed submit what the person chose is
    // still open. Pass it when the field's value comes from elsewhere: :shown="$user->contact === 'phone'".
    'shown' => null,
])

@php
    $expected = $value === null ? null : array_values(array_map('strval', (array) $value));
    // old() takes dot notation: address[country] is address.country, and addons[] is addons.
    $key = rtrim((string) preg_replace('/\[([^\]]*)\]/', '.$1', $field), '.');
    $current = array_values(array_filter(array_map('strval', (array) old($key)), fn (string $chosen): bool => $chosen !== ''));
    $shown ??= $expected === null ? $current !== [] : array_intersect($current, $expected) !== [];
@endphp

{{--
    A fieldset, because a disabled one takes everything inside out of the form: hidden, its fields don't submit, their
    required can't stop the submit, and inert keeps them out of reach of Tab and screen readers. resources/js/widget/show-if
    shows and hides it as the field changes; the server draws where it starts, so there's no flash before the script runs.
--}}
<fieldset
    data-show-if="{{ $field }}"
    @if ($expected !== null) data-show-value="{{ json_encode($expected) }}" @endif
    @unless ($shown) hidden inert disabled @endunless
    {{ $attributes->class(['m-0 min-w-0 border-0 p-0']) }}
>{{ $slot }}</fieldset>
resources/js/widget/show-if/index.js Show
index.js
// Drives <x-widget.show-if>: shows or hides its fieldset as the field it follows changes. Hidden, the fieldset is also
// inert and disabled, so what's in it can't be reached and doesn't submit. Delegated from `document`, so fields and
// show-ifs added later (fetched HTML, a modal's content) work without setting up.
import { onLivewireMorph } from '../field';

const ROOT = '[data-show-if]';

// What the field holds now: the ticked boxes' and chosen radio's values, a select's chosen options, or what's typed in.
// A field that is itself hidden and disabled holds nothing, so a show-if that follows it hides too.
function valuesOf(scope, name) {
    const controls = scope.querySelectorAll(`[name="${CSS.escape(name)}"], [name="${CSS.escape(name.replace(/\[\]$/, ''))}[]"]`);

    return [...controls].filter((control) => !control.matches(':disabled')).flatMap((control) => {
        if (control.type === 'checkbox' || control.type === 'radio') {
            return control.checked ? [control.value] : [];
        }
        if (control instanceof HTMLSelectElement) {
            return [...control.selectedOptions].map((option) => option.value);
        }

        return [control.value];
    }).filter((value) => value !== '');
}

// Shows or hides one show-if; returns whether it changed.
function update(root) {
    const expected = root.dataset.showValue ? JSON.parse(root.dataset.showValue) : null;
    const current = valuesOf(root.form ?? root.closest('form') ?? document, root.dataset.showIf);
    const show = expected ? current.some((value) => expected.includes(value)) : current.length > 0;
    if (show === !root.hidden) {
        return false;
    }
    root.hidden = !show;
    root.inert = !show;
    root.disabled = !show;

    return true;
}

// Every show-if in a scope, again until nothing changes: one can follow a field inside another, which a change shows or
// hides in turn.
function updateAll(scope = document) {
    const roots = [...scope.querySelectorAll(ROOT)];
    for (let pass = 0; pass < 10 && roots.map(update).some(Boolean); pass++) {
        // Keep going while something changed.
    }
}

const scopeOf = (element) => element.closest?.('form') ?? document;

document.addEventListener('input', (event) => updateAll(scopeOf(event.target)));
document.addEventListener('change', (event) => updateAll(scopeOf(event.target)));
// form.reset() puts the fields back after the event, without input or change events of its own.
document.addEventListener('reset', (event) => setTimeout(() => updateAll(scopeOf(event.target))));

// The server drew the starting state; this catches what the browser changed before the script ran (a field it filled
// back in on Back), and a Livewire render, which puts back the server's markup.
updateAll();
onLivewireMorph(() => updateAll());