{"data":{"name":"datepicker","title":"Date picker","tag":"x-widget.datepicker","description":"Single date field with a calendar popover. Submits Y-m-d; validate with NotAfterToday, or MinimumAge for birthday. Works with Livewire wire:model, name optional.","group":"Forms","install":"php artisan larawell:add datepicker","requires":["field","icon"],"examples_use":[],"examples_install":null,"composer":[],"php_extensions":["intl"],"components":[{"tag":"x-widget.datepicker","props":[{"name":"name","default":"null","required":false,"description":"What the date submits as (YYYY-MM-DD). Optional with wire:model, which then names it."},{"name":"id","default":"null","required":false,"description":"Defaults to one made from the name (or the wire:model property)."},{"name":"label","default":"null","required":false,"description":"Shown above the field, and its name for screen readers."},{"name":"value","default":"null","required":false,"description":"The date: YYYY-MM-DD or a date object. Old input wins after a failed submit; with wire:model and no value, the bound property."},{"name":"placeholder","default":"null","required":false,"description":"Shown while no date is picked; \"Choose a date\" by default."},{"name":"error","default":"null","required":false,"description":"An error message of your own; otherwise the validation error for the name, from the session or Livewire."},{"name":"info","default":"null","required":false,"description":"A hint under the field."},{"name":"bag","default":"'default'","required":false,"description":"Which error bag to read the error from."},{"name":"disabled","default":"false","required":false,"description":"Greyed out: it can't be opened."},{"name":"min","default":"null","required":false,"description":"The earliest date that can be picked: YYYY-MM-DD, a date object, or \"today\" (the visitor's today)."},{"name":"max","default":"'today'","required":false,"description":"The latest date: YYYY-MM-DD, a date object, \"today\" (the default), or false for no limit."},{"name":"birthday","default":"false","required":false,"description":"A date-of-birth picker: true for 18 or older, or a number for another minimum age. Sets max and the hint to match."},{"name":"week-start","default":"null","required":false,"description":"The first day of the week, 0 (Sunday) to 6 (Saturday); defaults to the locale's."},{"name":"locale","default":"null","required":false,"description":"The language for month and day names and the date shown (en, fr, ar\u2026); defaults to the app's."},{"name":"required-message","default":"'Choose a date.'","required":false,"description":"With required: the message when nothing is picked."}],"slots":{}}],"usage":[{"title":"In other languages","description":"Dates, month and weekday names, the week's first day and digits follow the app locale (app()->getLocale()). For Arabic, Hebrew, Persian or Urdu, set dir=\"rtl\" and the calendar mirrors, keyboard included. The picker's own labels are plain English in the component file; edit them there.","language":"blade","code":"<html lang=\"{{ str_replace('_', '-', app()->getLocale()) }}\" dir=\"{{ in_array(app()->getLocale(), ['ar', 'he', 'fa', 'ur']) ? 'rtl' : 'ltr' }}\">\n\n{{-- Or set them per picker: --}}\n<x-widget.datepicker name=\"start_date\" label=\"Datum\" locale=\"de\" :week-start=\"0\" \/>\n"},{"title":"Livewire","description":"In a Livewire component, bind with wire:model (deferred) or wire:model.live to a string property holding YYYY-MM-DD, or null: public ?string $due = null. No name is needed. The field shows the property after every render, and a date set or cleared in PHP shows too; the calendar opens on it. Validate it like any date: 'due' => ['required', 'date'].","language":"blade","code":"<div>\n    <x-widget.datepicker label=\"Due date\" wire:model.live=\"due\" :max=\"false\" \/>\n\n    <x-widget.datepicker label=\"Date of birth\" wire:model=\"birthday\" birthday \/>\n<\/div>\n"}],"examples":[{"title":"Basic","description":"Submits Y-m-d. Validate it with new NotAfterToday, not before_or_equal:today, which uses the server's UTC date.","variables":[],"code":"<x-widget.datepicker name=\"start_date\" label=\"Start date\" class=\"max-w-xs\" \/>\n","script":null},{"title":"Birthday","description":"Only allows dates at least 18 years back. Pair it with new MinimumAge(18) on the server.","variables":[],"code":"<x-widget.datepicker name=\"date_of_birth\" label=\"Date of birth\" birthday class=\"max-w-xs\" \/>\n","script":null},{"title":"States","description":null,"variables":[],"code":"<div class=\"grid gap-6 sm:grid-cols-2\">\n    <x-widget.datepicker name=\"due_date\" label=\"With error\" value=\"2026-09-01\" error=\"Please choose a valid date.\" \/>\n    <x-widget.datepicker name=\"locked_date\" label=\"Disabled\" disabled \/>\n<\/div>\n","script":null}],"files":[{"path":"resources\/views\/components\/widget\/datepicker\/index.blade.php","contents":"@props([\n    \/\/ What the date submits as (YYYY-MM-DD). Optional with wire:model, which then names it.\n    'name' => null,\n    \/\/ Defaults to one made from the name (or the wire:model property).\n    'id' => null,\n    \/\/ Shown above the field, and its name for screen readers.\n    'label' => null,\n    \/\/ The date: YYYY-MM-DD or a date object. Old input wins after a failed submit; with wire:model and no value, the\n    \/\/ bound property.\n    'value' => null,\n    \/\/ Shown while no date is picked; \"Choose a date\" by default.\n    'placeholder' => null,\n    \/\/ An error message of your own; otherwise the validation error for the name, from the session or Livewire.\n    'error' => null,\n    \/\/ A hint under the field.\n    'info' => null,\n    \/\/ Which error bag to read the error from.\n    'bag' => 'default',\n    \/\/ Greyed out: it can't be opened.\n    'disabled' => false,\n    \/\/ The earliest date that can be picked: YYYY-MM-DD, a date object, or \"today\" (the visitor's today).\n    'min' => null,\n    \/\/ The latest date: YYYY-MM-DD, a date object, \"today\" (the default), or false for no limit.\n    'max' => 'today',\n    \/\/ A date-of-birth picker: true for 18 or older, or a number for another minimum age. Sets max and the hint to\n    \/\/ match.\n    'birthday' => false,\n    \/\/ The first day of the week, 0 (Sunday) to 6 (Saturday); defaults to the locale's.\n    'weekStart' => null,\n    \/\/ The language for month and day names and the date shown (en, fr, ar\u2026); defaults to the app's.\n    'locale' => null,\n    \/\/ With required: the message when nothing is picked.\n    'requiredMessage' => 'Choose a date.',\n])\n\n@php\n    $toIso = static function (mixed $date): ?string {\n        if ($date instanceof \\DateTimeInterface) {\n            return $date->format('Y-m-d');\n        }\n\n        if (is_string($date) && preg_match('\/^(\\d{4})-(\\d{2})-(\\d{2})$\/', $date, $parts) === 1\n            && checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1])) {\n            return $date;\n        }\n\n        return null;\n    };\n\n    \/\/ birthday means 18 or older; birthday=\"21\" sets another age. Pair it with new MinimumAge($age).\n    $minAge = match (true) {\n        $birthday === true => 18,\n        is_numeric($birthday) && (int) $birthday > 0 => (int) $birthday,\n        default => null,\n    };\n    $birthday = $minAge !== null;\n    $maxYearsBack = $minAge;\n\n    \/\/ \"today\" is resolved by the browser, on the user's own clock: the server's date can be a day\n    \/\/ behind or ahead of the user (e.g. UTC server, user in IST), which would block or allow the wrong day.\n    \/\/ Browser-side limit only; the Form Request must enforce the same rule with\n    \/\/ App\\Rules\\NotAfterToday (default) or MinimumAge (birthday), not 'before_or_equal:today'.\n    \/\/ :max=\"false\" removes the limit (Blade turns a passed null back into the 'today' default).\n    $max = match (true) {\n        $birthday, $max === 'today' => 'today',\n        $max === false => null,\n        default => $toIso($max),\n    };\n    $min = $min === 'today' ? 'today' : $toIso($min);\n    $field = \\App\\View\\Widget\\FormField::make($name, $id, $errors ?? null, $error, $bag, 'datepicker', attributes: $attributes);\n    $id = $field->id;\n    $value = $toIso($field->old($value));\n    \/\/ Dates read the way the locale writes them; the browser keeps the same format (see the JS).\n    $locale = \\App\\View\\Widget\\LocalDate::locale($locale);\n    $weekStart ??= \\App\\View\\Widget\\LocalDate::firstDayOfWeek($locale);\n    $display = $value ? \\App\\View\\Widget\\LocalDate::format($value, $locale) : null;\n    $placeholder ??= 'Choose a date';\n    \/\/ Explains the greyed-out recent dates; pass info=\"\" to hide it, or your own text to replace it.\n    $info ??= $birthday ? \"You must be {$minAge} or older.\" : null;\n@endphp\n\n{{--\n    Built on the shared input frame (widget\/field): same label, box, error and hint as every\n    other input, all messages, clear-on-edit. The caller's attributes go on the submitted hidden input;\n    `class` goes on the wrapper.\n--}}\n{{-- The button can't be required or aria-required, so the label says it for screen readers, and resources\/js\/widget\/field\n     stops an empty submit (the value is in hidden inputs, which the browser never validates). --}}\n<x-widget.field\n    :required=\"$attributes->has('required')\"\n    :announce-required=\"true\"\n    :data-required-message=\"$attributes->has('required') && ! $disabled ? $requiredMessage : false\"\n    data-datepicker\n    data-min=\"{{ $min }}\"\n    data-max=\"{{ $max }}\"\n    :data-max-years-back=\"$maxYearsBack\"\n    data-week-start=\"{{ $weekStart }}\"\n    data-locale=\"{{ $locale }}\"\n    :id=\"$id\"\n    :label=\"$label\"\n    :error=\"$field->errors\"\n    :info=\"$info\"\n    :disabled=\"$disabled\"\n    box=\"h-12 items-center\"\n    :class=\"$attributes->get('class')\"\n>\n    <button\n        type=\"button\"\n        id=\"{{ $id }}\"\n        popovertarget=\"{{ $id }}-calendar\"\n        aria-haspopup=\"dialog\"\n        aria-expanded=\"false\"\n        {{-- A <label> would otherwise be the button's whole name, and the chosen date inside it would never be read. --}}\n        @if ($label) aria-labelledby=\"{{ $id }}-label {{ $id }}-display\" @endif\n        {{ $field->aria($attributes, (bool) $info) }}\n        @disabled($disabled)\n        data-datepicker-trigger\n        class=\"flex h-full w-full min-w-0 items-center justify-between gap-2.5 px-5 text-start whitespace-nowrap outline-none select-none enabled:cursor-pointer disabled:cursor-not-allowed disabled:opacity-50\"\n    >\n        <span\n            id=\"{{ $id }}-display\"\n            data-datepicker-display\n            @if (! $display) data-empty @endif\n            class=\"text-style-2 data-empty:text-muted group-data-invalid\/field:data-empty:text-error truncate\"\n        >{{ $display ?? $placeholder }}<\/span>\n\n        <x-widget.icon name=\"calendar-days\" @class(['size-5', 'opacity-40' => $disabled]) \/>\n    <\/button>\n\n    <input type=\"hidden\" @if ($name) name=\"{{ $name }}\" @endif value=\"{{ $value }}\" data-datepicker-input {{ $field->forwarded($attributes)->except(['required']) }}>\n\n    {{-- wire:ignore: the script builds everything in here (the month and year lists, the days), and a Livewire render\n         would empty it again, since the server sends it empty. The value lives outside, on the hidden input(s). --}}\n    <div\n        wire:ignore\n        id=\"{{ $id }}-calendar\"\n        popover\n        role=\"dialog\"\n        aria-label=\"{{ $label ?? 'Choose a date' }}\"\n        data-datepicker-popover\n        class=\"border-line text-foreground fixed inset-auto m-0 overflow-y-auto overscroll-contain rounded-md border bg-surface p-3 shadow-lg\"\n    >\n        {{-- Selects never shrink, so the full month name always shows; a <select> is as wide as its\n             longest option, so if \"September\" fits at the 252px minimum, every month does. --}}\n        <div class=\"mb-2 flex items-center justify-between\">\n            <button type=\"button\" data-datepicker-prev aria-label=\"Previous month\" class=\"enabled:hover:bg-field grid size-7 shrink-0 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:opacity-30\">\n                <x-widget.icon name=\"chevron-left\" class=\"size-4 rtl:rotate-180\" \/>\n            <\/button>\n\n            {{-- Each select draws its own arrow (appearance-none hides the browser's, which sits against the focus ring). --}}\n            <div class=\"flex shrink-0 gap-1\">\n                <span class=\"relative inline-flex shrink-0\">\n                    <select id=\"{{ $id }}-month\" data-datepicker-month aria-label=\"Month\" class=\"hover:bg-field shrink-0 cursor-pointer appearance-none rounded-md bg-transparent py-1 ps-1 pe-5 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-primary\"><\/select>\n                    <x-widget.icon name=\"chevron-down\" class=\"text-foreground\/60 pointer-events-none absolute end-1.5 top-1\/2 size-3 -translate-y-1\/2\" \/>\n                <\/span>\n                <span class=\"relative inline-flex shrink-0\">\n                    <select id=\"{{ $id }}-year\" data-datepicker-year aria-label=\"Year\" class=\"hover:bg-field shrink-0 cursor-pointer appearance-none rounded-md bg-transparent py-1 ps-1 pe-5 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-primary\"><\/select>\n                    <x-widget.icon name=\"chevron-down\" class=\"text-foreground\/60 pointer-events-none absolute end-1.5 top-1\/2 size-3 -translate-y-1\/2\" \/>\n                <\/span>\n            <\/div>\n\n            <button type=\"button\" data-datepicker-next aria-label=\"Next month\" class=\"enabled:hover:bg-field grid size-7 shrink-0 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:opacity-30\">\n                <x-widget.icon name=\"chevron-right\" class=\"size-4 rtl:rotate-180\" \/>\n            <\/button>\n        <\/div>\n\n        <table data-datepicker-grid class=\"w-full table-fixed border-collapse text-center\"><\/table>\n    <\/div>\n<\/x-widget.field>\n"},{"path":"resources\/js\/widget\/datepicker\/index.js","contents":"\/\/ Drives <x-widget.datepicker>. Dates are plain local calendar days (no time\n\/\/ part) so a timezone offset can never shift the selected day. The date helpers are\n\/\/ exported for <x-widget.date-range-picker>, which works on the same calendar days.\nimport { closeIfOutOfView } from '..\/field';\n\nexport const pad = (n) => String(n).padStart(2, '0');\nexport const toIso = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;\nexport const addDays = (d, n) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);\nexport const daysInMonth = (year, month) => new Date(year, month + 1, 0).getDate();\n\n\/\/ Keeps the day where possible but never overflows: 31 Jan + 1 month is 28\/29 Feb, not 3 Mar.\nexport const addMonths = (d, n) => {\n    const target = new Date(d.getFullYear(), d.getMonth() + n, 1);\n    const day = Math.min(d.getDate(), daysInMonth(target.getFullYear(), target.getMonth()));\n\n    return new Date(target.getFullYear(), target.getMonth(), day);\n};\n\nexport const parseIso = (value) => {\n    const match = \/^(\\d{4})-(\\d{2})-(\\d{2})$\/.exec(value ?? '');\n\n    return match ? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])) : null;\n};\n\n\/\/ How the picker's locale writes dates, shared with <x-widget.date-range-picker>. The locale comes from the\n\/\/ server (data-locale), which rendered the first paint in the same format. Always the Gregorian calendar,\n\/\/ because the grid is: some locales (ar-SA, th) would otherwise show Hijri or Buddhist dates above it.\n\/\/ Names, word order and digits stay the locale's own.\nexport function localeTools(root) {\n    const locale = root.dataset.locale || document.documentElement.lang || undefined;\n    const gregory = { calendar: 'gregory' };\n    const date = new Intl.DateTimeFormat(locale, { ...gregory, year: 'numeric', month: 'short', day: 'numeric' });\n    const number = new Intl.NumberFormat(locale, { useGrouping: false });\n\n    return {\n        locale,\n        rtl: getComputedStyle(root).direction === 'rtl',\n        format: (d) => date.format(d),\n        formatRange: (from, to) => date.formatRange(from, to),\n        number: (n) => number.format(n),\n        monthName: new Intl.DateTimeFormat(locale, { ...gregory, month: 'long' }),\n        monthCaption: new Intl.DateTimeFormat(locale, { ...gregory, month: 'long', year: 'numeric' }),\n        shortWeekday: new Intl.DateTimeFormat(locale, { ...gregory, weekday: 'short' }),\n        longWeekday: new Intl.DateTimeFormat(locale, { ...gregory, weekday: 'long' }),\n        dayLabel: new Intl.DateTimeFormat(locale, { ...gregory, dateStyle: 'full' }),\n    };\n}\n\n\/\/ Where a popover sits: under the field, lined up with its start edge (the right one in RTL), never off-screen.\nexport function alignedLeft(box, width, rtl) {\n    const edge = rtl ? box.right - width : box.left;\n\n    return Math.max(VIEWPORT_EDGE, Math.min(edge, window.innerWidth - width - VIEWPORT_EDGE));\n}\n\n\/\/ Arrow keys follow the reading direction: in RTL, left is forward in time.\nexport const horizontalStep = (key, rtl) => (key === 'ArrowRight' ? 1 : -1) * (rtl ? -1 : 1);\n\n\/\/ One tone per state so no two classes fight over the same property.\n\/\/ Fluid days: each fills its (equal, table-fixed) column up to 44px and stays round,\n\/\/ so the grid scales with the dropdown instead of forcing a fixed width.\nconst DAY_BASE = 'mx-auto grid aspect-square w-full max-w-11 place-items-center rounded-full text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-primary';\nconst DAY_TONE = {\n    selected: 'bg-primary-fill font-semibold text-on-primary',\n    disabled: 'cursor-not-allowed text-muted\/50',\n    outside: 'text-muted hover:bg-field',\n    today: 'font-semibold text-primary hover:bg-field',\n    default: 'text-foreground hover:bg-field',\n};\n\/\/ Added on top of whichever tone applies, so today stays marked even when disabled or outside the month.\nconst TODAY_MARK = 'ring-1 ring-inset ring-primary';\n\nexport const VIEWPORT_EDGE = 16;\nexport const POPOVER_GAP = 4;\n\/\/ Narrowest the calendar gets (32px days); a narrower field lets it overhang instead.\nexport const MIN_WIDTH = 252;\n\n\/\/ Set up once per element. Not a data attribute: Livewire's morph removes attributes the server didn't render.\nconst ready = new WeakSet();\n\nfunction initDatepicker(root) {\n    if (ready.has(root)) {\n        return;\n    }\n    ready.add(root);\n\n    const trigger = root.querySelector('[data-datepicker-trigger]');\n    const display = root.querySelector('[data-datepicker-display]');\n    const input = root.querySelector('[data-datepicker-input]');\n    const popover = root.querySelector('[data-datepicker-popover]');\n    const monthSelect = popover.querySelector('[data-datepicker-month]');\n    const yearSelect = popover.querySelector('[data-datepicker-year]');\n    const prevButton = popover.querySelector('[data-datepicker-prev]');\n    const nextButton = popover.querySelector('[data-datepicker-next]');\n    const grid = popover.querySelector('[data-datepicker-grid]');\n\n    const tools = localeTools(root);\n    const weekStart = Number(root.dataset.weekStart || 0);\n    \/\/ \"today\" means the user's local date, not the server's (see the Blade component). It and the limits built\n    \/\/ from it are worked out again each time the calendar opens (refreshLimits), so a page left open past\n    \/\/ midnight doesn't keep yesterday as today.\n    let today;\n    let min;\n    let max;\n    const parseLimit = (value) => (value === 'today' ? today : parseIso(value));\n    const yearsBack = (date, years) => (date && years ? new Date(date.getFullYear() - years, date.getMonth(), date.getDate()) : date);\n    \/\/ A birthday picker (max-years-back) has to reach the oldest people using the form, not stop at 100.\n    const defaultSpan = root.dataset.maxYearsBack ? 120 : 100;\n\n    function refreshLimits() {\n        const now = new Date();\n        const day = new Date(now.getFullYear(), now.getMonth(), now.getDate());\n        if (today && day.getTime() === today.getTime()) {\n            return;\n        }\n        today = day;\n        \/\/ Without explicit limits the year dropdown still needs a finite range.\n        min = parseLimit(root.dataset.min) ?? new Date(today.getFullYear() - defaultSpan, 0, 1);\n        max = yearsBack(parseLimit(root.dataset.max), Number(root.dataset.maxYearsBack || 0))\n            ?? new Date(today.getFullYear() + 10, 11, 31);\n        yearSelect.replaceChildren();\n        for (let year = max.getFullYear(); year >= min.getFullYear(); year--) {\n            yearSelect.add(new Option(tools.number(year), String(year)));\n        }\n    }\n    refreshLimits();\n\n    let selected = parseIso(input.value);\n    let focused = today;\n\n    const isOutOfRange = (d) => d < min || d > max;\n    const clamp = (d) => (d > max ? max : d < min ? min : d);\n    const weekdayOffset = (d) => (d.getDay() - weekStart + 7) % 7;\n\n    for (let month = 0; month < 12; month++) {\n        monthSelect.add(new Option(tools.monthName.format(new Date(2000, month, 1)), String(month)));\n    }\n\n    const headRow = grid.createTHead().insertRow();\n    for (let i = 0; i < 7; i++) {\n        \/\/ 1 Jan 2023 was a Sunday, so offsetting from it lists weekdays in order.\n        const day = new Date(2023, 0, 1 + ((weekStart + i) % 7));\n        const cell = document.createElement('th');\n        cell.scope = 'col';\n        cell.abbr = tools.longWeekday.format(day);\n        cell.textContent = tools.shortWeekday.format(day);\n        cell.className = 'pb-1 text-xs font-medium text-muted';\n        headRow.append(cell);\n    }\n    const body = grid.createTBody();\n\n    function render() {\n        const year = focused.getFullYear();\n        const month = focused.getMonth();\n\n        yearSelect.value = String(year);\n        monthSelect.value = String(month);\n        for (const option of monthSelect.options) {\n            const m = Number(option.value);\n            option.disabled = new Date(year, m + 1, 0) < min || new Date(year, m, 1) > max;\n        }\n        prevButton.disabled = new Date(year, month, 0) < min;\n        nextButton.disabled = new Date(year, month + 1, 1) > max;\n\n        const focusedIso = toIso(focused);\n        const selectedIso = selected ? toIso(selected) : null;\n        const todayIso = toIso(today);\n        let day = addDays(new Date(year, month, 1), -weekdayOffset(new Date(year, month, 1)));\n\n        body.replaceChildren();\n        \/\/ Always six weeks so the popover height doesn't jump between months.\n        for (let week = 0; week < 6; week++) {\n            const row = body.insertRow();\n\n            for (let i = 0; i < 7; i++, day = addDays(day, 1)) {\n                const iso = toIso(day);\n                const disabled = isOutOfRange(day);\n                const tone = iso === selectedIso ? 'selected'\n                    : disabled ? 'disabled'\n                    : day.getMonth() !== month ? 'outside'\n                    : iso === todayIso ? 'today'\n                    : 'default';\n\n                const button = document.createElement('button');\n                button.type = 'button';\n                button.textContent = tools.number(day.getDate());\n                button.dataset.date = iso;\n                button.disabled = disabled;\n                button.tabIndex = iso === focusedIso ? 0 : -1;\n                button.className = `${DAY_BASE} ${DAY_TONE[tone]}${iso === todayIso ? ` ${TODAY_MARK}` : ''}`;\n                button.setAttribute('aria-label', tools.dayLabel.format(day));\n                button.setAttribute('aria-pressed', String(iso === selectedIso));\n                if (iso === todayIso) {\n                    button.setAttribute('aria-current', 'date');\n                }\n\n                row.insertCell().append(button);\n            }\n        }\n    }\n\n    const focusDay = () => grid.querySelector('button[tabindex=\"0\"]')?.focus();\n\n    function moveFocus(date) {\n        focused = clamp(date);\n        render();\n        focusDay();\n    }\n\n    function changeMonth(date, sourceButton) {\n        focused = clamp(date);\n        render();\n        \/\/ A nav button that just became disabled drops focus to <body>; keep keyboard users in the grid.\n        if (sourceButton?.disabled) {\n            focusDay();\n        }\n    }\n\n    function select(date) {\n        selected = date;\n        input.value = toIso(date);\n        display.textContent = tools.format(date);\n        display.removeAttribute('data-empty');\n        \/\/ Both events: tools like Alpine x-model and Livewire wire:model listen for `input`, plain forms for `change`.\n        input.dispatchEvent(new Event('input', { bubbles: true }));\n        input.dispatchEvent(new Event('change', { bubbles: true }));\n        popover.hidePopover();\n        trigger.focus();\n    }\n\n    function position() {\n        if (closeIfOutOfView(trigger, popover)) {\n            return;\n        }\n        const box = trigger.getBoundingClientRect();\n        \/\/ Same width as the field (edges line up), but at least MIN_WIDTH and never wider than the screen.\n        const fit = Math.min(Math.max(box.width, MIN_WIDTH), window.innerWidth - VIEWPORT_EDGE * 2);\n        popover.style.width = `${fit}px`;\n        \/\/ On very short screens the calendar scrolls inside itself rather than running off-screen.\n        popover.style.maxHeight = `${window.innerHeight - VIEWPORT_EDGE * 2}px`;\n\n        const { offsetWidth: width, offsetHeight: height } = popover;\n        const below = box.bottom + POPOVER_GAP;\n        const above = box.top - POPOVER_GAP - height;\n\n        let top;\n        if (below + height <= window.innerHeight - VIEWPORT_EDGE) {\n            top = below;\n        } else if (above >= VIEWPORT_EDGE) {\n            top = above;\n        } else {\n            \/\/ No room either side (e.g. a phone in landscape): stay fully visible, even if that covers the field.\n            top = Math.max(VIEWPORT_EDGE, window.innerHeight - VIEWPORT_EDGE - height);\n        }\n\n        popover.style.top = `${top}px`;\n        popover.style.left = `${alignedLeft(box, width, tools.rtl)}px`;\n    }\n\n    const KEY_MOVES = {\n        ArrowLeft: (d) => addDays(d, horizontalStep('ArrowLeft', tools.rtl)),\n        ArrowRight: (d) => addDays(d, horizontalStep('ArrowRight', tools.rtl)),\n        ArrowUp: (d) => addDays(d, -7),\n        ArrowDown: (d) => addDays(d, 7),\n        Home: (d) => addDays(d, -weekdayOffset(d)),\n        End: (d) => addDays(d, 6 - weekdayOffset(d)),\n        PageUp: (d, event) => addMonths(d, event.shiftKey ? -12 : -1),\n        PageDown: (d, event) => addMonths(d, event.shiftKey ? 12 : 1),\n    };\n\n    grid.addEventListener('keydown', (event) => {\n        const move = KEY_MOVES[event.key];\n        if (!move) {\n            return;\n        }\n        event.preventDefault();\n        moveFocus(move(focused, event));\n    });\n\n    grid.addEventListener('click', (event) => {\n        const button = event.target.closest('button[data-date]');\n        if (button && !button.disabled) {\n            select(parseIso(button.dataset.date));\n        }\n    });\n\n    prevButton.addEventListener('click', () => changeMonth(addMonths(focused, -1), prevButton));\n    nextButton.addEventListener('click', () => changeMonth(addMonths(focused, 1), nextButton));\n\n    const onViewSelect = () => {\n        const year = Number(yearSelect.value);\n        const month = Number(monthSelect.value);\n        changeMonth(new Date(year, month, Math.min(focused.getDate(), daysInMonth(year, month))));\n    };\n    monthSelect.addEventListener('change', onViewSelect);\n    yearSelect.addEventListener('change', onViewSelect);\n\n    popover.addEventListener('beforetoggle', (event) => {\n        if (event.newState !== 'open') {\n            return;\n        }\n        refreshLimits();\n        \/\/ The value as it is now: a Livewire render or another script may have changed it since.\n        selected = parseIso(input.value);\n        \/\/ Open on the selected day, or today clamped into range, so a birthday\n        \/\/ picker doesn't open on a month where every day is disabled.\n        focused = clamp(selected ?? today);\n        render();\n        \/\/ Hidden until positioned, otherwise it flashes at the popover default (screen centre).\n        popover.style.visibility = 'hidden';\n    });\n\n    popover.addEventListener('toggle', (event) => {\n        const open = event.newState === 'open';\n        trigger.setAttribute('aria-expanded', String(open));\n\n        if (!open) {\n            window.removeEventListener('scroll', position, true);\n            window.removeEventListener('resize', position);\n\n            return;\n        }\n\n        position();\n        popover.style.visibility = '';\n        focusDay();\n        \/\/ Capture phase so scrolling any ancestor container also repositions it.\n        window.addEventListener('scroll', position, true);\n        window.addEventListener('resize', position);\n    });\n}\n\nexport function initDatepickers(scope = document) {\n    scope.querySelectorAll('[data-datepicker]').forEach(initDatepicker);\n}\n\ninitDatepickers();\n\n\/\/ Date pickers added to the page later (a Livewire render or wire:navigate, fetched HTML) set themselves up.\nnew MutationObserver((records) => {\n    for (const node of records.flatMap((record) => [...record.addedNodes])) {\n        if (node instanceof Element) {\n            (node.matches('[data-datepicker]') ? [node] : node.querySelectorAll('[data-datepicker]')).forEach(initDatepicker);\n        }\n    }\n}).observe(document.documentElement, { childList: true, subtree: true });\n"},{"path":"app\/View\/Widget\/ElementIds.php","contents":"<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\View\\Widget;\n\nuse Illuminate\\Container\\Attributes\\Scoped;\nuse LogicException;\n\n\/**\n * Keeps element ids unique within one response, so labels, aria-describedby and #fragments\n * always point at the right element. Scoped: a fresh set per request (and per Octane\/queue cycle).\n *\/\n#[Scoped]\nfinal class ElementIds\n{\n    \/** @var array<string, true> *\/\n    private array $used = [];\n\n    \/**\n     * Reserves an id for this response.\n     *\n     * A derived id (built from a field name) gets a -2, -3 \u2026 suffix when already taken. An explicit\n     * id is one the caller chose and may reference from JS or CSS, so silently renaming it would\n     * break that reference; a duplicate throws instead, which surfaces in development and tests.\n     *\/\n    public function claim(string $id, bool $explicit = false): string\n    {\n        if (! isset($this->used[$id])) {\n            return $this->reserve($id);\n        }\n\n        if ($explicit) {\n            throw new LogicException(\"Duplicate element id [{$id}] on this page. Give one of the widgets a different id or name.\");\n        }\n\n        $suffix = 2;\n        while (isset($this->used[\"{$id}-{$suffix}\"])) {\n            $suffix++;\n        }\n\n        return $this->reserve(\"{$id}-{$suffix}\");\n    }\n\n    private function reserve(string $id): string\n    {\n        $this->used[$id] = true;\n\n        return $id;\n    }\n}\n"},{"path":"app\/View\/Widget\/FormField.php","contents":"<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\View\\Widget;\n\nuse Illuminate\\Contracts\\Support\\MessageBag;\nuse Illuminate\\Support\\Arr;\nuse Illuminate\\Support\\Str;\nuse Illuminate\\Support\\ViewErrorBag;\nuse Illuminate\\View\\ComponentAttributeBag;\n\n\/**\n * Server-side state of one form widget: its dot-notation key, a valid id, its validation\n * messages and its old input. Every <x-widget.input.*> and the date picker resolve through\n * here, so array names (items[0][date]) and named error bags behave the same everywhere.\n *\/\nfinal class FormField\n{\n    \/**\n     * @param  list<string>  $errors\n     *\/\n    private function __construct(\n        public readonly ?string $name,\n        public readonly string $id,\n        public readonly ?string $key,\n        public readonly array $errors,\n        \/\/ The property a wire:model or x-model attribute binds it to, if any.\n        public readonly ?string $bound = null,\n    ) {}\n\n    \/**\n     * @param  mixed  $errorBag  the view's shared $errors (absent outside a web request)\n     * @param  string|array<int, string>|null  $error  an explicit message from the caller; overrides the bag\n     *\/\n    public static function make(\n        ?string $name,\n        ?string $id,\n        mixed $errorBag,\n        string|array|null $error = null,\n        string $bag = 'default',\n        string $idPrefix = 'field',\n        ?ComponentAttributeBag $attributes = null,\n    ): self {\n        \/\/ With no name, a Livewire or Alpine binding (wire:model=\"email\") names the field. Its errors are filed under\n        \/\/ that property, and its id stays the same on every render, which Livewire's morph needs to keep the element\n        \/\/ (it matches elements by id: a random one makes it swap in a new field, dropping focus mid-typing).\n        $bound = $attributes === null ? null : self::boundTo($attributes);\n        $key = match (true) {\n            $name !== null && $name !== '' => self::key($name),\n            $bound !== null => self::key($bound),\n            default => null,\n        };\n\n        $messages = match (true) {\n            $error !== null => Arr::wrap($error),\n            $key !== null && $errorBag instanceof ViewErrorBag => self::messagesFor($errorBag->getBag($bag), $key),\n            default => [],\n        };\n\n        return new self(\n            $name,\n            app(ElementIds::class)->claim(\n                $id ?? ($key !== null ? self::idFrom($key) : $idPrefix.'-'.Str::random(6)),\n                explicit: $id !== null,\n            ),\n            $key,\n            array_values(array_filter($messages, static fn (mixed $message): bool => is_string($message) && $message !== '')),\n            $bound,\n        );\n    }\n\n    \/**\n     * The field's own messages, plus those Laravel files per item for a list of values: a 'tags.*' rule\n     * reports a bad second choice under tags.1, which a multiple select named tags must still show. Only\n     * numbered children count, so a field named address doesn't take errors meant for address[city].\n     *\n     * @return list<string>\n     *\/\n    private static function messagesFor(MessageBag $bag, string $key): array\n    {\n        $items = array_filter(\n            $bag->getMessages(),\n            static fn (string $name): bool => preg_match('\/^'.preg_quote($key, '\/').'\\.\\d+$\/', $name) === 1,\n            ARRAY_FILTER_USE_KEY,\n        );\n\n        return array_values(array_unique([...$bag->get($key), ...array_merge(...array_values($items))]));\n    }\n\n    \/**\n     * items[0][date] \u2192 items.0.date and tags[] \u2192 tags: the key Laravel files errors and old input under.\n     *\/\n    public static function key(string $name): string\n    {\n        return trim((string) preg_replace('\/\\[([^\\]]*)\\]\/', '.$1', $name), '.');\n    }\n\n    \/**\n     * The id a field named $name gets when it's the first of that name on the page, for links to it\n     * (the error summary). A later duplicate gets a -2 suffix, which links can't know about.\n     *\/\n    public static function idFor(string $name): string\n    {\n        return self::idFrom(self::key($name));\n    }\n\n    \/**\n     * The property a wire:model or x-model attribute (any modifiers) binds the field to; null without one.\n     *\/\n    public static function boundTo(ComponentAttributeBag $attributes): ?string\n    {\n        return array_values(self::binding($attributes))[0] ?? null;\n    }\n\n    private static function idFrom(string $key): string\n    {\n        return trim((string) preg_replace('\/[^A-Za-z0-9_-]+\/', '-', $key), '-');\n    }\n\n    public function hasError(): bool\n    {\n        return $this->errors !== [];\n    }\n\n    public function errorId(): string\n    {\n        return $this->id.'-error';\n    }\n\n    public function infoId(): string\n    {\n        return $this->id.'-info';\n    }\n\n    \/**\n     * Old input after a failed validation, falling back to the widget's value prop, or with none, to the bound Livewire\n     * property: a re-render then draws the field as it is, which Livewire morphs onto the page.\n     *\/\n    public function old(mixed $default = null): mixed\n    {\n        if ($default === null) {\n            [$found, $live] = $this->fromLivewire();\n            $default = $found ? $live : null;\n        }\n\n        return $this->key === null ? $default : old($this->key, $default);\n    }\n\n    \/**\n     * The bound property's value while Livewire renders the component that holds it: Livewire shares that component\n     * with every view as $__livewire. Livewire isn't a dependency; it's only looked for. [false, null] otherwise.\n     * $key reads inside it: a range bound to period reads period.start.\n     *\n     * @return array{0: bool, 1: mixed}\n     *\/\n    public function fromLivewire(?string $key = null): array\n    {\n        $component = $this->bound === null ? null : view()->shared('__livewire');\n\n        return is_object($component) ? [true, data_get($component, $key === null ? $this->bound : \"{$this->bound}.{$key}\")] : [false, null];\n    }\n\n    \/**\n     * The binding attribute as written (wire:model.live => period), to put on the inputs that carry the value: a range\n     * picker binds period.start and period.end with the same modifiers. Empty without one.\n     *\n     * @return array<string, string>\n     *\/\n    public static function binding(ComponentAttributeBag $attributes): array\n    {\n        foreach ($attributes->getAttributes() as $attribute => $value) {\n            if (is_string($value) && $value !== '' && (str_starts_with($attribute, 'wire:model') || str_starts_with($attribute, 'x-model'))) {\n                return [$attribute => $value];\n            }\n        }\n\n        return [];\n    }\n\n    \/**\n     * Whether a checkbox or switch renders ticked. An unticked box isn't in the request at all, so after a\n     * failed submit \"no old value\" means unticked, but only when that submit was this box's own form. A page\n     * with a second form (or a disabled box, which is never sent) would otherwise lose every `checked`.\n     *\n     * @param  bool  $alwaysSent  it has an unchecked-value, so its form always sends something under its name\n     * @param  mixed  $errorBag  the view's shared $errors\n     *\/\n    public function checked(mixed $value, bool $default, bool $disabled, bool $alwaysSent, mixed $errorBag, string $bag = 'default'): bool\n    {\n        \/\/ Bound to a Livewire property: that says, true\/false, or for a list of boxes, whether it holds this value.\n        [$found, $live] = $this->fromLivewire();\n        if ($found) {\n            return is_array($live) ? in_array(self::text($value), array_map(self::text(...), $live), true) : (bool) $live;\n        }\n        if ($this->key === null || $disabled || ! session()->hasOldInput()) {\n            return $default;\n        }\n\n        $old = old($this->key);\n        if ($old !== null) {\n            return in_array(self::text($value), array_map(self::text(...), Arr::wrap($old)), true);\n        }\n        \/\/ Nothing under this name, though this box always sends something: its form wasn't the one submitted.\n        if ($alwaysSent) {\n            return $default;\n        }\n        \/\/ A form with its own error bag: no errors in that bag means the failed submit was a different form.\n        if ($bag !== 'default') {\n            return $errorBag instanceof ViewErrorBag && $errorBag->getBag($bag)->isNotEmpty() ? false : $default;\n        }\n\n        \/\/ One form, or forms sharing the default bag: there's no telling them apart, so trust the old input.\n        return false;\n    }\n\n    \/** Values cast to backed enums (Plan::Pro) compare as their backing value. *\/\n    private static function text(mixed $value): string\n    {\n        return (string) ($value instanceof \\BackedEnum ? $value->value : $value);\n    }\n\n    \/**\n     * What the caller passed, minus `class` (that styles the wrapper) and the aria attributes\n     * this widget manages itself. Goes on the element that is actually submitted.\n     *\/\n    public function forwarded(ComponentAttributeBag $attributes): ComponentAttributeBag\n    {\n        return $attributes->except(['class', 'aria-invalid', 'aria-describedby']);\n    }\n\n    \/**\n     * aria-invalid plus one aria-describedby that joins the error or the hint, and the caller's own\n     * ids. Two separate aria-describedby attributes would make the browser silently drop one.\n     *\/\n    public function aria(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag\n    {\n        \/\/ Not both: the frame hides the hint while there's an error, and aria-describedby reads hidden text, so a\n        \/\/ screen reader would hear two messages that often say the same thing. resources\/js\/field brings the hint\n        \/\/ back when the error clears.\n        $describedBy = array_filter([\n            $this->hasError() ? $this->errorId() : null,\n            $hasInfo && ! $this->hasError() ? $this->infoId() : null,\n            $attributes->get('aria-describedby'),\n        ]);\n\n        return new ComponentAttributeBag([\n            'aria-invalid' => $this->hasError() ? 'true' : null,\n            'aria-describedby' => $describedBy === [] ? null : implode(' ', $describedBy),\n        ]);\n    }\n\n    \/**\n     * For a widget whose visible control isn't the submitted input (grouped number, phone): what belongs on the\n     * hidden input that carries the value. Which form it's in, and Livewire and Alpine bindings, go with the value.\n     *\/\n    public function bindings(ComponentAttributeBag $attributes): ComponentAttributeBag\n    {\n        return $attributes->filter(static fn (mixed $value, string $key): bool => self::isBinding($key));\n    }\n\n    \/**\n     * The other side of bindings(): everything else the caller passed (required, autofocus, aria-label,\n     * placeholder\u2026) goes on the visible control, where the browser validates it and screen readers hear it.\n     *\/\n    public function visibleAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag\n    {\n        return $this->controlAttributes($attributes->filter(static fn (mixed $value, string $key): bool => ! self::isBinding($key)), $hasInfo);\n    }\n\n    private static function isBinding(string $key): bool\n    {\n        return $key === 'form' || str_starts_with($key, 'wire:model') || str_starts_with($key, 'x-model');\n    }\n\n    \/**\n     * forwarded() and aria() together, for widgets whose visible control is also the submitted one.\n     *\/\n    public function controlAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag\n    {\n        return $this->forwarded($attributes)->merge($this->aria($attributes, $hasInfo)->getAttributes());\n    }\n}\n"},{"path":"app\/View\/Widget\/LocalDate.php","contents":"<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\View\\Widget;\n\nuse IntlCalendar;\nuse IntlDateFormatter;\nuse IntlDatePatternGenerator;\n\n\/**\n * How a locale writes and lays out dates, for the date pickers. The server renders the first paint with\n * this and the browser (Intl.DateTimeFormat) takes over, both asking ICU for the same \"year, short month,\n * day\" pattern, so a date reads \"Sep 26, 2026\" in en-US and \"26 Sept 2026\" in en-GB either way.\n * Falls back to plain ISO dates when PHP's intl extension is missing.\n *\/\nfinal class LocalDate\n{\n    \/** \"en_GB\" or \"en-GB\" \u2192 \"en-GB\", the form Intl in the browser expects. *\/\n    public static function locale(?string $locale = null): string\n    {\n        return str_replace('_', '-', $locale ?? app()->getLocale());\n    }\n\n    \/**\n     * A Y-m-d date the way the locale writes it: \"Sep 26, 2026\", \"26 Sept 2026\", \"2026\u5e749\u670826\u65e5\".\n     *\/\n    public static function format(string $iso, ?string $locale = null): string\n    {\n        $locale = self::locale($locale);\n        if (! class_exists(IntlDatePatternGenerator::class)) {\n            return $iso;\n        }\n\n        $pattern = (new IntlDatePatternGenerator($locale))->getBestPattern('yMMMd');\n        $formatter = new IntlDateFormatter($locale, IntlDateFormatter::NONE, IntlDateFormatter::NONE, 'UTC', IntlDateFormatter::GREGORIAN, $pattern ?: 'y-MM-dd');\n        $formatted = $formatter->format(new \\DateTimeImmutable($iso, new \\DateTimeZone('UTC')));\n\n        return is_string($formatted) ? $formatted : $iso;\n    }\n\n    \/**\n     * First day of the week, as JavaScript counts days (0 = Sunday \u2026 6 = Saturday): Monday in most of\n     * the world, Sunday in the US, Saturday in parts of the Middle East.\n     *\/\n    public static function firstDayOfWeek(?string $locale = null): int\n    {\n        if (! class_exists(IntlCalendar::class)) {\n            return 1; \/\/ ISO 8601\n        }\n\n        $calendar = IntlCalendar::createInstance(null, str_replace('-', '_', self::locale($locale)));\n        $first = $calendar instanceof IntlCalendar ? $calendar->getFirstDayOfWeek() : false;\n\n        \/\/ ICU counts 1 = Sunday \u2026 7 = Saturday.\n        return is_int($first) ? ($first - 1) % 7 : 1;\n    }\n}\n"},{"path":"app\/Rules\/LocalToday.php","contents":"<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Rules;\n\nuse Carbon\\CarbonImmutable;\n\n\/**\n * \"Today\" for date rules when users sit in many timezones and the app runs on UTC.\n *\n * The server's today can be a day behind a user's local today. Comparing against the\n * first timezone on Earth to reach a new day never rejects a genuine local today, and\n * still rejects anything that is in the future for everyone.\n *\/\nfinal class LocalToday\n{\n    \/\/ UTC+14: the earliest timezone, so its date is the latest \"today\" anywhere.\n    private const string EARLIEST_TIMEZONE = 'Pacific\/Kiritimati';\n\n    public static function latest(): CarbonImmutable\n    {\n        return CarbonImmutable::now(self::EARLIEST_TIMEZONE)->startOfDay();\n    }\n\n    \/** Parses a strict Y-m-d date, as submitted by the date picker; anything else is null. *\/\n    public static function parse(mixed $value): ?CarbonImmutable\n    {\n        if (! is_string($value) || preg_match('\/^(\\d{4})-(\\d{2})-(\\d{2})$\/', $value, $parts) !== 1) {\n            return null;\n        }\n\n        if (! checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1])) {\n            return null;\n        }\n\n        return CarbonImmutable::createFromFormat('!Y-m-d', $value, self::EARLIEST_TIMEZONE);\n    }\n}\n"},{"path":"app\/Rules\/MinimumAge.php","contents":"<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Rules;\n\nuse Closure;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\n\n\/**\n * Server-side twin of the date picker's `birthday` limit: the person must have reached\n * `$years` on their own local today.\n *\/\nfinal class MinimumAge implements ValidationRule\n{\n    public function __construct(private readonly int $years = 18) {}\n\n    public function validate(string $attribute, mixed $value, Closure $fail): void\n    {\n        $birthDate = LocalToday::parse($value);\n\n        if ($birthDate === null) {\n            $fail('The :attribute must be a valid date.');\n\n            return;\n        }\n\n        \/\/ Overflow matches the browser: someone born on 29 Feb comes of age on 1 Mar in non-leap years.\n        $latestAllowed = LocalToday::latest()->subYearsWithOverflow($this->years);\n\n        if ($birthDate->greaterThan($latestAllowed)) {\n            $fail(\"You must be at least {$this->years} years old.\");\n        }\n    }\n}\n"},{"path":"app\/Rules\/NotAfterToday.php","contents":"<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Rules;\n\nuse Closure;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\n\n\/**\n * Server-side twin of the date picker's default \"no future dates\" limit.\n * Use instead of 'before_or_equal:today', which uses the server's UTC date and can\n * reject a user's genuine local today.\n *\/\nfinal class NotAfterToday implements ValidationRule\n{\n    public function validate(string $attribute, mixed $value, Closure $fail): void\n    {\n        $date = LocalToday::parse($value);\n\n        if ($date === null) {\n            $fail('The :attribute must be a valid date.');\n\n            return;\n        }\n\n        if ($date->greaterThan(LocalToday::latest())) {\n            $fail('The :attribute may not be in the future.');\n        }\n    }\n}\n"}],"docs":"https:\/\/larawellui.wasmer.app\/components\/datepicker"}}