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>