Calendar
A localized month grid with full keyboard navigation and an ISO hidden form value.
Preview
<x-ui.calendar name="event_date" value="" :first-day="1" />
Installation
php artisan ui:add calendar
Note
This component ships an Alpine behavior module at
resources/js/ui/calendar.js. Import it once from your bundle so it registers on alpine:init:
import './calendar.js';
Registry contract
php artisan ui:add calendar
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/calendar.blade.php -
resources/js/ui/calendar.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Calendar (`calendar`)
A localized month grid with full keyboard navigation and an ISO hidden form value.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add calendar
```
## Usage
```blade
<x-ui.calendar name="event_date" value="" :first-day="1" />
```
## Props
- `name` (mixed|null, default `null`) — Base name for the hidden input(s); range mode appends _start/_end unless startName/endName override it.
- `value` (string, default ``) — Selected ISO date in single mode.
- `mode` (string, default `single`) — single or range.
- `start` (string, default ``) — Selected ISO start date in range mode.
- `end` (string, default ``) — Selected ISO end date in range mode.
- `startName` (mixed|null, default `null`) — Overrides the hidden start-input name in range mode.
- `endName` (mixed|null, default `null`) — Overrides the hidden end-input name in range mode.
- `locale` (mixed|null, default `null`) — Intl locale override for weekday/month labels.
- `firstDay` (int, default `1`) — First weekday shown (0 = Sunday).
- `chrome` (bool, default `true`) — Renders the bordered popover-style card when true; false renders a bare grid, as used inside date-picker/date-range-picker.
- `min` (mixed|null, default `null`) — ISO date lower bound; earlier days are disabled.
- `max` (mixed|null, default `null`) — ISO date upper bound; later days are disabled.
- `disabled` (array, default `[]`) — ISO dates and/or weekday numbers (0 = Sunday) to disable.
- `caption` (string, default `label`) — label (month + year text) or dropdown (month and year selects).
- `months` (int, default `1`) — Consecutive months shown side by side (1-3).
- `weekNumbers` (bool, default `false`) — Shows the ISO week-number column when true.
- `showToday` (bool, default `false`) — Shows a Today link that jumps the view back to the current month.
## Use when
- Use when date, time, or bounded-value context helps users enter valid values faster and with fewer errors.
- Building a custom date-picking surface (inline, embedded in a panel, or composed inside another popover) that needs the full month grid.
- Needing a standalone range calendar with two or three synced months.
## Avoid when
- Do not force a picker when expert users can type the value faster and validation is straightforward.
- Needing a compact text-field-plus-popover date input; use date-picker or date-range-picker instead, which compose this component with chrome=false.
## Anti-patterns
- Forcing a picker when typing is faster
## Rules
- Use the `<brok:calendar>` tag (or `<x-ui.calendar>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/calendar
- Registry JSON (files, props, contract): https://brokui.dev/r/open/calendar.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- Month and year selects in the caption, a min/max window, and blocked dates. --}}
<x-ui.calendar name="appointment" value="2026-09-24" caption="dropdown" min="2026-09-10" max="2026-12-31" :disabled="['2026-09-25', '2026-09-28']" show-today />
{{-- Range mode across two months with a hovered preview, weekends off, ISO week numbers. --}}
<x-ui.calendar mode="range" name="stay" start="2026-09-22" end="2026-09-27" :months="2" :disabled="[0, 6]" week-numbers show-today />
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| name | mixed | null | null | Base name for the hidden input(s); range mode appends _start/_end unless startName/endName override it. |
| value | string | Selected ISO date in single mode. | |
| mode | string | single | single or range. |
| start | string | Selected ISO start date in range mode. | |
| end | string | Selected ISO end date in range mode. | |
| startName | mixed | null | null | Overrides the hidden start-input name in range mode. |
| endName | mixed | null | null | Overrides the hidden end-input name in range mode. |
| locale | mixed | null | null | Intl locale override for weekday/month labels. |
| firstDay | int | 1 | First weekday shown (0 = Sunday). |
| chrome | bool | true | Renders the bordered popover-style card when true; false renders a bare grid, as used inside date-picker/date-range-picker. |
| min | mixed | null | null | ISO date lower bound; earlier days are disabled. |
| max | mixed | null | null | ISO date upper bound; later days are disabled. |
| disabled | array | [] | ISO dates and/or weekday numbers (0 = Sunday) to disable. |
| caption | string | label | label (month + year text) or dropdown (month and year selects). |
| months | int | 1 | Consecutive months shown side by side (1-3). |
| weekNumbers | bool | false | Shows the ISO week-number column when true. |
| showToday | bool | false | Shows a Today link that jumps the view back to the current month. |
Slots
default— Not used by this component; present for attribute-bag compatibility.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Renders a localized month grid with role=grid/gridcell; Arrow keys move by day/week (RTL-aware), PageUp/PageDown change month, Home/End jump to the week edges, Enter/Space select; mode=range previews the hovered span while picking.
- Values are ISO yyyy-mm-dd strings, mirrored to hidden inputs when name is set and emitted via a change event; min/max/disabled grey out unavailable days.
- Binds wire:model (Livewire) or x-model (Alpine) to the ISO date, or { start, end } in range mode, through x-modelable; an inbound date turns the view to its month.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Choose a date, time, or bounded contextual value.
Use when
- Use when date, time, or bounded-value context helps users enter valid values faster and with fewer errors.
- Building a custom date-picking surface (inline, embedded in a panel, or composed inside another popover) that needs the full month grid.
- Needing a standalone range calendar with two or three synced months.
Avoid when
- Do not force a picker when expert users can type the value faster and validation is straightforward.
- Needing a compact text-field-plus-popover date input; use date-picker or date-range-picker instead, which compose this component with chrome=false.
Use instead
- Direct typed entry with validation
Anti-patterns
- Forcing a picker when typing is faster
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Enter ArrowUp ArrowDown ArrowLeft ArrowRight Home End PageUp PageDown
- Focus
managed
- Weekday and month labels are Intl-localized, and the caption text updates via aria-live="polite" as the view changes.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="calendar-{{ $record->id }}">
<x-ui.calendar name="event_date" value="" :first-day="1" />
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:calendar
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{--
Props:
- mode: single (one `value`) or range (`start` / `end`, posted as `name_start` /
`name_end`; the pointer previews the span while picking).
- min / max: ISO bounds; disabled: ISO dates and/or weekday numbers (0 = Sunday).
- caption: label (month + year text) or dropdown (month and year selects).
- months: consecutive months side by side (1–3); week-numbers: the ISO week column;
show-today: a Today link that jumps the view back.
--}}
@props([
'name' => null,
'value' => '',
'mode' => 'single',
'start' => '',
'end' => '',
'startName' => null,
'endName' => null,
'locale' => null,
'firstDay' => 1,
'chrome' => true,
'min' => null,
'max' => null,
'disabled' => [],
'caption' => 'label',
'months' => 1,
'weekNumbers' => false,
'showToday' => false,
])
{{--
Calendar (spec §17 grid keyboard navigation + §19).
A month grid: role=grid/gridcell, Arrow keys move by day/week (RTL-aware),
PageUp/PageDown change month, Home/End jump to the week edges, Enter/Space
select. Weekday/month labels are Intl-localized; values are ISO yyyy-mm-dd
strings, mirrored to hidden inputs when `name` is set and emitted via a
`change` event. Date Picker and Date Range Picker compose this with
`:chrome="false"` inside their popovers.
--}}
@php
$mode = $mode === 'range' ? 'range' : 'single';
$months = max(1, min(3, (int) $months));
$caption = in_array($caption, ['label', 'dropdown'], true) ? $caption : 'label';
$disabledList = array_values(array_map(fn ($d) => is_numeric($d) ? (int) $d : (string) $d, (array) $disabled));
$startName = $startName ?? (filled($name) ? $name.'_start' : null);
$endName = $endName ?? (filled($name) ? $name.'_end' : null);
$wrapper = $chrome
? 'w-fit rounded-md border border-border bg-popover p-3 text-popover-foreground'
: 'w-full';
$navButton = 'inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none';
$captionSelect = 'h-7 rounded-md border border-transparent bg-transparent px-1 text-sm font-medium text-foreground hover:border-input focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
$config = [
'mode' => $mode,
'value' => $value,
'start' => $start,
'end' => $end,
'locale' => $locale,
'firstDay' => (int) $firstDay,
'min' => $min,
'max' => $max,
'disabled' => $disabledList,
'months' => $months,
'weekNumbers' => (bool) $weekNumbers,
];
@endphp
<div
x-data="uiCalendar(@js($config))"
{{-- Modelable: `x-model` (Alpine) and `wire:model` (Livewire binds through
the same x-model contract) attach to the root. Inert without either. --}}
x-modelable="model"
x-id="['calendar-month', 'calendar-year']"
data-slot="calendar"
data-mode="{{ $mode }}"
{{ $attributes->except(['name'])->merge(['class' => $wrapper]) }}
>
@if ($mode === 'range')
@if (filled($startName)) <input type="hidden" name="{{ $startName }}" :value="range.start" /> @endif
@if (filled($endName)) <input type="hidden" name="{{ $endName }}" :value="range.end" /> @endif
@elseif ($name)
<input type="hidden" name="{{ $name }}" :value="value" />
@endif
<div class="flex gap-6" @if ($months > 1) data-months="{{ $months }}" @endif>
<template x-for="(view, vi) in views" :key="view.year + '-' + view.month">
<div class="min-w-0 flex-1">
{{-- Header: previous on the first month, next on the last, caption in between --}}
<div class="mb-2 flex items-center justify-between gap-2">
<button type="button" @click="prevMonth()" :class="vi === 0 ? '' : 'invisible'" class="{{ $navButton }}">
<span class="sr-only">{{ __('Previous month') }}</span>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 rtl:-scale-x-100"><path d="m15 18-6-6 6-6" /></svg>
</button>
@if ($caption === 'dropdown')
<div class="flex items-center gap-1" data-slot="calendar-caption">
<label class="sr-only" :for="$id('calendar-month') + '-' + vi">{{ __('Month') }}</label>
<select :id="$id('calendar-month') + '-' + vi" class="{{ $captionSelect }}" :value="view.month" @change="setView(view.year, $event.target.value - vi)">
<template x-for="(name, m) in monthNames" :key="m">
<option :value="m" :selected="m === view.month" x-text="name"></option>
</template>
</select>
<label class="sr-only" :for="$id('calendar-year') + '-' + vi">{{ __('Year') }}</label>
<select :id="$id('calendar-year') + '-' + vi" class="{{ $captionSelect }}" :value="view.year" @change="setView($event.target.value, view.month - vi)">
<template x-for="y in years" :key="y">
<option :value="y" :selected="y === view.year" x-text="y"></option>
</template>
</select>
</div>
@else
<div class="text-sm font-medium" data-slot="calendar-caption" aria-live="polite" x-text="view.label"></div>
@endif
<button type="button" @click="nextMonth()" :class="vi === views.length - 1 ? '' : 'invisible'" class="{{ $navButton }}">
<span class="sr-only">{{ __('Next month') }}</span>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 rtl:-scale-x-100"><path d="m9 18 6-6-6-6" /></svg>
</button>
</div>
{{-- Grid --}}
<div role="grid" @keydown="onGridKey($event)" @mouseleave="hoverIso = ''">
<div role="row" class="grid" :class="weekNumbers ? 'grid-cols-[2rem_repeat(7,minmax(1.75rem,1fr))]' : 'grid-cols-[repeat(7,minmax(1.75rem,1fr))]'">
<template x-if="weekNumbers">
<div role="columnheader" class="flex h-8 items-center justify-center text-xs text-muted-foreground"><span class="sr-only">{{ __('Week') }}</span><span aria-hidden="true">#</span></div>
</template>
<template x-for="weekday in weekdays" :key="weekday">
<div role="columnheader" class="flex h-8 items-center justify-center text-xs font-normal text-muted-foreground" x-text="weekday"></div>
</template>
</div>
<template x-for="week in view.weeks" :key="week.days[0].iso">
<div role="row" class="grid" :class="weekNumbers ? 'grid-cols-[2rem_repeat(7,minmax(1.75rem,1fr))]' : 'grid-cols-[repeat(7,minmax(1.75rem,1fr))]'" x-show="months === 1 || week.days.some((d) => d.inMonth)">
<template x-if="weekNumbers">
<div role="rowheader" class="flex items-center justify-center text-xs tabular-nums text-muted-foreground" x-text="week.number"></div>
</template>
<template x-for="day in week.days" :key="day.iso">
{{-- aria-selected belongs on the gridcell, not the button inside it. --}}
{{-- With several months on screen an outside day would show
twice, so it renders as an empty cell there. --}}
<div
role="gridcell"
:aria-selected="day.inMonth || months === 1 ? isSelected(day.iso) : null"
:data-in-range="(day.inMonth || months === 1) && inRange(day.iso) || null"
:data-range-start="(day.inMonth || months === 1) && isRangeStart(day.iso) || null"
:data-range-end="(day.inMonth || months === 1) && isRangeEnd(day.iso) || null"
class="p-0.5 data-[in-range=true]:bg-accent data-[range-end=true]:rounded-e-md data-[range-end=true]:bg-accent data-[range-start=true]:rounded-s-md data-[range-start=true]:bg-accent"
>
<template x-if="!day.inMonth && months > 1">
<span class="block size-9" aria-hidden="true"></span>
</template>
<button
x-show="day.inMonth || months === 1"
type="button"
:data-date="day.iso"
:tabindex="day.iso === focusIso ? 0 : -1"
:aria-current="day.isToday ? 'date' : null"
:aria-disabled="day.disabled ? 'true' : null"
:data-selected="isSelected(day.iso)"
:data-outside="!day.inMonth"
:data-disabled="day.disabled || null"
@click="select(day.iso)"
@mouseenter="hoverIso = day.iso"
@focus="mode === 'range' && (hoverIso = day.iso)"
class="inline-flex size-9 w-full items-center justify-center rounded-md text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring data-[outside=true]:text-muted-foreground data-[disabled=true]:cursor-not-allowed data-[disabled=true]:text-muted-foreground/50 data-[disabled=true]:line-through data-[disabled=true]:hover:bg-transparent data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground data-[selected=true]:hover:bg-primary aria-[current=date]:font-semibold aria-[current=date]:underline motion-reduce:transition-none"
x-text="day.label"
></button>
</div>
</template>
</div>
</template>
</div>
</div>
</template>
</div>
@if ($showToday)
<div class="mt-2 flex justify-end">
<button type="button" data-slot="calendar-today" @click="goToday()" class="rounded-sm text-xs font-medium text-link underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">{{ __('Today') }}</button>
</div>
@endif
</div>
/**
* Calendar behavior (spec §17 grid keyboard navigation + §19).
*
* A month grid (or two side by side) with a roving focus date. Arrow keys move
* by day/week (RTL-aware), PageUp/PageDown by month, Home/End to the week
* edges; Enter or Space selects the focused date. Labels and weekday names are
* localized with Intl, and values are ISO yyyy-mm-dd strings.
*
* `mode: 'single'` keeps one `value` and dispatches `change` ({ value }).
* `mode: 'range'` keeps `range.start` / `range.end`: the first pick sets the
* start, the second the end (a pick before the start restarts the range), the
* pointer previews the span, and `change` carries { start, end }.
*
* `min` / `max` (ISO) and `disabled` (ISO dates and/or weekday numbers 0–6,
* Sunday = 0) make days unpickable; `months` lays out that many consecutive
* months; `weekNumbers` adds the ISO week column.
*
* Self-registers on `alpine:init` so import order does not matter.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiCalendar', (config = {}) => ({
mode: config.mode === 'range' ? 'range' : 'single',
value: config.value || '',
range: { start: config.start || '', end: config.end || '' },
hoverIso: '',
focusIso: '',
viewYear: 0,
viewMonth: 0,
months: Math.max(1, Math.min(3, Number(config.months) || 1)),
views: [],
weekdays: [],
years: [],
monthNames: [],
locale: config.locale || document.documentElement.lang || 'en',
firstDay: config.firstDay ?? 1,
min: config.min || '',
max: config.max || '',
disabledDates: (config.disabled ?? []).filter((d) => typeof d === 'string'),
disabledWeekdays: (config.disabled ?? []).filter((d) => typeof d === 'number'),
weekNumbers: Boolean(config.weekNumbers),
root: null,
init() {
// `$el` is the element a handler runs on (a day button, the grid); the
// root is pinned here so focus lookups always search the whole calendar.
this.root = this.$el;
const first = this.mode === 'range' ? this.range.start : this.value;
const base = first ? this.fromIso(first) : new Date();
this.viewYear = base.getFullYear();
this.viewMonth = base.getMonth();
this.focusIso = first || this.toIso(base);
this.buildWeekdays();
this.buildMonthNames();
this.build();
},
/**
* Modelable value (`x-modelable="model"` on the root): the ISO date in
* single mode, `{ start, end }` in range mode. An inbound value
* (Livewire, `x-model`) also turns the view to that month.
*/
get model() {
return this.mode === 'range' ? { start: this.range.start, end: this.range.end } : this.value;
},
set model(v) {
if (this.mode === 'range') {
const pair = Array.isArray(v) ? { start: v[0], end: v[1] } : (v && typeof v === 'object' ? v : {});
const start = pair.start == null ? '' : String(pair.start);
const end = pair.end == null ? '' : String(pair.end);
if (start === this.range.start && end === this.range.end) return;
this.setRange(start, end);
return;
}
const next = v == null ? '' : String(v);
if (next === this.value) return;
this.value = next;
if (next && !this.isVisible(next)) {
this.focusIso = next;
this.setView(this.fromIso(next).getFullYear(), this.fromIso(next).getMonth());
}
},
/* ----- date helpers (local, no timezone surprises) ----- */
toIso(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
},
fromIso(iso) {
const [y, m, d] = iso.split('-').map(Number);
return new Date(y, m - 1, d);
},
/** ISO 8601 week number (weeks start Monday; week 1 holds the year's first Thursday). */
isoWeek(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const day = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - day);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
},
buildWeekdays() {
const fmt = new Intl.DateTimeFormat(this.locale, { weekday: 'short' });
// 2023-01-01 is a Sunday; offset by firstDay to order the header.
this.weekdays = Array.from({ length: 7 }, (_, i) =>
fmt.format(new Date(2023, 0, 1 + ((this.firstDay + i) % 7)))
);
},
buildMonthNames() {
const fmt = new Intl.DateTimeFormat(this.locale, { month: 'long' });
this.monthNames = Array.from({ length: 12 }, (_, m) => fmt.format(new Date(2023, m, 1)));
const thisYear = new Date().getFullYear();
const from = this.min ? this.fromIso(this.min).getFullYear() : thisYear - 100;
const to = this.max ? this.fromIso(this.max).getFullYear() : thisYear + 10;
this.years = Array.from({ length: to - from + 1 }, (_, i) => from + i);
},
isDisabled(iso) {
if (this.min && iso < this.min) return true;
if (this.max && iso > this.max) return true;
if (this.disabledDates.includes(iso)) return true;
return this.disabledWeekdays.includes(this.fromIso(iso).getDay());
},
/** The span shown in range mode: the picked range, or start → hovered day while picking. */
span() {
const { start, end } = this.range;
if (start && end) return [start, end];
if (start && this.hoverIso) return start <= this.hoverIso ? [start, this.hoverIso] : [this.hoverIso, start];
return start ? [start, start] : null;
},
build() {
const today = this.toIso(new Date());
const views = [];
for (let offset = 0; offset < this.months; offset++) {
const first = new Date(this.viewYear, this.viewMonth + offset, 1);
const year = first.getFullYear();
const month = first.getMonth();
const label = new Intl.DateTimeFormat(this.locale, { month: 'long', year: 'numeric' }).format(first);
// Lead with enough days from the previous month to align firstDay.
const lead = (first.getDay() - this.firstDay + 7) % 7;
const start = new Date(year, month, 1 - lead);
const weeks = [];
for (let w = 0; w < 6; w++) {
const week = [];
for (let d = 0; d < 7; d++) {
const day = new Date(start.getFullYear(), start.getMonth(), start.getDate() + (w * 7 + d));
const iso = this.toIso(day);
week.push({
iso,
label: day.getDate(),
inMonth: day.getMonth() === month,
isToday: iso === today,
disabled: this.isDisabled(iso),
});
}
weeks.push({ number: this.isoWeek(this.fromIso(week[0].iso)), days: week });
}
views.push({ year, month, label, weeks });
}
this.views = views;
},
/* ----- day state for styling ----- */
isSelected(iso) {
if (this.mode === 'range') return iso === this.range.start || iso === this.range.end;
return iso === this.value;
},
isRangeStart(iso) {
const s = this.span();
return this.mode === 'range' && s !== null && iso === s[0];
},
isRangeEnd(iso) {
const s = this.span();
return this.mode === 'range' && s !== null && iso === s[1];
},
inRange(iso) {
const s = this.span();
return this.mode === 'range' && s !== null && iso > s[0] && iso < s[1];
},
setView(year, month) {
const d = new Date(Number(year), Number(month), 1);
this.viewYear = d.getFullYear();
this.viewMonth = d.getMonth();
this.build();
},
prevMonth() {
this.setView(this.viewYear, this.viewMonth - 1);
},
nextMonth() {
this.setView(this.viewYear, this.viewMonth + 1);
},
goToday() {
this.setFocusDate(new Date());
},
/** True when `iso` falls inside one of the months on screen. */
isVisible(iso) {
return this.views.some((v) => iso.startsWith(`${v.year}-${String(v.month + 1).padStart(2, '0')}`));
},
moveFocus(days) {
const next = this.fromIso(this.focusIso);
next.setDate(next.getDate() + days);
this.setFocusDate(next);
},
moveFocusByMonth(months) {
const current = this.fromIso(this.focusIso);
const targetMonth = new Date(current.getFullYear(), current.getMonth() + months, 1);
const lastDay = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate();
const next = new Date(
targetMonth.getFullYear(),
targetMonth.getMonth(),
Math.min(current.getDate(), lastDay)
);
this.setFocusDate(next);
},
setFocusDate(next) {
this.focusIso = this.toIso(next);
if (!this.isVisible(this.focusIso)) {
this.setView(next.getFullYear(), next.getMonth());
}
this.focusActive();
},
focusActive() {
this.$nextTick(() => {
// A `change` listener (a date picker closing its popover) may
// already have moved focus out of the calendar on this tick.
// Leave it there: taking it back focuses a date that is about
// to hide, and WebKit and Firefox then drop focus to <body>.
const active = document.activeElement;
if (
active instanceof HTMLElement &&
active !== document.body &&
this.root &&
!this.root.contains(active)
) {
return;
}
const inMonth = this.root?.querySelector(`[data-date="${this.focusIso}"]:not([data-outside="true"])`);
(inMonth ?? this.root?.querySelector(`[data-date="${this.focusIso}"]`))?.focus();
});
},
onGridKey(event) {
const rtl = getComputedStyle(this.root).direction === 'rtl';
switch (event.key) {
case 'ArrowRight':
this.moveFocus(rtl ? -1 : 1);
break;
case 'ArrowLeft':
this.moveFocus(rtl ? 1 : -1);
break;
case 'ArrowDown':
this.moveFocus(7);
break;
case 'ArrowUp':
this.moveFocus(-7);
break;
case 'Home':
this.moveFocus(-(this.fromIso(this.focusIso).getDay() - this.firstDay + 7) % 7);
break;
case 'End':
this.moveFocus(6 - ((this.fromIso(this.focusIso).getDay() - this.firstDay + 7) % 7));
break;
case 'PageUp':
this.moveFocusByMonth(-1);
break;
case 'PageDown':
this.moveFocusByMonth(1);
break;
case 'Enter':
case ' ':
this.select(this.focusIso);
break;
default:
return;
}
event.preventDefault();
},
select(iso) {
if (this.isDisabled(iso)) return;
this.focusIso = iso;
if (this.mode === 'range') {
const { start, end } = this.range;
if (!start || (start && end) || iso < start) {
this.range = { start: iso, end: '' };
} else {
this.range = { start, end: iso };
}
this.hoverIso = '';
this.$dispatch('change', { start: this.range.start, end: this.range.end, complete: Boolean(this.range.end) });
this.focusActive();
return;
}
this.value = iso;
this.$dispatch('change', { value: iso });
this.focusActive();
},
/** Programmatic set (presets in a range picker). */
setRange(start, end) {
this.range = { start: start || '', end: end || '' };
if (start) {
this.focusIso = start;
if (!this.isVisible(start)) this.setView(this.fromIso(start).getFullYear(), this.fromIso(start).getMonth());
}
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.3.3 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0