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>