Skip to main content

Forms

Calendar code

Copy the implementation or inspect every file that belongs to this component unit.

resources/views/components/ui/calendar.blade.php

@props([
    'name' => null,
    'value' => null,
    'min' => null,
    'max' => null,
    'disabledDates' => [],
    'clearable' => true,
])

@php
    /*
    | Standalone month calendar (always visible, unlike the date-picker's
    | popover). The selected date is submitted as YYYY-MM-DD via a hidden input
    | bound to `name`, and a `calendar-selected` window event is dispatched with
    | { date } whenever the selection changes.
    |
    | Supports min/max bounds and a list of disabled YYYY-MM-DD dates.
    |
    | Usage:
    |   <x-ui.calendar name="due_date" value="2026-03-04" min="2026-03-01" />
    |   <x-ui.calendar @calendar-selected.window="console.log($event.detail)" />
    */
@endphp

<div
    x-data="{
        selected: @js($value),
        focused: @js($value),
        min: @js($min),
        max: @js($max),
        disabledDates: @js(array_values($disabledDates)),
        view: new Date(),
        months: ['January','February','March','April','May','June','July','August','September','October','November','December'],
        days: ['Su','Mo','Tu','We','Th','Fr','Sa'],
        init() {
            const seed = this.selected || this.todayIso();
            const d = new Date(seed + 'T00:00:00');
            if (! isNaN(d)) { this.view = new Date(d.getFullYear(), d.getMonth(), 1); }
        },
        get year() { return this.view.getFullYear(); },
        get month() { return this.view.getMonth(); },
        get grid() {
            const first = new Date(this.year, this.month, 1).getDay();
            const total = new Date(this.year, this.month + 1, 0).getDate();
            const cells = [];
            for (let i = 0; i < first; i++) { cells.push(null); }
            for (let d = 1; d <= total; d++) { cells.push(d); }
            return cells;
        },
        todayIso() { return this.format(new Date()); },
        format(date) {
            const m = String(date.getMonth() + 1).padStart(2, '0');
            const d = String(date.getDate()).padStart(2, '0');
            return `${date.getFullYear()}-${m}-${d}`;
        },
        iso(day) { return this.format(new Date(this.year, this.month, day)); },
        isToday(day) { return this.iso(day) === this.todayIso(); },
        isSelected(day) { return this.selected === this.iso(day); },
        isFocused(day) { return this.focused === this.iso(day); },
        isDisabledIso(iso) {
            if (! iso) { return true; }
            if (this.min && iso < this.min) { return true; }
            if (this.max && iso > this.max) { return true; }
            return this.disabledDates.includes(iso);
        },
        isDisabled(day) { return this.isDisabledIso(this.iso(day)); },
        setViewFromIso(iso) {
            const d = new Date(iso + 'T00:00:00');
            if (! isNaN(d)) { this.view = new Date(d.getFullYear(), d.getMonth(), 1); }
        },
        select(day) {
            const iso = this.iso(day);
            if (this.isDisabledIso(iso)) { return; }
            this.selected = iso;
            this.focused = iso;
            this.$dispatch('calendar-selected', { date: iso });
        },
        clear() {
            this.selected = null;
            this.focused = null;
            this.$dispatch('calendar-selected', { date: null });
        },
        moveFocus(days) {
            let date = new Date((this.focused || this.selected || this.todayIso()) + 'T00:00:00');
            let attempts = 0;
            do {
                date.setDate(date.getDate() + days);
                attempts++;
            } while (this.isDisabledIso(this.format(date)) && attempts < 370);
            this.focused = this.format(date);
            this.setViewFromIso(this.focused);
        },
        selectFocused() {
            if (! this.focused || this.isDisabledIso(this.focused)) { return; }
            this.selected = this.focused;
            this.$dispatch('calendar-selected', { date: this.selected });
        },
        prevMonth() { this.view = new Date(this.year, this.month - 1, 1); },
        nextMonth() { this.view = new Date(this.year, this.month + 1, 1); },
    }"
    @keydown.arrow-left.prevent="moveFocus(-1)"
    @keydown.arrow-right.prevent="moveFocus(1)"
    @keydown.arrow-up.prevent="moveFocus(-7)"
    @keydown.arrow-down.prevent="moveFocus(7)"
    @keydown.enter.prevent="selectFocused()"
    {{ $attributes->merge(['class' => 'w-72 rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-gray-800 dark:bg-gray-900']) }}
    role="group"
    aria-label="Calendar"
>
    @if ($name)
        <input type="hidden" name="{{ $name }}" :value="selected ?? ''">
    @endif

    <div class="flex items-center justify-between px-1">
        <button type="button" @click="prevMonth()" class="rounded-lg p-1.5 text-gray-400 transition hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-gray-200" aria-label="Previous month">
            <x-ui.icon name="caret-left" weight="bold" size="sm" />
        </button>
        <span class="text-sm font-semibold text-gray-900 dark:text-white" aria-live="polite">
            <span x-text="months[month]"></span> <span x-text="year"></span>
        </span>
        <button type="button" @click="nextMonth()" class="rounded-lg p-1.5 text-gray-400 transition hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-gray-200" aria-label="Next month">
            <x-ui.icon name="caret-right" weight="bold" size="sm" />
        </button>
    </div>

    <div class="mt-2 grid grid-cols-7 gap-1 text-center">
        <template x-for="day in days" :key="day">
            <span class="py-1 text-xs font-medium text-gray-400 dark:text-gray-500" x-text="day"></span>
        </template>

        <template x-for="(day, index) in grid" :key="index">
            <div>
                <button
                    x-show="day"
                    type="button"
                    :disabled="day && isDisabled(day)"
                    @click="select(day)"
                    :aria-pressed="day && isSelected(day)"
                    :class="{
                        'bg-brand-600 text-white font-semibold': day && isSelected(day),
                        'text-brand-600 dark:text-brand-400 font-semibold ring-1 ring-brand-300 dark:ring-brand-800': day && isToday(day) && ! isSelected(day),
                        'ring-2 ring-brand-500 ring-offset-1 ring-offset-white dark:ring-offset-gray-900': day && isFocused(day) && ! isSelected(day),
                        'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800': day && ! isSelected(day) && ! isToday(day) && ! isDisabled(day),
                        'cursor-not-allowed text-gray-300 line-through dark:text-gray-700': day && isDisabled(day),
                    }"
                    class="flex h-9 w-9 items-center justify-center rounded-lg text-sm transition"
                    x-text="day"
                ></button>
            </div>
        </template>
    </div>

    @if ($clearable)
        <div class="mt-3 flex justify-end border-t border-gray-100 pt-3 dark:border-gray-800">
            <button type="button" @click="clear()" class="text-xs font-medium text-gray-400 transition hover:text-brand-600 dark:hover:text-brand-400">Clear date</button>
        </div>
    @endif
</div>