Skip to main content

General

Date Picker code

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

resources/views/components/ui/date-picker.blade.php

@props([
    'name' => null,
    'value' => null,
    'placeholder' => 'Select a date',
    'size' => 'base',
    'icon' => 'calendar-blank',
    'invalid' => false,
    'disabled' => false,
    'min' => null,
    'max' => null,
    'disabledDates' => [],
    'clearable' => true,
])

@php
    /*
    | Calendar date picker. Renders a read-only text trigger that opens a month
    | calendar in a popover. The selected date is submitted as YYYY-MM-DD via a
    | hidden input bound to `name`. Supports min/max and disabled date lists.
    |
    | Usage:
    |   <x-ui.date-picker name="due_date" min="2026-03-01" max="2026-03-31" />
    |   <x-ui.date-picker name="start" value="2026-03-04" />
    */
    $sizes = [
        'sm' => 'text-sm py-1.5',
        'base' => 'text-sm py-2.5',
        'lg' => 'text-base py-3',
    ];

    $ring = $invalid
        ? 'border-red-400 dark:border-red-500 focus:border-red-500 focus:ring-red-500'
        : 'border-gray-300 dark:border-gray-700 focus:border-brand-500 focus:ring-brand-500';

    $classes = implode(' ', [
        'block w-full rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 shadow-sm pe-3.5 text-left transition focus:ring-1 disabled:opacity-50 disabled:cursor-not-allowed',
        $icon ? 'ps-10' : 'ps-3.5',
        $sizes[$size] ?? $sizes['base'],
        $ring,
    ]);
@endphp

<div
    x-data="{
        open: false,
        selected: @js($value),
        focused: @js($value),
        disabled: @js((bool) $disabled),
        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 label() {
            if (! this.selected) { return ''; }
            const d = new Date(this.selected + 'T00:00:00');
            return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
        },
        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() {
            const d = new Date();
            return this.format(d);
        },
        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.open = false;
        },
        clear() {
            this.selected = null;
            this.focused = null;
            this.open = false;
        },
        openCalendar() {
            if (this.disabled) { return; }
            this.open = true;
            this.focused = this.selected || this.focused || this.todayIso();
            this.setViewFromIso(this.focused);
        },
        moveFocus(days) {
            if (! this.open) { this.openCalendar(); return; }
            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.open || ! this.focused || this.isDisabledIso(this.focused)) { return; }
            this.selected = this.focused;
            this.open = false;
        },
        prevMonth() { this.view = new Date(this.year, this.month - 1, 1); },
        nextMonth() { this.view = new Date(this.year, this.month + 1, 1); },
    }"
    @click.outside="open = false"
    @keydown.escape.window="open = false"
    @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()"
    class="relative"
>
    @if ($name)
        <input type="hidden" name="{{ $name }}" :value="selected ?? ''">
    @endif

    @if ($icon)
        <span class="pointer-events-none absolute inset-y-0 start-0 flex items-center ps-3 text-gray-400 dark:text-gray-500">
            <x-ui.icon :name="$icon" size="base" />
        </span>
    @endif

    <button
        type="button"
        @disabled($disabled)
        @if ($invalid) aria-invalid="true" @endif
        @click="open ? open = false : openCalendar()"
        :aria-expanded="open.toString()"
        aria-haspopup="dialog"
        class="{{ $classes }}"
    >
        <span x-show="label" x-text="label"></span>
        <span x-show="! label" class="text-gray-400 dark:text-gray-500">{{ $placeholder }}</span>
    </button>

    <div
        x-show="open" x-cloak
        x-transition:enter="transition ease-out duration-150"
        x-transition:enter-start="opacity-0 scale-95"
        x-transition:enter-end="opacity-100 scale-100"
        class="absolute z-50 mt-2 w-72 rounded-xl border border-gray-200 bg-white p-3 shadow-lg ring-1 ring-black/5 dark:border-gray-800 dark:bg-gray-900"
        role="dialog"
        aria-label="Choose date"
    >
        <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">
                <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>
</div>