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>