Skip to content
Brok UI

Loading…

No results

Calendar

Open source Core

A localized month grid with full keyboard navigation and an ISO hidden form value.

Version
v1.3.3
Stability
stable
License
MIT
Related
Date Picker
Date Range Picker
Mini Calendar

Preview

previews.components.calendar.default.blade.php Blade
<x-ui.calendar name="event_date" value="" :first-day="1" />

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/calendar.blade.php
  • js resources/js/ui/calendar.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

calendar.md
# 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

bounded.blade.php Blade
{{-- 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.blade.php Blade
{{-- 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

calendar calendar-caption calendar-today

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

Date and time picker

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
root calendar-caption calendar-today
Theming hooks
calendar calendar-caption calendar-today

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/calendar.blade.php Blade
{{--
    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>
resources/js/ui/calendar.js JS
/**
 * 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