Skip to main content

General

Date Range Picker code

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

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

@props([
    'startName' => 'start_date',
    'endName' => 'end_date',
    'start' => null,
    'end' => null,
    'placeholder' => 'Select a date range',
    'size' => 'base',
    'icon' => 'calendar-blank',
    'invalid' => false,
    'disabled' => false,
    'min' => null,
    'max' => null,
    'disabledDates' => [],
    'presets' => [],
    'clearable' => true,
])

@php
    /*
    | Date range picker. A read-only trigger that opens a two-month calendar in a
    | popover. Start/end are submitted as YYYY-MM-DD via hidden inputs.
    |
    | Usage:
    |   <x-ui.date-range-picker start-name="from" end-name="to" />
    |   <x-ui.date-range-picker :presets="[['label' => 'This week', 'start' => '2026-03-02', 'end' => '2026-03-08']]" />
    */
    $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 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,
        start: @js($start),
        end: @js($end),
        disabled: @js((bool) $disabled),
        min: @js($min),
        max: @js($max),
        disabledDates: @js(array_values($disabledDates)),
        presets: @js(array_values($presets)),
        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.start || this.end;
            if (seed) {
                const d = new Date(seed + 'T00:00:00');
                if (! isNaN(d)) { this.view = new Date(d.getFullYear(), d.getMonth(), 1); }
            }
        },
        fmt(d) {
            const m = String(d.getMonth() + 1).padStart(2, '0');
            const day = String(d.getDate()).padStart(2, '0');
            return `${d.getFullYear()}-${m}-${day}`;
        },
        pretty(iso) {
            if (! iso) { return null; }
            const d = new Date(iso + 'T00:00:00');
            return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
        },
        get label() {
            if (! this.start && ! this.end) { return ''; }
            if (this.start && ! this.end) { return this.pretty(this.start) + ' - ...'; }
            return this.pretty(this.start) + ' - ' + this.pretty(this.end);
        },
        monthGrid(offset) {
            const base = new Date(this.view.getFullYear(), this.view.getMonth() + offset, 1);
            const year = base.getFullYear();
            const month = base.getMonth();
            const first = new Date(year, month, 1).getDay();
            const total = new Date(year, 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(new Date(year, month, d)); }
            return { year, month, cells };
        },
        iso(date) { return this.fmt(date); },
        isStart(date) { return this.start === this.fmt(date); },
        isEnd(date) { return this.end === this.fmt(date); },
        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(date) { return this.isDisabledIso(this.fmt(date)); },
        inRange(date) {
            if (! this.start || ! this.end) { return false; }
            const iso = this.fmt(date);
            return iso > this.start && iso < this.end;
        },
        select(date) {
            const iso = this.fmt(date);
            if (this.isDisabledIso(iso)) { return; }
            if (! this.start || (this.start && this.end)) {
                this.start = iso;
                this.end = null;
            } else if (iso < this.start) {
                this.end = this.start;
                this.start = iso;
            } else {
                this.end = iso;
            }
        },
        applyPreset(preset) {
            if (this.isDisabledIso(preset.start) || this.isDisabledIso(preset.end)) { return; }
            this.start = preset.start;
            this.end = preset.end;
            const d = new Date(this.start + 'T00:00:00');
            if (! isNaN(d)) { this.view = new Date(d.getFullYear(), d.getMonth(), 1); }
            this.open = false;
        },
        clear() { this.start = null; this.end = null; },
        prev() { this.view = new Date(this.view.getFullYear(), this.view.getMonth() - 1, 1); },
        next() { this.view = new Date(this.view.getFullYear(), this.view.getMonth() + 1, 1); },
    }"
    @click.outside="open = false"
    @keydown.escape.window="open = false"
    class="relative"
>
    <input type="hidden" name="{{ $startName }}" :value="start ?? ''">
    <input type="hidden" name="{{ $endName }}" :value="end ?? ''">

    @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"
        :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-[min(38rem,calc(100vw-2rem))] 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 range"
    >
        <div class="flex items-center justify-between px-1">
            <button type="button" @click="prev()" 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>
            @if ($clearable)
                <button type="button" @click="clear()" class="text-xs font-medium text-gray-400 transition hover:text-brand-600 dark:hover:text-brand-400">Clear</button>
            @endif
            <button type="button" @click="next()" 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-3 grid gap-4 md:grid-cols-[auto_1fr]">
            <div x-show="presets.length" class="w-full space-y-1 border-b border-gray-100 pb-3 dark:border-gray-800 md:w-32 md:border-b-0 md:border-e md:pb-0 md:pe-3">
                <template x-for="preset in presets" :key="preset.label">
                    <button
                        type="button"
                        @click="applyPreset(preset)"
                        class="block w-full rounded-lg px-2.5 py-2 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
                        x-text="preset.label"
                    ></button>
                </template>
            </div>

            <div class="flex gap-6 overflow-x-auto">
                <template x-for="offset in [0, 1]" :key="offset">
                    <div class="w-60 shrink-0">
                        <p class="text-center text-sm font-semibold text-gray-900 dark:text-white">
                            <span x-text="months[monthGrid(offset).month]"></span>
                            <span x-text="monthGrid(offset).year"></span>
                        </p>

                        <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="(date, index) in monthGrid(offset).cells" :key="index">
                                <div :class="date && inRange(date) ? 'bg-brand-50 dark:bg-brand-950/40' : ''" class="rounded-lg">
                                    <button
                                        x-show="date"
                                        type="button"
                                        :disabled="date && isDisabled(date)"
                                        @click="select(date)"
                                        :class="{
                                            'bg-brand-600 text-white font-semibold': date && (isStart(date) || isEnd(date)),
                                            'text-brand-700 dark:text-brand-300': date && inRange(date),
                                            'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800': date && ! isStart(date) && ! isEnd(date) && ! inRange(date) && ! isDisabled(date),
                                            'cursor-not-allowed text-gray-300 line-through dark:text-gray-700': date && isDisabled(date),
                                        }"
                                        class="flex h-8 w-8 items-center justify-center rounded-lg text-sm transition"
                                        x-text="date ? date.getDate() : ''"
                                    ></button>
                                </div>
                            </template>
                        </div>
                    </div>
                </template>
            </div>
        </div>
    </div>
</div>