<x-widget.date-range-picker>
Date range picker
Start/end date picker with a multi-month popover. Submits two Y-m-d fields; validate them with NotAfterToday. Works with Livewire: wire:model binds an array's start and end.
php artisan larawell:add date-range-picker
- Also adds
- Button, Date picker, Icon
- Needs PHP extension
intl
Usage
Livewire
In a Livewire component, bind with wire:model to an array with start and end keys, the shape name="period" submits: public array $period = ['start' => null, 'end' => null]. Both ends update together (wire:model.live sends them in one request), the field shows the property after every render, and a range set or cleared in PHP shows too. Errors are read under period.start and period.end, as the rules below report them.
<div>
<x-widget.date-range-picker label="Report period" wire:model.live="period" :max="false" />
{{-- In the component:
$this->validate(['period.start' => ['required', 'date'], 'period.end' => ['required', 'date', 'after_or_equal:period.start']]); --}}
</div>
Examples
Report period
Submits report_period[start] and report_period[end] as Y-m-d. By default nothing after today can be picked.
Show code Hide code
<x-widget.date-range-picker name="report_period" label="Report period" class="max-w-sm" />
Booking
For bookings: start from today, no upper limit.
Check-in to check-out.
Show code Hide code
<x-widget.date-range-picker name="stay" label="Stay" min="today" :max="false" info="Check-in to check-out." class="max-w-sm" />
With error
Errors are read from the session automatically; pass error to set one yourself.
Leave can be at most 5 days.
Show code Hide code
<x-widget.date-range-picker name="leave" label="Leave" start="2026-09-01" end="2026-09-10" error="Leave can be at most 5 days." class="max-w-sm" />
Props
Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.
<x-widget.date-range-picker>
| Prop | Default | Description |
|---|---|---|
| name |
null
|
Submits the range as name[start] and name[end]. Optional with wire:model, which binds an array's start and end. |
| start-name |
null
|
Or name the start field yourself (e.g. from). |
| end-name |
null
|
And the end field (e.g. to). |
| id |
null
|
Defaults to one made from the name (or the wire:model property). |
| label |
null
|
Shown above the field, and its name for screen readers. |
| start |
null
|
The first date: YYYY-MM-DD or a date object. Old input wins; with wire:model and no start or end, the bound property. |
| end |
null
|
The last date, the same way. A range that ends before it starts is dropped. |
| placeholder |
null
|
Shown while no range is picked; "Choose dates" by default. |
| error |
null
|
An error message of your own; otherwise the validation error for the name, from the session or Livewire. |
| info |
null
|
A hint under the field. |
| bag |
'default'
|
Which error bag to read the error from. |
| disabled |
false
|
Greyed out: it can't be opened. |
| min |
null
|
The earliest date that can be picked: YYYY-MM-DD, a date object, or "today" (the visitor's today). |
| max |
'today'
|
The latest date: YYYY-MM-DD, a date object, "today" (the default), or false for no limit. |
| week-start |
null
|
The first day of the week, 0 (Sunday) to 6 (Saturday); defaults to the locale's. |
| months |
2
|
2 shows two months side by side where the screen is wide enough (one on phones); 1 always shows one. |
| locale |
null
|
The language for month and day names and the date shown (en, fr, ar…); defaults to the app's. |
| required-message |
'Choose a start and an end date.'
|
With required: the message when nothing is picked. |
Source
What larawell:add date-range-picker writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from
the components it also adds and Field, and the theme and base CSS.
resources/views/components/widget/date-range-picker/index.blade.php Show
@props([
// Submits the range as name[start] and name[end]. Optional with wire:model, which binds an array's start and
// end.
'name' => null,
// Or name the start field yourself (e.g. from).
'startName' => null,
// And the end field (e.g. to).
'endName' => null,
// Defaults to one made from the name (or the wire:model property).
'id' => null,
// Shown above the field, and its name for screen readers.
'label' => null,
// The first date: YYYY-MM-DD or a date object. Old input wins; with wire:model and no start or end, the bound
// property.
'start' => null,
// The last date, the same way. A range that ends before it starts is dropped.
'end' => null,
// Shown while no range is picked; "Choose dates" by default.
'placeholder' => null,
// An error message of your own; otherwise the validation error for the name, from the session or Livewire.
'error' => null,
// A hint under the field.
'info' => null,
// Which error bag to read the error from.
'bag' => 'default',
// Greyed out: it can't be opened.
'disabled' => false,
// The earliest date that can be picked: YYYY-MM-DD, a date object, or "today" (the visitor's today).
'min' => null,
// The latest date: YYYY-MM-DD, a date object, "today" (the default), or false for no limit.
'max' => 'today',
// The first day of the week, 0 (Sunday) to 6 (Saturday); defaults to the locale's.
'weekStart' => null,
// 2 shows two months side by side where the screen is wide enough (one on phones); 1 always shows one.
'months' => 2,
// The language for month and day names and the date shown (en, fr, ar…); defaults to the app's.
'locale' => null,
// With required: the message when nothing is picked.
'requiredMessage' => 'Choose a start and an end date.',
])
@php
$toIso = static function (mixed $date): ?string {
if ($date instanceof \DateTimeInterface) {
return $date->format('Y-m-d');
}
if (is_string($date) && preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $parts) === 1
&& checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1])) {
return $date;
}
return null;
};
// name="stay" submits stay[start] and stay[end]; start-name / end-name override either (e.g. flat "from" / "to").
$startName ??= $name !== null ? "{$name}[start]" : null;
$endName ??= $name !== null ? "{$name}[end]" : null;
// wire:model="period" (or x-model) binds an array, as name="period" submits period[start] and period[end]: the
// start input binds period.start and the end input period.end, with the same modifiers (wire:model.live …).
$binding = \App\View\Widget\FormField::binding($attributes);
$bindingAttribute = array_key_first($binding);
$bound = $binding[$bindingAttribute] ?? null;
$startKey = match (true) {
$startName !== null => \App\View\Widget\FormField::key($startName),
$bound !== null => "{$bound}.start",
default => null,
};
$endKey = match (true) {
$endName !== null => \App\View\Widget\FormField::key($endName),
$bound !== null => "{$bound}.end",
default => null,
};
// Messages filed under the range itself ("stay") or either end ("stay.start", "stay.end") all show
// under the one field. Validate with the same rules as the datepicker, e.g.
// 'stay.start' => ['required', new NotAfterToday], 'stay.end' => ['required', new NotAfterToday, 'after_or_equal:stay.start'].
$bagErrors = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors->getBag($bag) : null;
$error ??= $bagErrors
? array_values(array_unique(array_merge(...array_map(
static fn (?string $key): array => $key !== null ? $bagErrors->get($key) : [],
[$name !== null ? \App\View\Widget\FormField::key($name) : null, $startKey, $endKey],
))))
: null;
// Same limits as the datepicker: "today" is the user's local day; :max="false" removes the limit.
$max = match (true) {
$max === 'today' => 'today',
$max === false => null,
default => $toIso($max),
};
$min = $min === 'today' ? 'today' : $toIso($min);
$field = \App\View\Widget\FormField::make($name ?? $startName, $id, null, $error ?: null, $bag, 'date-range', attributes: $attributes);
$id = $field->id;
// Without start and end props, the bound Livewire property (see FormField::fromLivewire).
if ($start === null && $end === null && $bound !== null) {
$start = $field->fromLivewire('start')[1];
$end = $field->fromLivewire('end')[1];
}
$start = $toIso($startKey !== null ? old($startKey, $start) : $start);
$end = $toIso($endKey !== null ? old($endKey, $end) : $end);
// A half or back-to-front range from old input can't be shown as a range: start over.
if ($start === null || $end === null || $end < $start) {
[$start, $end] = [null, null];
}
// Dates read the way the locale writes them. The browser redraws the range with Intl's formatRange,
// which also shortens it naturally ("Sep 26 – Oct 3, 2026").
$locale = \App\View\Widget\LocalDate::locale($locale);
$weekStart ??= \App\View\Widget\LocalDate::firstDayOfWeek($locale);
$placeholder ??= 'Choose dates';
$shown = $start ? \App\View\Widget\LocalDate::format($start, $locale).' – '.\App\View\Widget\LocalDate::format($end, $locale) : null;
// The month and year selects draw their own arrow (appearance-none hides the browser's, which sits against the
// focus ring), with room for it on the end.
$control = 'hover:bg-field shrink-0 cursor-pointer appearance-none rounded-md bg-transparent py-1 ps-1 pe-5 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-primary';
$nav = 'enabled:hover:bg-field grid size-7 shrink-0 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:opacity-30';
@endphp
{{--
The datepicker's range sibling: same frame, limits and keyboard, but two hidden inputs. `class` and
`form` go where they belong (wrapper / both inputs); any other attribute goes on the wrapper, since
neither input alone is "the" control.
--}}
{{-- The button can't be required or aria-required, so the label says it for screen readers, and resources/js/widget/field
stops an empty submit (the value is in hidden inputs, which the browser never validates). --}}
<x-widget.field
:required="$attributes->has('required')"
:announce-required="true"
:data-required-message="$attributes->has('required') && ! $disabled ? $requiredMessage : false"
data-date-range
data-min="{{ $min }}"
data-max="{{ $max }}"
data-week-start="{{ $weekStart }}"
data-locale="{{ $locale }}"
data-months="{{ (int) $months === 1 ? 1 : 2 }}"
:id="$id"
:label="$label"
:error="$field->errors"
:info="$info"
:disabled="$disabled"
box="h-12 items-center"
:class="$attributes->get('class')"
{{ $attributes->except(['class', 'form', 'required', 'aria-invalid', 'aria-describedby', ...array_keys($binding)]) }}
>
<button
type="button"
id="{{ $id }}"
popovertarget="{{ $id }}-calendar"
aria-haspopup="dialog"
aria-expanded="false"
{{-- A <label> would otherwise be the button's whole name, and the chosen range inside it would never be read. --}}
@if ($label) aria-labelledby="{{ $id }}-label {{ $id }}-display" @endif
{{ $field->aria($attributes, (bool) $info) }}
@disabled($disabled)
data-date-range-trigger
class="flex h-full w-full min-w-0 items-center justify-between gap-2.5 px-5 text-start whitespace-nowrap outline-none select-none enabled:cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
>
<span
id="{{ $id }}-display"
data-date-range-display
data-placeholder="{{ $placeholder }}"
@if (! $shown) data-empty @endif
class="text-style-2 data-empty:text-muted group-data-invalid/field:data-empty:text-error truncate"
>{{ $shown ?? $placeholder }}</span>
<x-widget.icon name="calendar-range" @class(['size-5 shrink-0', 'opacity-40' => $disabled]) />
</button>
<input type="hidden" @if ($startName) name="{{ $startName }}" @endif value="{{ $start }}" data-date-range-start @if ($bound) {{ $bindingAttribute }}="{{ $bound }}.start" @endif {{ $attributes->only(['form']) }}>
<input type="hidden" @if ($endName) name="{{ $endName }}" @endif value="{{ $end }}" data-date-range-end @if ($bound) {{ $bindingAttribute }}="{{ $bound }}.end" @endif {{ $attributes->only(['form']) }}>
{{-- wire:ignore: the script builds everything in here (the month and year lists, the days), and a Livewire render
would empty it again, since the server sends it empty. The value lives outside, on the hidden input(s). --}}
<div
wire:ignore
id="{{ $id }}-calendar"
popover
role="dialog"
aria-label="{{ $label ?? 'Choose dates' }}"
data-date-range-popover
class="border-line text-foreground bg-surface fixed inset-auto m-0 overflow-y-auto overscroll-contain rounded-md border p-3 shadow-lg"
>
{{-- The selects and arrows drive the first month shown; a second month, when there is room, follows it. --}}
<div class="mb-2 flex items-center justify-between">
<button type="button" data-date-range-prev aria-label="Previous month" class="{{ $nav }}">
<x-widget.icon name="chevron-left" class="size-4 rtl:rotate-180" />
</button>
<div class="flex shrink-0 gap-1">
<span class="relative inline-flex shrink-0">
<select id="{{ $id }}-month" data-date-range-month aria-label="Month" class="{{ $control }}"></select>
<x-widget.icon name="chevron-down" class="text-foreground/60 pointer-events-none absolute end-1.5 top-1/2 size-3 -translate-y-1/2" />
</span>
<span class="relative inline-flex shrink-0">
<select id="{{ $id }}-year" data-date-range-year aria-label="Year" class="{{ $control }}"></select>
<x-widget.icon name="chevron-down" class="text-foreground/60 pointer-events-none absolute end-1.5 top-1/2 size-3 -translate-y-1/2" />
</span>
</div>
<button type="button" data-date-range-next aria-label="Next month" class="{{ $nav }}">
<x-widget.icon name="chevron-right" class="size-4 rtl:rotate-180" />
</button>
</div>
{{-- Month grids are built by resources/js/widget/date-range-picker. --}}
<div data-date-range-months class="grid gap-6"></div>
<div class="border-line mt-3 flex items-center justify-between gap-3 border-t pt-3">
<p data-date-range-status aria-live="polite" class="text-muted min-w-0 text-xs"></p>
<x-widget.button variant="link" size="sm" data-date-range-clear>Clear</x-widget.button>
</div>
</div>
</x-widget.field>
resources/js/widget/date-range-picker/index.js Show
// Drives <x-widget.date-range-picker>: the first click sets the start, the second the end (a click
// before the start restarts from there). Nothing is submitted until both ends are chosen; closing
// half-way keeps the previous range.
import { closeIfOutOfView } from '../field';
import { addDays, addMonths, alignedLeft, daysInMonth, horizontalStep, localeTools, MIN_WIDTH, parseIso, POPOVER_GAP, toIso, VIEWPORT_EDGE } from '../datepicker';
const DAY_BASE = 'mx-auto grid aspect-square w-full max-w-11 place-items-center rounded-full text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-primary';
const DAY_TONE = {
endpoint: 'bg-primary font-semibold text-on-primary',
disabled: 'cursor-not-allowed text-muted/50',
inRange: 'text-foreground hover:bg-primary/20',
today: 'font-semibold text-primary hover:bg-field',
default: 'text-foreground hover:bg-field',
};
const TODAY_MARK = 'ring-1 ring-inset ring-primary';
// The band behind the range sits on the cells, so it runs unbroken between the round day buttons;
// on the two ends it covers only the inner half. bg-clip-content + py leaves a gap between weeks.
const CELL_BASE = 'bg-clip-content py-0.5';
const BAND = {
middle: 'bg-primary/10',
// In RTL the range runs right to left, so each end's half-band flips.
start: 'bg-linear-to-r rtl:bg-linear-to-l from-transparent from-50% to-primary/10 to-50%',
end: 'bg-linear-to-l rtl:bg-linear-to-r from-transparent from-50% to-primary/10 to-50%',
};
// Two months need two calendars' width; on a narrower screen one is shown.
const TWO_MONTH_WIDTH = MIN_WIDTH * 2;
const firstOfMonth = (d) => new Date(d.getFullYear(), d.getMonth(), 1);
const monthsBetween = (from, to) => (to.getFullYear() - from.getFullYear()) * 12 + to.getMonth() - from.getMonth();
const daysBetween = (from, to) => Math.round((to - from) / 86_400_000);
// Set up once per element. Not a data attribute: Livewire's morph removes attributes the server didn't render.
const ready = new WeakSet();
function initDateRange(root) {
if (ready.has(root)) {
return;
}
ready.add(root);
const trigger = root.querySelector('[data-date-range-trigger]');
const display = root.querySelector('[data-date-range-display]');
const startInput = root.querySelector('[data-date-range-start]');
const endInput = root.querySelector('[data-date-range-end]');
const popover = root.querySelector('[data-date-range-popover]');
const monthSelect = popover.querySelector('[data-date-range-month]');
const yearSelect = popover.querySelector('[data-date-range-year]');
const prevButton = popover.querySelector('[data-date-range-prev]');
const nextButton = popover.querySelector('[data-date-range-next]');
const monthsEl = popover.querySelector('[data-date-range-months]');
const status = popover.querySelector('[data-date-range-status]');
const clearButton = popover.querySelector('[data-date-range-clear]');
const tools = localeTools(root);
// Screen-reader labels and the status line. Change the wording here.
const text = { start: 'start date', end: 'end date', inRange: 'in range', chooseStart: 'Choose the start date', chooseEnd: 'From :date, choose the end date' };
// "8 days" in the locale's own words and plural rules (Arabic has six forms), with nothing to translate.
const dayCount = new Intl.NumberFormat(tools.locale, { style: 'unit', unit: 'day', unitDisplay: 'long' });
const weekStart = Number(root.dataset.weekStart || 0);
// Worked out again each time the calendar opens, so a page left open past midnight moves "today" on.
let today;
let min;
let max;
const parseLimit = (value) => (value === 'today' ? today : parseIso(value));
function refreshLimits() {
const now = new Date();
const day = new Date(now.getFullYear(), now.getMonth(), now.getDate());
if (today && day.getTime() === today.getTime()) {
return;
}
today = day;
min = parseLimit(root.dataset.min) ?? new Date(today.getFullYear() - 100, 0, 1);
max = parseLimit(root.dataset.max) ?? new Date(today.getFullYear() + 10, 11, 31);
yearSelect.replaceChildren();
for (let year = max.getFullYear(); year >= min.getFullYear(); year--) {
yearSelect.add(new Option(tools.number(year), String(year)));
}
}
refreshLimits();
const isOutOfRange = (d) => d < min || d > max;
const clamp = (d) => (d > max ? max : d < min ? min : d);
const weekdayOffset = (d) => (d.getDay() - weekStart + 7) % 7;
let committed = { start: parseIso(startInput.value), end: parseIso(endInput.value) };
let draft = { ...committed };
let hover = null;
let focused = today;
let view = firstOfMonth(today);
let count = 1;
for (let month = 0; month < 12; month++) {
monthSelect.add(new Option(tools.monthName.format(new Date(2000, month, 1)), String(month)));
}
const monthsToShow = () => (root.dataset.months === '2' && window.innerWidth - VIEWPORT_EDGE * 2 >= TWO_MONTH_WIDTH ? 2 : 1);
const pickingEnd = () => draft.start && !draft.end;
// Keep the focused day on screen, and don't open onto a month that lies wholly past the limit:
// with max=today the current month is shown on the right, not on the left beside a blank one.
function placeView() {
if (focused < view) {
view = firstOfMonth(focused);
} else if (monthsBetween(view, focused) >= count) {
view = addMonths(firstOfMonth(focused), 1 - count);
}
if (count === 2 && new Date(view.getFullYear(), view.getMonth() + 1, 1) > max && new Date(view.getFullYear(), view.getMonth(), 0) >= min) {
view = addMonths(view, -1);
}
}
// Structure: one table per month shown. Colours and state are applied by paint().
function build() {
count = monthsToShow();
placeView();
const year = view.getFullYear();
const month = view.getMonth();
yearSelect.value = String(year);
monthSelect.value = String(month);
for (const option of monthSelect.options) {
const m = Number(option.value);
option.disabled = new Date(year, m + 1, 0) < min || new Date(year, m, 1) > max;
}
prevButton.disabled = new Date(year, month, 0) < min;
nextButton.disabled = new Date(year, month + count, 1) > max;
monthsEl.classList.toggle('grid-cols-2', count === 2);
monthsEl.replaceChildren(...Array.from({ length: count }, (_, i) => monthTable(addMonths(view, i))));
paint();
}
function monthTable(first) {
const table = document.createElement('table');
table.className = 'w-full table-fixed border-collapse text-center';
// With one month the selects above already name it; with two, each grid is captioned.
const caption = table.createCaption();
caption.textContent = tools.monthCaption.format(first);
caption.className = count === 2 ? 'mb-1 text-sm font-medium' : 'sr-only';
const headRow = table.createTHead().insertRow();
for (let i = 0; i < 7; i++) {
// 1 Jan 2023 was a Sunday, so offsetting from it lists weekdays in order.
const day = new Date(2023, 0, 1 + ((weekStart + i) % 7));
const cell = document.createElement('th');
cell.scope = 'col';
cell.abbr = tools.longWeekday.format(day);
cell.textContent = tools.shortWeekday.format(day);
cell.className = 'pb-1 text-xs font-medium text-muted';
headRow.append(cell);
}
const body = table.createTBody();
let day = addDays(first, -weekdayOffset(first));
// Six weeks always, so the height never jumps. Days of the neighbouring months stay blank:
// with two months side by side they would otherwise appear twice.
for (let week = 0; week < 6; week++) {
const row = body.insertRow();
for (let i = 0; i < 7; i++, day = addDays(day, 1)) {
const cell = row.insertCell();
cell.className = CELL_BASE;
if (day.getMonth() !== first.getMonth()) {
continue;
}
const button = document.createElement('button');
button.type = 'button';
button.textContent = tools.number(day.getDate());
button.dataset.date = toIso(day);
button.disabled = isOutOfRange(day);
cell.append(button);
}
}
return table;
}
// State: endpoints, the band between them (following the pointer or focus while the end is being
// chosen), today, the roving tabindex and the labels. Runs on every hover, so no rebuilding here.
function paint() {
const start = draft.start ? toIso(draft.start) : null;
const preview = pickingEnd() && hover && hover >= draft.start ? hover : null;
const end = draft.end ? toIso(draft.end) : preview ? toIso(preview) : null;
const committedEnd = draft.end ? toIso(draft.end) : null;
const focusedIso = toIso(focused);
const todayIso = toIso(today);
for (const button of monthsEl.querySelectorAll('button[data-date]')) {
const iso = button.dataset.date;
const isEndpoint = iso === start || iso === end;
const inRange = start && end && iso > start && iso < end;
const tone = isEndpoint ? 'endpoint' : button.disabled ? 'disabled' : inRange ? 'inRange' : iso === todayIso ? 'today' : 'default';
const band = !start || !end || start === end ? '' : inRange ? BAND.middle : iso === start ? BAND.start : iso === end ? BAND.end : '';
button.className = `${DAY_BASE} ${DAY_TONE[tone]}${iso === todayIso ? ` ${TODAY_MARK}` : ''}`;
button.parentElement.className = `${CELL_BASE} ${band}`;
button.tabIndex = iso === focusedIso ? 0 : -1;
// The labels describe the chosen range only, not the hover preview.
const role = iso === start ? text.start : iso === committedEnd ? text.end : committedEnd && iso > start && iso < committedEnd ? text.inRange : null;
button.setAttribute('aria-label', tools.dayLabel.format(parseIso(iso)) + (role ? `, ${role}` : ''));
button.setAttribute('aria-pressed', String(iso === start || iso === committedEnd));
if (iso === todayIso) {
button.setAttribute('aria-current', 'date');
}
}
status.textContent = draft.start && draft.end
? `${tools.formatRange(draft.start, draft.end)} · ${dayCount.format(daysBetween(draft.start, draft.end) + 1)}`
: draft.start ? text.chooseEnd.replace(':date', tools.format(draft.start)) : text.chooseStart;
clearButton.disabled = !draft.start;
}
const focusDay = () => monthsEl.querySelector('button[tabindex="0"]')?.focus();
function moveFocus(date) {
focused = clamp(date);
if (pickingEnd()) {
hover = focused;
}
const visible = focused >= view && monthsBetween(view, focused) < count;
visible ? paint() : build();
focusDay();
}
function changeView(target, sourceButton) {
view = firstOfMonth(target);
focused = clamp(new Date(view.getFullYear(), view.getMonth(), Math.min(focused.getDate(), daysInMonth(view.getFullYear(), view.getMonth()))));
build();
// A nav button that just became disabled drops focus to <body>; keep keyboard users in the grid.
if (sourceButton?.disabled) {
focusDay();
}
}
// formatRange already writes the shortest natural form for the locale ("Sep 26 – Oct 3, 2026"). If even
// that is cut off in a narrow field, the tooltip keeps the whole range.
function fitDisplay() {
const { start, end } = committed;
display.toggleAttribute('data-empty', !start);
display.textContent = start ? tools.formatRange(start, end) : display.dataset.placeholder;
syncTitle();
}
// Only reads the layout (a title doesn't change it), so a page of pickers observed at load measures once,
// instead of each one rewriting its text and forcing a layout before the next can measure.
function syncTitle() {
committed.start && display.scrollWidth > display.clientWidth ? display.setAttribute('title', display.textContent) : display.removeAttribute('title');
}
function commit() {
committed = { ...draft };
startInput.value = committed.start ? toIso(committed.start) : '';
endInput.value = committed.end ? toIso(committed.end) : '';
fitDisplay();
// Both events on both inputs: x-model / wire:model listen for `input`, plain forms for `change`.
for (const input of [startInput, endInput]) {
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
}
function pick(date) {
focused = date;
if (pickingEnd() && date >= draft.start) {
draft.end = date;
commit();
popover.hidePopover();
trigger.focus();
return;
}
draft = { start: date, end: null };
hover = date;
paint();
focusDay();
}
function position() {
if (closeIfOutOfView(trigger, popover)) {
return;
}
const box = trigger.getBoundingClientRect();
const wanted = count === 2 ? TWO_MONTH_WIDTH : MIN_WIDTH;
const fit = Math.min(Math.max(box.width, wanted), window.innerWidth - VIEWPORT_EDGE * 2);
popover.style.width = `${fit}px`;
popover.style.maxHeight = `${window.innerHeight - VIEWPORT_EDGE * 2}px`;
const { offsetWidth: width, offsetHeight: height } = popover;
const below = box.bottom + POPOVER_GAP;
const above = box.top - POPOVER_GAP - height;
const top = below + height <= window.innerHeight - VIEWPORT_EDGE ? below
: above >= VIEWPORT_EDGE ? above
: Math.max(VIEWPORT_EDGE, window.innerHeight - VIEWPORT_EDGE - height);
popover.style.top = `${top}px`;
popover.style.left = `${alignedLeft(box, width, tools.rtl)}px`;
}
// Turning a phone, or resizing the window, can change how many months fit.
function onResize() {
if (monthsToShow() !== count) {
// build() replaces every day button; put focus back on the day it was on, not on <body>.
const hadFocus = popover.contains(document.activeElement);
build();
if (hadFocus) {
focusDay();
}
}
position();
}
const KEY_MOVES = {
ArrowLeft: (d) => addDays(d, horizontalStep('ArrowLeft', tools.rtl)),
ArrowRight: (d) => addDays(d, horizontalStep('ArrowRight', tools.rtl)),
ArrowUp: (d) => addDays(d, -7),
ArrowDown: (d) => addDays(d, 7),
Home: (d) => addDays(d, -weekdayOffset(d)),
End: (d) => addDays(d, 6 - weekdayOffset(d)),
PageUp: (d, event) => addMonths(d, event.shiftKey ? -12 : -1),
PageDown: (d, event) => addMonths(d, event.shiftKey ? 12 : 1),
};
monthsEl.addEventListener('keydown', (event) => {
const move = KEY_MOVES[event.key];
if (move && event.target.matches('button[data-date]')) {
event.preventDefault();
moveFocus(move(focused, event));
}
});
monthsEl.addEventListener('click', (event) => {
const button = event.target.closest('button[data-date]');
if (button && !button.disabled) {
pick(parseIso(button.dataset.date));
}
});
// Preview the range under the pointer while the end is being chosen.
monthsEl.addEventListener('mouseover', (event) => {
const button = event.target.closest('button[data-date]:enabled');
const date = button ? parseIso(button.dataset.date) : null;
if (pickingEnd() && date && toIso(date) !== (hover && toIso(hover))) {
hover = date;
paint();
}
});
monthsEl.addEventListener('mouseleave', () => {
if (pickingEnd() && hover) {
hover = null;
paint();
}
});
prevButton.addEventListener('click', () => changeView(addMonths(view, -1), prevButton));
nextButton.addEventListener('click', () => changeView(addMonths(view, 1), nextButton));
const onViewSelect = () => changeView(new Date(Number(yearSelect.value), Number(monthSelect.value), 1));
monthSelect.addEventListener('change', onViewSelect);
yearSelect.addEventListener('change', onViewSelect);
clearButton.addEventListener('click', () => {
draft = { start: null, end: null };
hover = null;
commit();
paint();
focusDay();
});
// The field's width changes with the layout (grid columns, rotation), so re-fit whenever it does.
new ResizeObserver(syncTitle).observe(trigger);
popover.addEventListener('beforetoggle', (event) => {
if (event.newState !== 'open') {
return;
}
refreshLimits();
// The range as it is now (a Livewire render or another script may have changed it), and from there: an
// unfinished pick from last time is dropped.
committed = { start: parseIso(startInput.value), end: parseIso(endInput.value) };
draft = { ...committed };
hover = null;
focused = clamp(committed.start ?? today);
view = firstOfMonth(focused);
build();
// Hidden until positioned, otherwise it flashes at the popover default (screen centre).
popover.style.visibility = 'hidden';
});
popover.addEventListener('toggle', (event) => {
const open = event.newState === 'open';
trigger.setAttribute('aria-expanded', String(open));
if (!open) {
window.removeEventListener('scroll', position, true);
window.removeEventListener('resize', onResize);
return;
}
position();
popover.style.visibility = '';
focusDay();
// Capture phase so scrolling any ancestor container also repositions it.
window.addEventListener('scroll', position, true);
window.addEventListener('resize', onResize);
});
}
export function initDateRangePickers(scope = document) {
scope.querySelectorAll('[data-date-range]').forEach(initDateRange);
}
initDateRangePickers();
// Range pickers added to the page later (a Livewire render or wire:navigate, fetched HTML) set themselves up.
new MutationObserver((records) => {
for (const node of records.flatMap((record) => [...record.addedNodes])) {
if (node instanceof Element) {
(node.matches('[data-date-range]') ? [node] : node.querySelectorAll('[data-date-range]')).forEach(initDateRange);
}
}
}).observe(document.documentElement, { childList: true, subtree: true });
app/View/Widget/ElementIds.php Show
<?php
declare(strict_types=1);
namespace App\View\Widget;
use Illuminate\Container\Attributes\Scoped;
use LogicException;
/**
* Keeps element ids unique within one response, so labels, aria-describedby and #fragments
* always point at the right element. Scoped: a fresh set per request (and per Octane/queue cycle).
*/
#[Scoped]
final class ElementIds
{
/** @var array<string, true> */
private array $used = [];
/**
* Reserves an id for this response.
*
* A derived id (built from a field name) gets a -2, -3 … suffix when already taken. An explicit
* id is one the caller chose and may reference from JS or CSS, so silently renaming it would
* break that reference; a duplicate throws instead, which surfaces in development and tests.
*/
public function claim(string $id, bool $explicit = false): string
{
if (!isset($this->used[$id])) {
return $this->reserve($id);
}
if ($explicit) {
throw new LogicException("Duplicate element id [{$id}] on this page. Give one of the widgets a different id or name.");
}
$suffix = 2;
while (isset($this->used["{$id}-{$suffix}"])) {
$suffix++;
}
return $this->reserve("{$id}-{$suffix}");
}
private function reserve(string $id): string
{
$this->used[$id] = true;
return $id;
}
}
app/View/Widget/FormField.php Show
<?php
declare(strict_types=1);
namespace App\View\Widget;
use Illuminate\Contracts\Support\MessageBag;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\ViewErrorBag;
use Illuminate\View\ComponentAttributeBag;
/**
* Server-side state of one form widget: its dot-notation key, a valid id, its validation
* messages and its old input. Every <x-widget.input.*> and the date picker resolve through
* here, so array names (items[0][date]) and named error bags behave the same everywhere.
*/
final class FormField
{
/**
* @param list<string> $errors
*/
private function __construct(
public readonly ?string $name,
public readonly string $id,
public readonly ?string $key,
public readonly array $errors,
// The property a wire:model or x-model attribute binds it to, if any.
public readonly ?string $bound = null,
) {}
/**
* @param mixed $errorBag the view's shared $errors (absent outside a web request)
* @param string|array<int, string>|null $error an explicit message from the caller; overrides the bag
*/
public static function make(
?string $name,
?string $id,
mixed $errorBag,
string|array|null $error = null,
string $bag = 'default',
string $idPrefix = 'field',
?ComponentAttributeBag $attributes = null,
): self {
// With no name, a Livewire or Alpine binding (wire:model="email") names the field. Its errors are filed under
// that property, and its id stays the same on every render, which Livewire's morph needs to keep the element
// (it matches elements by id: a random one makes it swap in a new field, dropping focus mid-typing).
$bound = $attributes === null ? null : self::boundTo($attributes);
$key = match (true) {
$name !== null && $name !== '' => self::key($name),
$bound !== null => self::key($bound),
default => null,
};
$messages = match (true) {
$error !== null => Arr::wrap($error),
$key !== null && $errorBag instanceof ViewErrorBag => self::messagesFor($errorBag->getBag($bag), $key),
default => [],
};
return new self(
$name,
app(ElementIds::class)->claim(
$id ?? ($key !== null ? self::idFrom($key) : $idPrefix.'-'.Str::random(6)),
explicit: $id !== null,
),
$key,
array_values(array_filter($messages, static fn (mixed $message): bool => is_string($message) && $message !== '')),
$bound,
);
}
/**
* The field's own messages, plus those Laravel files per item for a list of values: a 'tags.*' rule
* reports a bad second choice under tags.1, which a multiple select named tags must still show. Only
* numbered children count, so a field named address doesn't take errors meant for address[city].
*
* @return list<string>
*/
private static function messagesFor(MessageBag $bag, string $key): array
{
$items = array_filter(
$bag->getMessages(),
static fn (string $name): bool => preg_match('/^'.preg_quote($key, '/').'\.\d+$/', $name) === 1,
ARRAY_FILTER_USE_KEY,
);
return array_values(array_unique([...$bag->get($key), ...array_merge(...array_values($items))]));
}
/**
* items[0][date] → items.0.date and tags[] → tags: the key Laravel files errors and old input under.
*/
public static function key(string $name): string
{
return trim((string) preg_replace('/\[([^\]]*)\]/', '.$1', $name), '.');
}
/**
* The id a field named $name gets when it's the first of that name on the page, for links to it
* (the error summary). A later duplicate gets a -2 suffix, which links can't know about.
*/
public static function idFor(string $name): string
{
return self::idFrom(self::key($name));
}
/**
* The property a wire:model or x-model attribute (any modifiers) binds the field to; null without one.
*/
public static function boundTo(ComponentAttributeBag $attributes): ?string
{
return array_values(self::binding($attributes))[0] ?? null;
}
private static function idFrom(string $key): string
{
return trim((string) preg_replace('/[^A-Za-z0-9_-]+/', '-', $key), '-');
}
public function hasError(): bool
{
return $this->errors !== [];
}
public function errorId(): string
{
return $this->id.'-error';
}
public function infoId(): string
{
return $this->id.'-info';
}
/**
* Old input after a failed validation, falling back to the widget's value prop, or with none, to the bound Livewire
* property: a re-render then draws the field as it is, which Livewire morphs onto the page.
*/
public function old(mixed $default = null): mixed
{
if ($default === null) {
[$found, $live] = $this->fromLivewire();
$default = $found ? $live : null;
}
return $this->key === null ? $default : old($this->key, $default);
}
/**
* The bound property's value while Livewire renders the component that holds it: Livewire shares that component
* with every view as $__livewire. Livewire isn't a dependency; it's only looked for. [false, null] otherwise.
* $key reads inside it: a range bound to period reads period.start.
*
* @return array{0: bool, 1: mixed}
*/
public function fromLivewire(?string $key = null): array
{
$component = $this->bound === null ? null : view()->shared('__livewire');
return is_object($component) ? [true, data_get($component, $key === null ? $this->bound : "{$this->bound}.{$key}")] : [false, null];
}
/**
* The binding attribute as written (wire:model.live => period), to put on the inputs that carry the value: a range
* picker binds period.start and period.end with the same modifiers. Empty without one.
*
* @return array<string, string>
*/
public static function binding(ComponentAttributeBag $attributes): array
{
foreach ($attributes->getAttributes() as $attribute => $value) {
if (is_string($value) && $value !== '' && (str_starts_with($attribute, 'wire:model') || str_starts_with($attribute, 'x-model'))) {
return [$attribute => $value];
}
}
return [];
}
/**
* Whether a checkbox or switch renders ticked. An unticked box isn't in the request at all, so after a
* failed submit "no old value" means unticked, but only when that submit was this box's own form. A page
* with a second form (or a disabled box, which is never sent) would otherwise lose every `checked`.
*
* @param bool $alwaysSent it has an unchecked-value, so its form always sends something under its name
* @param mixed $errorBag the view's shared $errors
*/
public function checked(mixed $value, bool $default, bool $disabled, bool $alwaysSent, mixed $errorBag, string $bag = 'default'): bool
{
// Bound to a Livewire property: that says, true/false, or for a list of boxes, whether it holds this value.
[$found, $live] = $this->fromLivewire();
if ($found) {
return is_array($live) ? in_array(self::text($value), array_map(self::text(...), $live), true) : (bool) $live;
}
if ($this->key === null || $disabled || !session()->hasOldInput()) {
return $default;
}
$old = old($this->key);
if ($old !== null) {
return in_array(self::text($value), array_map(self::text(...), Arr::wrap($old)), true);
}
// Nothing under this name, though this box always sends something: its form wasn't the one submitted.
if ($alwaysSent) {
return $default;
}
// A form with its own error bag: no errors in that bag means the failed submit was a different form.
if ($bag !== 'default') {
return $errorBag instanceof ViewErrorBag && $errorBag->getBag($bag)->isNotEmpty() ? false : $default;
}
// One form, or forms sharing the default bag: there's no telling them apart, so trust the old input.
return false;
}
/** Values cast to backed enums (Plan::Pro) compare as their backing value. */
private static function text(mixed $value): string
{
return (string) ($value instanceof \BackedEnum ? $value->value : $value);
}
/**
* What the caller passed, minus `class` (that styles the wrapper) and the aria attributes
* this widget manages itself. Goes on the element that is actually submitted.
*/
public function forwarded(ComponentAttributeBag $attributes): ComponentAttributeBag
{
return $attributes->except(['class', 'aria-invalid', 'aria-describedby']);
}
/**
* aria-invalid plus one aria-describedby that joins the error, the hint and the caller's own
* ids. Two separate aria-describedby attributes would make the browser silently drop one.
*/
public function aria(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
{
$describedBy = array_filter([
$this->hasError() ? $this->errorId() : null,
$hasInfo ? $this->infoId() : null,
$attributes->get('aria-describedby'),
]);
return new ComponentAttributeBag([
'aria-invalid' => $this->hasError() ? 'true' : null,
'aria-describedby' => $describedBy === [] ? null : implode(' ', $describedBy),
]);
}
/**
* For a widget whose visible control isn't the submitted input (grouped number, phone): what belongs on the
* hidden input that carries the value. Which form it's in, and Livewire and Alpine bindings, go with the value.
*/
public function bindings(ComponentAttributeBag $attributes): ComponentAttributeBag
{
return $attributes->filter(static fn (mixed $value, string $key): bool => self::isBinding($key));
}
/**
* The other side of bindings(): everything else the caller passed (required, autofocus, aria-label,
* placeholder…) goes on the visible control, where the browser validates it and screen readers hear it.
*/
public function visibleAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
{
return $this->controlAttributes($attributes->filter(static fn (mixed $value, string $key): bool => !self::isBinding($key)), $hasInfo);
}
private static function isBinding(string $key): bool
{
return $key === 'form' || str_starts_with($key, 'wire:model') || str_starts_with($key, 'x-model');
}
/**
* forwarded() and aria() together, for widgets whose visible control is also the submitted one.
*/
public function controlAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
{
return $this->forwarded($attributes)->merge($this->aria($attributes, $hasInfo)->getAttributes());
}
}
app/View/Widget/LocalDate.php Show
<?php
declare(strict_types=1);
namespace App\View\Widget;
use IntlCalendar;
use IntlDateFormatter;
use IntlDatePatternGenerator;
/**
* How a locale writes and lays out dates, for the date pickers. The server renders the first paint with
* this and the browser (Intl.DateTimeFormat) takes over, both asking ICU for the same "year, short month,
* day" pattern, so a date reads "Sep 26, 2026" in en-US and "26 Sept 2026" in en-GB either way.
* Falls back to plain ISO dates when PHP's intl extension is missing.
*/
final class LocalDate
{
/** "en_GB" or "en-GB" → "en-GB", the form Intl in the browser expects. */
public static function locale(?string $locale = null): string
{
return str_replace('_', '-', $locale ?? app()->getLocale());
}
/**
* A Y-m-d date the way the locale writes it: "Sep 26, 2026", "26 Sept 2026", "2026年9月26日".
*/
public static function format(string $iso, ?string $locale = null): string
{
$locale = self::locale($locale);
if (!class_exists(IntlDatePatternGenerator::class)) {
return $iso;
}
$pattern = (new IntlDatePatternGenerator($locale))->getBestPattern('yMMMd');
$formatter = new IntlDateFormatter($locale, IntlDateFormatter::NONE, IntlDateFormatter::NONE, 'UTC', IntlDateFormatter::GREGORIAN, $pattern ?: 'y-MM-dd');
$formatted = $formatter->format(new \DateTimeImmutable($iso, new \DateTimeZone('UTC')));
return is_string($formatted) ? $formatted : $iso;
}
/**
* First day of the week, as JavaScript counts days (0 = Sunday … 6 = Saturday): Monday in most of
* the world, Sunday in the US, Saturday in parts of the Middle East.
*/
public static function firstDayOfWeek(?string $locale = null): int
{
if (!class_exists(IntlCalendar::class)) {
return 1; // ISO 8601
}
$calendar = IntlCalendar::createInstance(null, str_replace('-', '_', self::locale($locale)));
$first = $calendar instanceof IntlCalendar ? $calendar->getFirstDayOfWeek() : false;
// ICU counts 1 = Sunday … 7 = Saturday.
return is_int($first) ? ($first - 1) % 7 : 1;
}
}
app/Rules/LocalToday.php Show
<?php
declare(strict_types=1);
namespace App\Rules;
use Carbon\CarbonImmutable;
/**
* "Today" for date rules when users sit in many timezones and the app runs on UTC.
*
* The server's today can be a day behind a user's local today. Comparing against the
* first timezone on Earth to reach a new day never rejects a genuine local today, and
* still rejects anything that is in the future for everyone.
*/
final class LocalToday
{
// UTC+14: the earliest timezone, so its date is the latest "today" anywhere.
private const string EARLIEST_TIMEZONE = 'Pacific/Kiritimati';
public static function latest(): CarbonImmutable
{
return CarbonImmutable::now(self::EARLIEST_TIMEZONE)->startOfDay();
}
/** Parses a strict Y-m-d date, as submitted by the date picker; anything else is null. */
public static function parse(mixed $value): ?CarbonImmutable
{
if (!is_string($value) || preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $parts) !== 1) {
return null;
}
if (!checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1])) {
return null;
}
return CarbonImmutable::createFromFormat('!Y-m-d', $value, self::EARLIEST_TIMEZONE);
}
}
app/Rules/NotAfterToday.php Show
<?php
declare(strict_types=1);
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Server-side twin of the date picker's default "no future dates" limit.
* Use instead of 'before_or_equal:today', which uses the server's UTC date and can
* reject a user's genuine local today.
*/
final class NotAfterToday implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$date = LocalToday::parse($value);
if ($date === null) {
$fail('The :attribute must be a valid date.');
return;
}
if ($date->greaterThan(LocalToday::latest())) {
$fail('The :attribute may not be in the future.');
}
}
}