Skip to content
LarawellUi

<x-widget.phone>

Phone

A country picker with flags and dial codes, plus the number. Submits one international number, +60123456789. Works with Livewire wire:model.

php artisan larawell:add phone
Also adds
Icon, Search

Usage

Livewire

In a Livewire component, bind with wire:model (deferred) or wire:model.live; no name is needed. The property gets the full international number (+14155550123), whatever country and formatting people pick. Set it in PHP and the field picks the matching country and shows the national number; country only sets where an empty field starts.

Blade
<x-widget.phone label="Phone" country="US" wire:model="phone" />

Examples

Phone

Pick the country, type the number as it's dialled at home. It submits one international number (+60123456789): validate it on the server, for example with propaganistas/laravel-phone. Pasting a full +44 number switches the country. country sets where it starts; otherwise the app locale's region does.

Show code
Blade
<div class="grid gap-6 sm:grid-cols-2">
    <x-widget.phone name="mobile" label="Mobile number" country="MY" placeholder="12-345 6789" required />
    <x-widget.phone name="office" label="Office" value="+442079460958" />
</div>

Props

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

<x-widget.phone>

Prop Default Description
name null What it submits as; its error and old input are found under it. Optional with wire:model, which then names it.
id null Defaults to one made from the name (or the wire:model property).
label null Shown above the field, and its name for screen readers.
value null The full international number (+14155550123). Old input wins; with wire:model and no value, the bound property.
country null The country to start with (US); otherwise guessed from the locale.
placeholder null Shown while it's empty.
error null An error message of your own; otherwise the validation error for the name, from the session or Livewire.
info null A hint under the field.
bag 'default' Which error bag to read the error from.
disabled false Greyed out: it can't be changed.
readonly false Shown, and submitted, but it can't be edited.
locale null The language for country names and that guess; defaults to the app's.

Source

What larawell:add phone writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from the components it also adds and Field, and the theme and base CSS.

resources/views/components/widget/phone/index.blade.php Show
index.blade.php
@props([
    // What it submits as; its error and old input are found under it. Optional with wire:model, which then names it.
    'name' => null,
    // Defaults to one made from the name (or the wire:model property).
    'id' => null,
    // Shown above the field, and its name for screen readers.
    'label' => null,
    // The full international number (+14155550123). Old input wins; with wire:model and no value, the bound
    // property.
    'value' => null,
    // The country to start with (US); otherwise guessed from the locale.
    'country' => null,
    // Shown while it's empty.
    'placeholder' => null,
    // An error message of your own; otherwise the validation error for the name, from the session or Livewire.
    'error' => null,
    // A hint under the field.
    'info' => null,
    // Which error bag to read the error from.
    'bag' => 'default',
    // Greyed out: it can't be changed.
    'disabled' => false,
    // Shown, and submitted, but it can't be edited.
    'readonly' => false,
    // The language for country names and that guess; defaults to the app's.
    'locale' => null,
])

@php
    $field = \App\View\Widget\FormField::make($name, $id, $errors ?? null, $error, $bag, 'phone', attributes: $attributes);
    $locale = str_replace('_', '-', $locale ?? app()->getLocale());
    // The stored value is one international number (+60123456789); it opens on its own country.
    [$selected, $national] = \App\View\Widget\Countries::split($field->old($value), \App\View\Widget\Countries::guess($country, $locale));
    $dial = \App\View\Widget\Countries::DIAL_CODES[$selected];
    $flag = implode('', array_map(static fn (string $letter): string => mb_chr(0x1F1E6 + ord($letter) - 65), str_split($selected)));
@endphp

{{--
    Submits one international number, +60123456789, from the hidden input: validate it on the server,
    e.g. with propaganistas/laravel-phone ('phone' => 'phone:INTERNATIONAL'). The country list is built in
    the browser (resources/js/widget/phone), with names in the page's language.
--}}
<x-widget.field :required="$attributes->has('required')" data-phone data-locale="{{ $locale }}" data-countries="{{ \App\View\Widget\Countries::compact() }}" :id="$field->id" :label="$label" :error="$field->errors" :info="$info" :disabled="$disabled" :readonly="$readonly" box="h-12 items-center" :class="$attributes->get('class')">
    <button
        type="button"
        popovertarget="{{ $field->id }}-countries"
        aria-haspopup="listbox"
        aria-expanded="false"
        aria-label="Country code +{{ $dial }}"
        data-phone-country
        @disabled($disabled || $readonly)
        class="border-line hover:bg-line/40 focus-visible:ring-primary flex h-full shrink-0 items-center gap-1.5 border-e ps-5 pe-3 outline-none focus-visible:ring-2 focus-visible:ring-inset disabled:cursor-not-allowed disabled:opacity-50"
    >
        <span data-phone-flag aria-hidden="true" class="text-base leading-none">{{ $flag }}</span>
        <span data-phone-dial class="tabular-nums">+{{ $dial }}</span>
        <x-widget.icon name="chevron-down" class="text-foreground/60 size-4" />
    </button>

    <input
        type="tel"
        id="{{ $field->id }}"
        value="{{ $national }}"
        placeholder="{{ $placeholder }}"
        inputmode="tel"
        autocomplete="tel-national"
        data-phone-national
        @disabled($disabled)
        @readonly($readonly)
        {{-- The visible field gets required, autofocus and the like, so the browser checks it and screen readers hear it. --}}
        {{ $field->visibleAttributes($attributes, (bool) $info)->class([
            'h-full w-full min-w-0 bg-transparent ps-3 pe-5 tabular-nums outline-none placeholder:text-muted disabled:cursor-not-allowed disabled:opacity-50 read-only:cursor-default',
            'group-data-invalid/field:placeholder:text-error group-data-invalid/field:focus:placeholder:text-muted',
        ]) }}
    >

    <input type="hidden" @if ($name) name="{{ $name }}" @endif value="{{ \App\View\Widget\Countries::international($selected, $national) }}" data-phone-value data-country="{{ $selected }}" {{ $field->bindings($attributes) }}>

    <div id="{{ $field->id }}-countries" popover data-phone-popover class="border-line bg-surface text-foreground fixed inset-auto m-0 overflow-hidden rounded-2xl border p-0 text-sm shadow-lg">
        <div class="border-line border-b p-2">
            {{-- A fixed id: Livewire's morph matches elements by id, and a generated one would swap in a box without the script's listeners. --}}
            <x-widget.search :id="$field->id.'-search'" size="sm" placeholder="Search countries or codes" data-phone-search role="searchbox" aria-controls="{{ $field->id }}-country-list" autocomplete="off" />
        </div>
        <div id="{{ $field->id }}-country-list" role="listbox" aria-label="Country" data-phone-list class="flex max-h-72 flex-col overflow-y-auto overscroll-contain [scrollbar-width:thin]"></div>
        <p data-phone-empty hidden class="text-muted px-5 py-2">No country matches</p>
    </div>
</x-widget.field>
resources/js/widget/phone/index.js Show
index.js
// Drives <x-widget.phone>: a country picker plus the number, joined into one international number
// (+60123456789) in the hidden input. The list is built on first open from the compact code table on the
// field, with names from Intl.DisplayNames in the page's language and emoji flags, so it costs nothing until used.
// Pasting or autofilling a full +44… number switches the country to match.

import { closeIfOutOfView, onLivewireMorph } from '../field';

const VIEWPORT_EDGE = 16;
const GAP = 4;
const KEEPS_LEADING_ZERO = new Set(['IT', 'SM', 'VA']);

const flag = (iso) => String.fromCodePoint(...[...iso].map((letter) => 0x1F1E6 + letter.charCodeAt(0) - 65));
// Case and accent-insensitive: "cote" finds Côte d'Ivoire.
const fold = (text) => text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();

// Set up once per element. Not a data attribute: Livewire's morph removes attributes the server didn't render.
const ready = new WeakSet();
// Each field's way to take on a value set from outside (see refreshPhones).
const followers = new WeakMap();

function initPhone(root) {
    if (ready.has(root)) {
        return;
    }
    ready.add(root);

    const trigger = root.querySelector('[data-phone-country]');
    const flagEl = root.querySelector('[data-phone-flag]');
    const dialEl = root.querySelector('[data-phone-dial]');
    const national = root.querySelector('[data-phone-national]');
    const hidden = root.querySelector('[data-phone-value]');
    const popover = root.querySelector('[data-phone-popover]');
    const search = popover.querySelector('[data-phone-search]');
    const list = popover.querySelector('[data-phone-list]');
    const empty = popover.querySelector('[data-phone-empty]');
    const anchor = trigger.parentElement;

    const locale = root.dataset.locale || document.documentElement.lang || undefined;
    const codes = Object.fromEntries(root.dataset.countries.split(',').map((pair) => pair.split(':')));
    const names = typeof Intl.DisplayNames === 'function' ? new Intl.DisplayNames([locale], { type: 'region' }) : null;
    const nameOf = (iso) => names?.of(iso) ?? iso;

    let country = hidden.dataset.country;
    let options = [];
    let active = -1;

    function international() {
        const digits = national.value.replace(/\D/g, '');
        if (!digits) {
            return '';
        }

        return `+${codes[country]}${KEEPS_LEADING_ZERO.has(country) ? digits : digits.replace(/^0/, '')}`;
    }

    function commit() {
        hidden.value = international();
        hidden.dataset.country = country;
        // Both events: x-model / wire:model listen for `input`, plain forms for `change`.
        hidden.dispatchEvent(new Event('input', { bubbles: true }));
        hidden.dispatchEvent(new Event('change', { bubbles: true }));
    }

    function showCountry() {
        flagEl.textContent = flag(country);
        dialEl.textContent = `+${codes[country]}`;
        trigger.setAttribute('aria-label', `${nameOf(country)}, +${codes[country]}`);
    }

    // A full international number (pasted, or autofilled into the national field): pick its country.
    // The longest code wins; on a shared code, the current country if it fits, else the main one (listed first).
    function adopt(full) {
        const digits = full.replace(/\D/g, '');
        let best = null;
        for (const [iso, dial] of Object.entries(codes)) {
            if (digits.startsWith(dial) && (!best || dial.length > codes[best].length || (dial.length === codes[best].length && iso === country))) {
                best = iso;
            }
        }
        if (best) {
            country = best;
            national.value = digits.slice(codes[best].length);
            showCountry();
        }
    }

    national.addEventListener('input', () => {
        if (national.value.trim().startsWith('+')) {
            adopt(national.value);
        } else {
            // Keep the separators people type (spaces, dashes, brackets); drop anything else.
            const clean = national.value.replace(/[^\d\s\-().]/g, '');
            if (clean !== national.value) {
                national.value = clean;
            }
        }
        commit();
    });

    function build() {
        const collator = new Intl.Collator(locale);
        options = Object.keys(codes)
            .map((iso) => ({ iso, name: nameOf(iso) }))
            .sort((a, b) => collator.compare(a.name, b.name))
            .map(({ iso, name }) => {
                const option = document.createElement('div');
                option.id = `${list.id}-${iso}`;
                option.setAttribute('role', 'option');
                option.dataset.iso = iso;
                option.dataset.search = `${fold(name)} +${codes[iso]} ${iso.toLowerCase()}`;
                option.className = 'flex shrink-0 cursor-pointer items-center gap-3 px-4 py-2 select-none aria-selected:bg-primary/10 data-active:bg-field';
                option.innerHTML = '<span aria-hidden="true" class="text-base leading-none"></span><span class="min-w-0 flex-1 truncate"></span><span class="text-foreground/60 tabular-nums"></span>';
                option.children[0].textContent = flag(iso);
                option.children[1].textContent = name;
                option.children[2].textContent = `+${codes[iso]}`;
                list.append(option);

                return option;
            });
    }

    const visible = () => options.filter((option) => !option.hidden);

    function setActive(index) {
        const shown = visible();
        options.forEach((option) => option.removeAttribute('data-active'));
        active = shown[index] ? index : -1;
        if (active === -1) {
            search.removeAttribute('aria-activedescendant');

            return;
        }
        shown[active].setAttribute('data-active', '');
        shown[active].scrollIntoView({ block: 'nearest' });
        search.setAttribute('aria-activedescendant', shown[active].id);
    }

    function filter() {
        const query = fold(search.value.trim()).replace(/^\+/, '+');
        options.forEach((option) => {
            option.hidden = query !== '' && !option.dataset.search.includes(query);
        });
        empty.hidden = visible().length > 0;
        setActive(0);
    }

    function choose(option) {
        if (!option) {
            return;
        }
        country = option.dataset.iso;
        showCountry();
        commit();
        popover.hidePopover();
        national.focus();
    }

    search.addEventListener('input', filter);
    search.addEventListener('keydown', (event) => {
        if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
            event.preventDefault();
            setActive(Math.max(0, Math.min(visible().length - 1, active + (event.key === 'ArrowDown' ? 1 : -1))));
        } else if (event.key === 'Enter') {
            event.preventDefault();
            choose(visible()[active]);
        } else if (event.key === 'Tab') {
            // Focus is leaving the list, so the list goes too, as the select does.
            popover.hidePopover();
        }
    });
    list.addEventListener('click', (event) => choose(event.target.closest('[role="option"]')));
    list.addEventListener('mousedown', (event) => event.preventDefault());

    function position() {
        if (closeIfOutOfView(anchor, popover)) {
            return;
        }
        const box = anchor.getBoundingClientRect();
        popover.style.width = `${box.width}px`;
        const height = popover.offsetHeight;
        const below = box.bottom + GAP;
        const fitsBelow = below + height <= window.innerHeight - VIEWPORT_EDGE;
        popover.style.top = `${fitsBelow || box.top - GAP - height < VIEWPORT_EDGE ? below : box.top - GAP - height}px`;
        popover.style.left = `${box.left}px`;
    }

    popover.addEventListener('beforetoggle', (event) => {
        if (event.newState === 'open') {
            // Built on first open; again if a Livewire render emptied the list (the server sends it empty).
            if (options.length === 0 || !options[0].isConnected) {
                options = [];
                list.replaceChildren();
                build();
            }
            popover.style.visibility = 'hidden';
        }
    });

    popover.addEventListener('toggle', (event) => {
        const open = event.newState === 'open';
        trigger.setAttribute('aria-expanded', String(open));
        if (!open) {
            window.removeEventListener('scroll', position, true);
            window.removeEventListener('resize', position);
            search.value = '';
            filter();

            return;
        }
        options.forEach((option) => option.setAttribute('aria-selected', String(option.dataset.iso === country)));
        position();
        popover.style.visibility = '';
        search.focus();
        setActive(visible().findIndex((option) => option.dataset.iso === country));
        window.addEventListener('scroll', position, true);
        window.addEventListener('resize', position);
    });

    showCountry();

    // The hidden input changed from outside (a Livewire render, the server setting or clearing the number): show it.
    followers.set(root, () => {
        // Not while it's being typed in: a render can answer an earlier keystroke, and the field holds the newer value.
        if (root.contains(document.activeElement) || (hidden.value === international() && hidden.dataset.country === country)) {
            return;
        }
        country = hidden.dataset.country || country;
        if (hidden.value) {
            adopt(hidden.value);
        } else {
            national.value = '';
        }
        showCountry();
    });
}

export function initPhones(scope = document) {
    scope.querySelectorAll('[data-phone]').forEach(initPhone);
}

export function refreshPhones(scope = document) {
    (scope.matches?.('[data-phone]') ? [scope] : [...scope.querySelectorAll('[data-phone]')]).forEach((root) => followers.get(root)?.());
}

initPhones();

// Phone fields added to the page later (a Livewire render or wire:navigate, fetched HTML) set themselves up.
new MutationObserver((records) => {
    for (const node of records.flatMap((record) => [...record.addedNodes])) {
        if (node instanceof Element) {
            (node.matches('[data-phone]') ? [node] : node.querySelectorAll('[data-phone]')).forEach(initPhone);
        }
    }
}).observe(document.documentElement, { childList: true, subtree: true });

// Livewire: a render brings back the server's markup; each phone takes on the value it came back with.
onLivewireMorph(refreshPhones);
app/View/Widget/Countries.php Show
Countries.php
<?php

declare(strict_types=1);

namespace App\View\Widget;

/**
 * Country dial codes for <x-widget.phone>. Only codes: names come from the browser in the page's
 * language (Intl.DisplayNames) and flags are emoji built from the country code, so nothing else ships.
 * Countries sharing a code (+1, +7, +44…) are listed under it; Caribbean +1 countries under their area code.
 */
final class Countries
{
    /** @var array<string, string> ISO 3166 alpha-2 => dial code, without the + */
    public const array DIAL_CODES = [
        'AD' => '376', 'AE' => '971', 'AF' => '93', 'AG' => '1268', 'AI' => '1264', 'AL' => '355', 'AM' => '374', 'AO' => '244',
        'AR' => '54', 'AS' => '1684', 'AT' => '43', 'AU' => '61', 'AW' => '297', 'AX' => '358', 'AZ' => '994', 'BA' => '387',
        'BB' => '1246', 'BD' => '880', 'BE' => '32', 'BF' => '226', 'BG' => '359', 'BH' => '973', 'BI' => '257', 'BJ' => '229',
        'BL' => '590', 'BM' => '1441', 'BN' => '673', 'BO' => '591', 'BQ' => '599', 'BR' => '55', 'BS' => '1242', 'BT' => '975',
        'BW' => '267', 'BY' => '375', 'BZ' => '501', 'CA' => '1', 'CC' => '61', 'CD' => '243', 'CF' => '236', 'CG' => '242',
        'CH' => '41', 'CI' => '225', 'CK' => '682', 'CL' => '56', 'CM' => '237', 'CN' => '86', 'CO' => '57', 'CR' => '506',
        'CU' => '53', 'CV' => '238', 'CW' => '599', 'CX' => '61', 'CY' => '357', 'CZ' => '420', 'DE' => '49', 'DJ' => '253',
        'DK' => '45', 'DM' => '1767', 'DO' => '1809', 'DZ' => '213', 'EC' => '593', 'EE' => '372', 'EG' => '20', 'EH' => '212',
        'ER' => '291', 'ES' => '34', 'ET' => '251', 'FI' => '358', 'FJ' => '679', 'FK' => '500', 'FM' => '691', 'FO' => '298',
        'FR' => '33', 'GA' => '241', 'GB' => '44', 'GD' => '1473', 'GE' => '995', 'GF' => '594', 'GG' => '44', 'GH' => '233',
        'GI' => '350', 'GL' => '299', 'GM' => '220', 'GN' => '224', 'GP' => '590', 'GQ' => '240', 'GR' => '30', 'GT' => '502',
        'GU' => '1671', 'GW' => '245', 'GY' => '592', 'HK' => '852', 'HN' => '504', 'HR' => '385', 'HT' => '509', 'HU' => '36',
        'ID' => '62', 'IE' => '353', 'IL' => '972', 'IM' => '44', 'IN' => '91', 'IO' => '246', 'IQ' => '964', 'IR' => '98',
        'IS' => '354', 'IT' => '39', 'JE' => '44', 'JM' => '1876', 'JO' => '962', 'JP' => '81', 'KE' => '254', 'KG' => '996',
        'KH' => '855', 'KI' => '686', 'KM' => '269', 'KN' => '1869', 'KP' => '850', 'KR' => '82', 'KW' => '965', 'KY' => '1345',
        'KZ' => '7', 'LA' => '856', 'LB' => '961', 'LC' => '1758', 'LI' => '423', 'LK' => '94', 'LR' => '231', 'LS' => '266',
        'LT' => '370', 'LU' => '352', 'LV' => '371', 'LY' => '218', 'MA' => '212', 'MC' => '377', 'MD' => '373', 'ME' => '382',
        'MF' => '590', 'MG' => '261', 'MH' => '692', 'MK' => '389', 'ML' => '223', 'MM' => '95', 'MN' => '976', 'MO' => '853',
        'MP' => '1670', 'MQ' => '596', 'MR' => '222', 'MS' => '1664', 'MT' => '356', 'MU' => '230', 'MV' => '960', 'MW' => '265',
        'MX' => '52', 'MY' => '60', 'MZ' => '258', 'NA' => '264', 'NC' => '687', 'NE' => '227', 'NF' => '672', 'NG' => '234',
        'NI' => '505', 'NL' => '31', 'NO' => '47', 'NP' => '977', 'NR' => '674', 'NU' => '683', 'NZ' => '64', 'OM' => '968',
        'PA' => '507', 'PE' => '51', 'PF' => '689', 'PG' => '675', 'PH' => '63', 'PK' => '92', 'PL' => '48', 'PM' => '508',
        'PR' => '1787', 'PS' => '970', 'PT' => '351', 'PW' => '680', 'PY' => '595', 'QA' => '974', 'RE' => '262', 'RO' => '40',
        'RS' => '381', 'RU' => '7', 'RW' => '250', 'SA' => '966', 'SB' => '677', 'SC' => '248', 'SD' => '249', 'SE' => '46',
        'SG' => '65', 'SH' => '290', 'SI' => '386', 'SJ' => '47', 'SK' => '421', 'SL' => '232', 'SM' => '378', 'SN' => '221',
        'SO' => '252', 'SR' => '597', 'SS' => '211', 'ST' => '239', 'SV' => '503', 'SX' => '1721', 'SY' => '963', 'SZ' => '268',
        'TC' => '1649', 'TD' => '235', 'TG' => '228', 'TH' => '66', 'TJ' => '992', 'TK' => '690', 'TL' => '670', 'TM' => '993',
        'TN' => '216', 'TO' => '676', 'TR' => '90', 'TT' => '1868', 'TV' => '688', 'TW' => '886', 'TZ' => '255', 'UA' => '380',
        'UG' => '256', 'US' => '1', 'UY' => '598', 'UZ' => '998', 'VA' => '39', 'VC' => '1784', 'VE' => '58', 'VG' => '1284',
        'VI' => '1340', 'VN' => '84', 'VU' => '678', 'WF' => '681', 'WS' => '685', 'XK' => '383', 'YE' => '967', 'YT' => '262',
        'ZA' => '27', 'ZM' => '260', 'ZW' => '263',
    ];

    /**
     * Where most numbers keep their leading 0 inside the international form too (Italy and its enclaves);
     * everywhere else a national 012… becomes +60 12….
     */
    public const array KEEPS_LEADING_ZERO = ['IT', 'SM', 'VA'];

    /** Who a shared code means when nothing else decides: +1 is the US, not Canada; +44 the UK, not Jersey. */
    public const array MAIN_COUNTRY = ['US', 'RU', 'GB', 'NO', 'AU', 'RE', 'GP', 'CW', 'MA', 'FI', 'IT'];

    /**
     * The table with each shared code's main country first, so "first match wins" picks it on a tie.
     *
     * @return array<string, string>
     */
    public static function ordered(): array
    {
        return array_merge(array_intersect_key(self::DIAL_CODES, array_flip(self::MAIN_COUNTRY)), self::DIAL_CODES);
    }

    /** "US:1,…,MY:60,SG:65,…" for the browser, a couple of KB, main countries first like ordered(). */
    public static function compact(): string
    {
        $ordered = self::ordered();

        return implode(',', array_map(static fn (string $iso, string $dial): string => "{$iso}:{$dial}", array_keys($ordered), $ordered));
    }

    /**
     * The country to start on: the one asked for, else the region of the locale (en_MY → MY), else the US.
     */
    public static function guess(?string $country = null, ?string $locale = null): string
    {
        $country = strtoupper((string) $country);
        if (isset(self::DIAL_CODES[$country])) {
            return $country;
        }
        $region = class_exists(\Locale::class) ? strtoupper((string) \Locale::getRegion(str_replace('-', '_', $locale ?? app()->getLocale()))) : '';

        return isset(self::DIAL_CODES[$region]) ? $region : 'US';
    }

    /**
     * Splits a stored international number (+60123456789) into its country and national digits. The
     * longest matching code wins; on a shared code, $preferred when it matches, else the main country.
     *
     * @return array{0: string, 1: string} [country, national digits]
     */
    public static function split(?string $number, string $preferred): array
    {
        $digits = preg_replace('/\D/', '', (string) $number) ?? '';
        if ($number === null || !str_starts_with(trim($number), '+') || $digits === '') {
            return [$preferred, $digits];
        }

        $best = null;
        foreach (self::ordered() as $iso => $dial) {
            if (!str_starts_with($digits, $dial)) {
                continue;
            }
            $bestDial = $best !== null ? self::DIAL_CODES[$best] : '';
            if ($best === null || strlen($dial) > strlen($bestDial) || (strlen($dial) === strlen($bestDial) && $iso === $preferred)) {
                $best = $iso;
            }
        }

        return $best === null ? [$preferred, $digits] : [$best, substr($digits, strlen(self::DIAL_CODES[$best]))];
    }

    /** National digits as they are dialled (0123456789) → +60123456789. Empty stays empty. */
    public static function international(string $country, string $national): string
    {
        $digits = preg_replace('/\D/', '', $national) ?? '';
        if ($digits === '') {
            return '';
        }
        if (!in_array($country, self::KEEPS_LEADING_ZERO, true)) {
            $digits = preg_replace('/^0/', '', $digits) ?? $digits;
        }

        return '+'.self::DIAL_CODES[$country].$digits;
    }
}
app/View/Widget/ElementIds.php Show
ElementIds.php
<?php

declare(strict_types=1);

namespace App\View\Widget;

use Illuminate\Container\Attributes\Scoped;
use LogicException;

/**
 * Keeps element ids unique within one response, so labels, aria-describedby and #fragments
 * always point at the right element. Scoped: a fresh set per request (and per Octane/queue cycle).
 */
#[Scoped]
final class ElementIds
{
    /** @var array<string, true> */
    private array $used = [];

    /**
     * Reserves an id for this response.
     *
     * A derived id (built from a field name) gets a -2, -3 … suffix when already taken. An explicit
     * id is one the caller chose and may reference from JS or CSS, so silently renaming it would
     * break that reference; a duplicate throws instead, which surfaces in development and tests.
     */
    public function claim(string $id, bool $explicit = false): string
    {
        if (!isset($this->used[$id])) {
            return $this->reserve($id);
        }

        if ($explicit) {
            throw new LogicException("Duplicate element id [{$id}] on this page. Give one of the widgets a different id or name.");
        }

        $suffix = 2;
        while (isset($this->used["{$id}-{$suffix}"])) {
            $suffix++;
        }

        return $this->reserve("{$id}-{$suffix}");
    }

    private function reserve(string $id): string
    {
        $this->used[$id] = true;

        return $id;
    }
}
app/View/Widget/FormField.php Show
FormField.php
<?php

declare(strict_types=1);

namespace App\View\Widget;

use Illuminate\Contracts\Support\MessageBag;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\ViewErrorBag;
use Illuminate\View\ComponentAttributeBag;

/**
 * Server-side state of one form widget: its dot-notation key, a valid id, its validation
 * messages and its old input. Every <x-widget.input.*> and the date picker resolve through
 * here, so array names (items[0][date]) and named error bags behave the same everywhere.
 */
final class FormField
{
    /**
     * @param  list<string>  $errors
     */
    private function __construct(
        public readonly ?string $name,
        public readonly string $id,
        public readonly ?string $key,
        public readonly array $errors,
        // The property a wire:model or x-model attribute binds it to, if any.
        public readonly ?string $bound = null,
    ) {}

    /**
     * @param  mixed  $errorBag  the view's shared $errors (absent outside a web request)
     * @param  string|array<int, string>|null  $error  an explicit message from the caller; overrides the bag
     */
    public static function make(
        ?string $name,
        ?string $id,
        mixed $errorBag,
        string|array|null $error = null,
        string $bag = 'default',
        string $idPrefix = 'field',
        ?ComponentAttributeBag $attributes = null,
    ): self {
        // With no name, a Livewire or Alpine binding (wire:model="email") names the field. Its errors are filed under
        // that property, and its id stays the same on every render, which Livewire's morph needs to keep the element
        // (it matches elements by id: a random one makes it swap in a new field, dropping focus mid-typing).
        $bound = $attributes === null ? null : self::boundTo($attributes);
        $key = match (true) {
            $name !== null && $name !== '' => self::key($name),
            $bound !== null => self::key($bound),
            default => null,
        };

        $messages = match (true) {
            $error !== null => Arr::wrap($error),
            $key !== null && $errorBag instanceof ViewErrorBag => self::messagesFor($errorBag->getBag($bag), $key),
            default => [],
        };

        return new self(
            $name,
            app(ElementIds::class)->claim(
                $id ?? ($key !== null ? self::idFrom($key) : $idPrefix.'-'.Str::random(6)),
                explicit: $id !== null,
            ),
            $key,
            array_values(array_filter($messages, static fn (mixed $message): bool => is_string($message) && $message !== '')),
            $bound,
        );
    }

    /**
     * The field's own messages, plus those Laravel files per item for a list of values: a 'tags.*' rule
     * reports a bad second choice under tags.1, which a multiple select named tags must still show. Only
     * numbered children count, so a field named address doesn't take errors meant for address[city].
     *
     * @return list<string>
     */
    private static function messagesFor(MessageBag $bag, string $key): array
    {
        $items = array_filter(
            $bag->getMessages(),
            static fn (string $name): bool => preg_match('/^'.preg_quote($key, '/').'\.\d+$/', $name) === 1,
            ARRAY_FILTER_USE_KEY,
        );

        return array_values(array_unique([...$bag->get($key), ...array_merge(...array_values($items))]));
    }

    /**
     * items[0][date] → items.0.date and tags[] → tags: the key Laravel files errors and old input under.
     */
    public static function key(string $name): string
    {
        return trim((string) preg_replace('/\[([^\]]*)\]/', '.$1', $name), '.');
    }

    /**
     * The id a field named $name gets when it's the first of that name on the page, for links to it
     * (the error summary). A later duplicate gets a -2 suffix, which links can't know about.
     */
    public static function idFor(string $name): string
    {
        return self::idFrom(self::key($name));
    }

    /**
     * The property a wire:model or x-model attribute (any modifiers) binds the field to; null without one.
     */
    public static function boundTo(ComponentAttributeBag $attributes): ?string
    {
        return array_values(self::binding($attributes))[0] ?? null;
    }

    private static function idFrom(string $key): string
    {
        return trim((string) preg_replace('/[^A-Za-z0-9_-]+/', '-', $key), '-');
    }

    public function hasError(): bool
    {
        return $this->errors !== [];
    }

    public function errorId(): string
    {
        return $this->id.'-error';
    }

    public function infoId(): string
    {
        return $this->id.'-info';
    }

    /**
     * Old input after a failed validation, falling back to the widget's value prop, or with none, to the bound Livewire
     * property: a re-render then draws the field as it is, which Livewire morphs onto the page.
     */
    public function old(mixed $default = null): mixed
    {
        if ($default === null) {
            [$found, $live] = $this->fromLivewire();
            $default = $found ? $live : null;
        }

        return $this->key === null ? $default : old($this->key, $default);
    }

    /**
     * The bound property's value while Livewire renders the component that holds it: Livewire shares that component
     * with every view as $__livewire. Livewire isn't a dependency; it's only looked for. [false, null] otherwise.
     * $key reads inside it: a range bound to period reads period.start.
     *
     * @return array{0: bool, 1: mixed}
     */
    public function fromLivewire(?string $key = null): array
    {
        $component = $this->bound === null ? null : view()->shared('__livewire');

        return is_object($component) ? [true, data_get($component, $key === null ? $this->bound : "{$this->bound}.{$key}")] : [false, null];
    }

    /**
     * The binding attribute as written (wire:model.live => period), to put on the inputs that carry the value: a range
     * picker binds period.start and period.end with the same modifiers. Empty without one.
     *
     * @return array<string, string>
     */
    public static function binding(ComponentAttributeBag $attributes): array
    {
        foreach ($attributes->getAttributes() as $attribute => $value) {
            if (is_string($value) && $value !== '' && (str_starts_with($attribute, 'wire:model') || str_starts_with($attribute, 'x-model'))) {
                return [$attribute => $value];
            }
        }

        return [];
    }

    /**
     * Whether a checkbox or switch renders ticked. An unticked box isn't in the request at all, so after a
     * failed submit "no old value" means unticked, but only when that submit was this box's own form. A page
     * with a second form (or a disabled box, which is never sent) would otherwise lose every `checked`.
     *
     * @param  bool  $alwaysSent  it has an unchecked-value, so its form always sends something under its name
     * @param  mixed  $errorBag  the view's shared $errors
     */
    public function checked(mixed $value, bool $default, bool $disabled, bool $alwaysSent, mixed $errorBag, string $bag = 'default'): bool
    {
        // Bound to a Livewire property: that says, true/false, or for a list of boxes, whether it holds this value.
        [$found, $live] = $this->fromLivewire();
        if ($found) {
            return is_array($live) ? in_array(self::text($value), array_map(self::text(...), $live), true) : (bool) $live;
        }
        if ($this->key === null || $disabled || !session()->hasOldInput()) {
            return $default;
        }

        $old = old($this->key);
        if ($old !== null) {
            return in_array(self::text($value), array_map(self::text(...), Arr::wrap($old)), true);
        }
        // Nothing under this name, though this box always sends something: its form wasn't the one submitted.
        if ($alwaysSent) {
            return $default;
        }
        // A form with its own error bag: no errors in that bag means the failed submit was a different form.
        if ($bag !== 'default') {
            return $errorBag instanceof ViewErrorBag && $errorBag->getBag($bag)->isNotEmpty() ? false : $default;
        }

        // One form, or forms sharing the default bag: there's no telling them apart, so trust the old input.
        return false;
    }

    /** Values cast to backed enums (Plan::Pro) compare as their backing value. */
    private static function text(mixed $value): string
    {
        return (string) ($value instanceof \BackedEnum ? $value->value : $value);
    }

    /**
     * What the caller passed, minus `class` (that styles the wrapper) and the aria attributes
     * this widget manages itself. Goes on the element that is actually submitted.
     */
    public function forwarded(ComponentAttributeBag $attributes): ComponentAttributeBag
    {
        return $attributes->except(['class', 'aria-invalid', 'aria-describedby']);
    }

    /**
     * aria-invalid plus one aria-describedby that joins the error, the hint and the caller's own
     * ids. Two separate aria-describedby attributes would make the browser silently drop one.
     */
    public function aria(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
    {
        $describedBy = array_filter([
            $this->hasError() ? $this->errorId() : null,
            $hasInfo ? $this->infoId() : null,
            $attributes->get('aria-describedby'),
        ]);

        return new ComponentAttributeBag([
            'aria-invalid' => $this->hasError() ? 'true' : null,
            'aria-describedby' => $describedBy === [] ? null : implode(' ', $describedBy),
        ]);
    }

    /**
     * For a widget whose visible control isn't the submitted input (grouped number, phone): what belongs on the
     * hidden input that carries the value. Which form it's in, and Livewire and Alpine bindings, go with the value.
     */
    public function bindings(ComponentAttributeBag $attributes): ComponentAttributeBag
    {
        return $attributes->filter(static fn (mixed $value, string $key): bool => self::isBinding($key));
    }

    /**
     * The other side of bindings(): everything else the caller passed (required, autofocus, aria-label,
     * placeholder…) goes on the visible control, where the browser validates it and screen readers hear it.
     */
    public function visibleAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
    {
        return $this->controlAttributes($attributes->filter(static fn (mixed $value, string $key): bool => !self::isBinding($key)), $hasInfo);
    }

    private static function isBinding(string $key): bool
    {
        return $key === 'form' || str_starts_with($key, 'wire:model') || str_starts_with($key, 'x-model');
    }

    /**
     * forwarded() and aria() together, for widgets whose visible control is also the submitted one.
     */
    public function controlAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
    {
        return $this->forwarded($attributes)->merge($this->aria($attributes, $hasInfo)->getAttributes());
    }
}