Skip to content

Calendar

An accessible, always-visible inline calendar. Supports day / month / year drill-down views, min/max bounds, disabled weekend days, theming, and native form association. Use this when you want the calendar rendered directly in the page — see Date Picker for the trigger + popup variant.

Features

  • Full keyboard nav — Arrow keys (including ArrowUp/ArrowDown for row jumping), Home/End, Enter/Space
  • ARIArole="group" host, role="grid" day grid, role="gridcell" day cells, aria-selected, aria-current="date" for today, aria-disabled on disabled cells
  • Three views — Day → Month → Year cycling via the header label button
  • InternationalisedIntl.DateTimeFormat; pass any BCP 47 locale string
  • Min / Max bounds — ISO 8601 min / max; out-of-range days are disabled
  • Weekend disabling — JSON array of day-of-week indices, e.g. weekend-days="[0,6]"
  • Form-associated — participates in native <form> submission; value is the ISO date string
  • 6 semantic colors — primary, secondary, info, success, warning, error
  • CSS custom properties--calendar-* tokens for full theming control

Source Code

View Source Code
ts
import { define, useField, html, prop } from '@vielzeug/craft';
import { computed, signal } from '@vielzeug/ripple';
import { Temporal, format } from '@vielzeug/tempo';

import type { ComponentSize, RoundedSize, ThemeColor } from '../../shared';

import '../../content/icon/icon';
import '../../feedback/badge/badge';
import { createDatePickerControl, parseIso, toIsoString, type DatePickerView } from '../../headless';
import { disablableBundle, roundableBundle, sizableBundle, themableBundle } from '../../shared';
import { colorThemeMixin, reducedMotionMixin } from '../../styles';
import componentStyles from './calendar.css?inline';

// ── Types ─────────────────────────────────────────────────────────────────────

export type SgCalendarEvents = {
  change: { isoValue: string | null };
};

/**
 * A calendar event entry.
 * Dates must be ISO 8601 strings (yyyy-MM-dd).
 *
 * The `color` field accepts any valid CSS color value (e.g. `'#e11d48'`,
 * `'var(--color-primary)'`). An invalid value silently falls back to the
 * component's theme color. Only pass values you control.
 */
export type CalendarEvent = {
  /**
   * Any CSS color value for the event dot / pill.
   * Defaults to the component's theme color when omitted.
   * An invalid CSS value silently produces no custom color.
   */
  color?: string;
  /** ISO date the event falls on (yyyy-MM-dd) */
  date: string;
  /** Unique identifier */
  id: string;
  /** Short label shown in the calendar cell */
  label: string;
};

export type SgCalendarProps = {
  /** Theme color */
  color?: ThemeColor;
  /** Disable all date selection */
  disabled?: boolean;
  /**
   * Array of calendar events to display on day cells.
   * In normal mode each event renders as a small colored dot (max 3 dots, then "+N").
   * In expanded mode each event renders as a full-width colored pill with its label.
   * Pass as a JS property or as a JSON-encoded attribute.
   * @example
   * ```js
   * calendar.events = [
   *   { id: '1', date: '2025-06-15', label: 'Team standup', color: 'var(--color-primary)' },
   *   { id: '2', date: '2025-06-20', label: 'Release', color: '#e11d48' },
   * ];
   * ```
   */
  events?: CalendarEvent[];
  /**
   * Expanded layout — large cells with top-aligned day numbers suitable
   * for a full-page calendar app view.
   */
  expanded?: boolean;
  /** Expand to container width */
  fullwidth?: boolean;
  /** Locale for day/month names (default: browser locale) */
  locale?: string;
  /** Latest selectable date in ISO 8601 format (yyyy-MM-dd) */
  max?: string;
  /** Earliest selectable date in ISO 8601 format (yyyy-MM-dd) */
  min?: string;
  /** Form field name (when used inside a form) */
  name?: string;
  /** Mark field as required */
  required?: boolean;
  /** Border radius override */
  rounded?: RoundedSize;
  /** Component size */
  size?: ComponentSize;
  /**
   * Selected date in ISO 8601 format (yyyy-MM-dd).
   * @example '2025-06-15'
   */
  value?: string;
  /**
   * Day-of-week indices to disable (0 = Sunday … 6 = Saturday).
   * Pass as a JSON array attribute or a JS property.
   * @example
   * ```html
   * <sg-calendar weekend-days="[0,6]"></sg-calendar>
   * ```
   */
  'weekend-days'?: number[];
};

/**
 * An accessible, keyboard-navigable inline calendar component.
 * Supports day/month/year drill-down views, min/max bounds, disabled weekend
 * days, and optional form association.
 *
 * @element sg-calendar
 *
 * @attr {string} value - Selected date in ISO 8601 format (yyyy-MM-dd)
 * @attr {string} min - Minimum selectable date (yyyy-MM-dd)
 * @attr {string} max - Maximum selectable date (yyyy-MM-dd)
 * @attr {boolean} disabled - Disable all interaction
 * @attr {boolean} required - Required field (form association)
 * @attr {string} name - Form field name
 * @attr {string} color - Theme color: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
 * @attr {string} size - Component size: 'sm' | 'md' | 'lg'
 * @attr {string} rounded - Border radius: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full'
 * @attr {string} locale - BCP 47 locale string
 * @attr {string} weekend-days - JSON array of day indices to disable (0=Sun…6=Sat)
 * @attr {boolean} fullwidth - Expand to full container width
 * @attr {boolean} expanded - Large-cell calendar-app layout with top-aligned day numbers
 *
 * @fires change - Fired when a date is selected. detail: { isoValue: string | null }
 *
 * @cssprop --calendar-bg - Calendar background
 * @cssprop --calendar-border-color - Calendar border color
 * @cssprop --calendar-radius - Calendar border radius
 * @cssprop --calendar-shadow - Calendar drop shadow
 * @cssprop --calendar-day-selected-bg - Background of selected day cell
 * @cssprop --calendar-day-today-color - Colour of today's date number
 * @cssprop --calendar-day-outside-opacity - Opacity of days outside visible month
 *
 * @part calendar - The root calendar panel
 * @part header - Calendar header (nav + label)
 * @part grid - Day grid
 * @part day - Individual day cell
 *
 * @example
 * ```html
 * <!-- Single date with bounds -->
 * <sg-calendar value="2025-06-15" min="2025-01-01" max="2025-12-31" color="primary"></sg-calendar>
 *
 * <!-- Expanded calendar-app layout -->
 * <sg-calendar expanded fullwidth color="primary"></sg-calendar>
 * ```
 */
export const CALENDAR_TAG = 'sg-calendar' as const;

/** Maximum event dots/pills shown per cell before "+N" overflow */
const MAX_EVENTS = 3;

// ── Component ────────────────────────────────────────────────────────────────

define<SgCalendarProps, SgCalendarEvents>(CALENDAR_TAG, {
  formAssociated: true,
  props: {
    ...themableBundle,
    ...sizableBundle,
    ...disablableBundle,
    ...roundableBundle,
    events: prop.json([] as CalendarEvent[]),
    expanded: prop.bool(false),
    fullwidth: prop.bool(false),
    locale: prop.string(),
    max: prop.string(),
    min: prop.string(),
    name: prop.string(),
    required: prop.bool(false),
    value: prop.string(),
    'weekend-days': prop.json([] as number[]),
  },

  setup(props, { bind, emit }) {
    // ── Derived flags ────────────────────────────────────────────────────────

    const isDisabled = computed(() => props.disabled.value === true);
    const isExpanded = computed(() => props.expanded.value === true);

    // ── Selected date: local-override pattern ─────────────────────────────────
    // `localSelection` holds a user-initiated pick (or undefined = no override).
    // `selectedDate` derives from it, falling back to the value prop reactively.
    // This eliminates setInterval polling — external prop changes propagate
    // through the computed graph automatically.

    const localSelection = signal<Temporal.PlainDate | null | undefined>(undefined);

    const selectedDate = computed(() =>
      localSelection.value !== undefined ? localSelection.value : parseIso(props.value.value),
    );

    // ── Date-picker control ──────────────────────────────────────────────────

    const ctrl = createDatePickerControl({
      get locale() {
        return props.locale.value ?? (typeof navigator !== 'undefined' ? navigator.language : 'en');
      },
      get max() {
        return parseIso(props.max.value);
      },
      get min() {
        return parseIso(props.min.value);
      },
      onChange(date) {
        localSelection.value = date;
        currentView.value = 'day';
        displayYear.value = ctrl.displayYear();
        displayMonth.value = ctrl.displayMonth();
        emit('change', { isoValue: toIsoString(date) });
      },
      get value() {
        return selectedDate.value;
      },
      get weekendDays() {
        return props['weekend-days'].value ?? [];
      },
    });

    // ── Reactive display state ────────────────────────────────────────────────
    // Three separate primitive signals — the proven pattern. Mutating any one
    // of them invalidates only the computeds that read it.

    const currentView = signal<DatePickerView>('day');
    const displayYear = signal(ctrl.displayYear());
    const displayMonth = signal(ctrl.displayMonth()); // 1-indexed (Temporal)

    // ── Form value ───────────────────────────────────────────────────────────

    useField<string>({
      disabled: isDisabled,
      toFormValue: (v) => v || null,
      value: computed(() => toIsoString(selectedDate.value) ?? ''),
    });

    // ── Events lookup ─────────────────────────────────────────────────────────

    const eventsByDate = computed(() => {
      const map = new Map<string, CalendarEvent[]>();

      for (const evt of props.events.value ?? []) {
        const list = map.get(evt.date) ?? [];

        list.push(evt);
        map.set(evt.date, list);
      }

      return map;
    });

    // ── Derived cells ─────────────────────────────────────────────────────────
    // Each reads a display signal so they re-run when navigation changes.

    const dayCells = computed(() => {
      void displayYear.value;
      void displayMonth.value;
      void selectedDate.value; // re-run when selection changes to refresh isSelected flags

      return ctrl.dayCells();
    });
    const monthCells = computed(() => {
      void displayYear.value;

      return ctrl.monthCells();
    });
    const yearCells = computed(() => {
      void displayYear.value;

      return ctrl.yearCells();
    });
    const weekdayLabels = computed(() => ctrl.weekdayLabels());

    const displayMonth_ = computed(() => {
      const d = Temporal.PlainDate.from({ day: 1, month: displayMonth.value, year: displayYear.value });
      const loc = props.locale.value ?? (typeof navigator !== 'undefined' ? navigator.language : 'en');

      return format(d, { intl: { month: 'long' }, locale: loc, tz: 'UTC' });
    });

    const displayYear_ = computed(() => String(displayYear.value));
    const displayLabel = computed(() => `${displayMonth_.value} ${displayYear_.value}`);

    // ── Navigation ───────────────────────────────────────────────────────────

    function handlePrev(): void {
      if (currentView.value === 'day') ctrl.prevMonth();
      else ctrl.prevYear();

      displayYear.value = ctrl.displayYear();
      displayMonth.value = ctrl.displayMonth();
    }

    function handleNext(): void {
      if (currentView.value === 'day') ctrl.nextMonth();
      else ctrl.nextYear();

      displayYear.value = ctrl.displayYear();
      displayMonth.value = ctrl.displayMonth();
    }

    function handleHeaderClick(): void {
      const views: DatePickerView[] = ['day', 'month', 'year'];
      const next = views[(views.indexOf(currentView.value) + 1) % views.length];

      ctrl.setView(next);
      currentView.value = next;
    }

    // ── Selection handlers — all disabled guards live here ────────────────────

    function handleSelectMonth(month: number): void {
      if (isDisabled.value) return;

      ctrl.goTo(ctrl.displayYear(), month);
      ctrl.setView('day');
      displayYear.value = ctrl.displayYear();
      displayMonth.value = ctrl.displayMonth();
      currentView.value = 'day';
    }

    function handleSelectYear(year: number): void {
      if (isDisabled.value) return;

      ctrl.goTo(year, ctrl.displayMonth());
      ctrl.setView('month');
      displayYear.value = ctrl.displayYear();
      currentView.value = 'month';
    }

    function handleSelectDay(isoStr: string): void {
      if (isDisabled.value) return;

      const date = parseIso(isoStr);

      if (!date) return;

      ctrl.select(date); // ctrl.select() internally rejects disabled/out-of-range dates
    }

    // ── Day-cell keyboard navigation (full ARIA grid pattern) ─────────────────

    function handleDayKeydown(e: KeyboardEvent): void {
      const cell = e.currentTarget as HTMLElement;
      const grid = cell.closest('.cal-grid-days');

      if (!grid) return;

      const allCells = Array.from(grid.querySelectorAll<HTMLElement>('.cal-cell-day'));
      const idx = allCells.indexOf(cell);

      if (idx === -1) return;

      let target: HTMLElement | undefined;

      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        handleSelectDay(cell.dataset.iso ?? '');

        return;
      } else if (e.key === 'ArrowRight') {
        target = allCells[idx + 1];
      } else if (e.key === 'ArrowLeft') {
        target = allCells[idx - 1];
      } else if (e.key === 'ArrowDown') {
        target = allCells[idx + 7];
      } else if (e.key === 'ArrowUp') {
        target = allCells[idx - 7];
      } else if (e.key === 'Home') {
        target = allCells[Math.floor(idx / 7) * 7]; // first cell in same row
      } else if (e.key === 'End') {
        target = allCells[Math.floor(idx / 7) * 7 + 6]; // last cell in same row
      } else {
        return;
      }

      e.preventDefault();
      target?.focus();
    }

    // ── Host bindings ────────────────────────────────────────────────────────

    bind({
      attr: {
        'aria-disabled': () => (isDisabled.value ? 'true' : null),
        'aria-label': () => displayLabel.value,
        role: 'group',
      },
      class: {
        'is-disabled': isDisabled,
      },
    });

    // ── Template ─────────────────────────────────────────────────────────────

    return html`
      <div class="calendar" part="calendar" role="presentation">
        <!-- Header -->
        <div class="cal-header" part="header">
          <button class="nav-btn" type="button" aria-label="Previous" ?disabled="${isDisabled}" @click="${handlePrev}">
            <sg-icon name="chevron-left" size="16" stroke-width="2" aria-hidden="true"></sg-icon>
          </button>

          <button
            class="cal-label-btn"
            type="button"
            :aria-label="${() =>
              `Switch to ${currentView.value === 'day' ? 'month' : currentView.value === 'month' ? 'year' : 'day'} view`}"
            ?disabled="${isDisabled}"
            @click="${handleHeaderClick}">
            <span class="cal-label-month">${displayMonth_}</span><span class="cal-label-sep" aria-hidden="true">/</span
            ><span class="cal-label-year">${displayYear_}</span>
          </button>

          <button class="nav-btn" type="button" aria-label="Next" ?disabled="${isDisabled}" @click="${handleNext}">
            <sg-icon name="chevron-right" size="16" stroke-width="2" aria-hidden="true"></sg-icon>
          </button>
        </div>

        <!-- Day view -->
        <div
          class="cal-grid cal-grid-days"
          role="grid"
          part="grid"
          :aria-label="${() => displayLabel.value}"
          ?hidden="${() => currentView.value !== 'day'}">
          ${() =>
            weekdayLabels.value.map(
              (lbl) => html`<div class="cal-cell cal-cell-head" role="columnheader" aria-label="${lbl}">${lbl}</div>`,
            )}
          ${() =>
            dayCells.value.map(
              (cell) =>
                html`<div
                  class="cal-cell cal-cell-day"
                  role="gridcell"
                  part="day"
                  :aria-selected="${() => String(cell.isSelected)}"
                  :aria-disabled="${() => String(cell.isDisabled || isDisabled.value)}"
                  :aria-current="${() => (cell.isToday ? 'date' : null)}"
                  ?data-selected="${() => cell.isSelected}"
                  ?data-today="${() => cell.isToday}"
                  ?data-outside="${() => cell.isOutsideMonth}"
                  ?data-disabled="${() => cell.isDisabled || isDisabled.value}"
                  data-iso="${cell.iso}"
                  data-day="${String(cell.day)}"
                  tabindex="${() => (cell.isDisabled || isDisabled.value ? '-1' : '0')}"
                  @click="${() => handleSelectDay(cell.iso)}"
                  @keydown="${handleDayKeydown}">
                  ${String(cell.day)}
                  ${() => {
                    const evts = eventsByDate.value.get(cell.iso) ?? [];

                    if (!evts.length) return html``;

                    const shown = evts.slice(0, MAX_EVENTS);
                    const overflow = evts.length - MAX_EVENTS;

                    if (isExpanded.value) {
                      return html`<div
                        class="cal-events"
                        aria-label="${() => `${evts.length} event${evts.length > 1 ? 's' : ''}`}">
                        ${shown.map(
                          (evt) =>
                            html`<sg-badge
                              class="cal-event-pill"
                              size="xs"
                              rounded="sm"
                              aria-label="${evt.label}"
                              style="${() =>
                                evt.color ? `--badge-bg:${evt.color};--badge-border-color:${evt.color}` : ''}">
                              ${evt.label}
                            </sg-badge>`,
                        )}
                        ${overflow > 0
                          ? html`<span class="cal-event-pill-overflow" aria-hidden="true">+${overflow} more</span>`
                          : html``}
                      </div>`;
                    }

                    return html`<div
                      class="cal-dots"
                      aria-label="${() => `${evts.length} event${evts.length > 1 ? 's' : ''}`}">
                      ${shown.map(
                        (evt) =>
                          html`<sg-badge
                            class="cal-dot"
                            dot
                            size="xs"
                            aria-hidden="true"
                            style="${() =>
                              evt.color
                                ? `--badge-bg:${evt.color};--badge-border-color:${evt.color}`
                                : ''}"></sg-badge>`,
                      )}
                      ${overflow > 0
                        ? html`<span class="cal-dot-overflow" aria-hidden="true">+${overflow}</span>`
                        : html``}
                    </div>`;
                  }}
                </div>`,
            )}
        </div>

        <!-- Month view -->
        <div
          class="cal-grid cal-grid-months"
          role="grid"
          :aria-label="${() => displayYear_.value}"
          ?hidden="${() => currentView.value !== 'month'}">
          ${() =>
            monthCells.value.map(
              (cell) =>
                html`<div
                  class="cal-cell cal-cell-month"
                  role="gridcell"
                  :aria-selected="${() => String(cell.isSelected)}"
                  :aria-disabled="${() => String(cell.isDisabled || isDisabled.value)}"
                  ?data-selected="${() => cell.isSelected}"
                  ?data-disabled="${() => cell.isDisabled || isDisabled.value}"
                  tabindex="${() => (cell.isDisabled || isDisabled.value ? '-1' : '0')}"
                  @click="${() => handleSelectMonth(cell.month)}"
                  @keydown="${(e: KeyboardEvent) => {
                    if (e.key === 'Enter' || e.key === ' ') {
                      e.preventDefault();
                      handleSelectMonth(cell.month);
                    }
                  }}">
                  ${cell.shortLabel}
                </div>`,
            )}
        </div>

        <!-- Year view -->
        <div
          class="cal-grid cal-grid-years"
          role="grid"
          aria-label="Select year"
          ?hidden="${() => currentView.value !== 'year'}">
          ${() =>
            yearCells.value.map(
              (cell) =>
                html`<div
                  class="cal-cell cal-cell-year"
                  role="gridcell"
                  :aria-selected="${() => String(cell.isSelected)}"
                  :aria-disabled="${() => String(cell.isDisabled || isDisabled.value)}"
                  ?data-selected="${() => cell.isSelected}"
                  ?data-disabled="${() => cell.isDisabled || isDisabled.value}"
                  tabindex="${() => (cell.isDisabled || isDisabled.value ? '-1' : '0')}"
                  @click="${() => handleSelectYear(cell.year)}"
                  @keydown="${(e: KeyboardEvent) => {
                    if (e.key === 'Enter' || e.key === ' ') {
                      e.preventDefault();
                      handleSelectYear(cell.year);
                    }
                  }}">
                  ${String(cell.year)}
                </div>`,
            )}
        </div>
      </div>
    `;
  },
  shadow: { delegatesFocus: true },
  styles: [colorThemeMixin, reducedMotionMixin, componentStyles],
});

Basic Usage

PreviewCode
RTL

With Pre-selected Value

PreviewCode
RTL

Min / Max Bounds

PreviewCode
RTL

Disabled Weekends

Pass a JSON array of day-of-week indices (0 = Sunday … 6 = Saturday) to weekend-days.

PreviewCode
RTL

Colors

PreviewCode
RTL
PreviewCode
RTL

Calendar Events

Pass an array of CalendarEvent objects via the events JS property. Each entry requires an id, a date (ISO 8601), and a label. An optional color accepts any CSS color value.

Normal mode — up to 3 colored dots per cell; additional events appear as a +N count.

Expanded mode — up to 3 colored pills with labels; additional events appear as +N more.

PreviewCode
RTL

Expanded with events

PreviewCode
RTL

Events overflow

When a day has more than 3 events, the first 3 are shown and the rest are summarised. This applies to both dots (normal mode) and pills (expanded mode).

PreviewCode
RTL
PreviewCode
RTL

Expanded Layout

Use expanded for a full-page, calendar-app style layout. Each day cell becomes tall with the day number shown as a circle in the top-left corner, leaving the remaining space for event content. The minimum cell height defaults to var(--size-28) and can be overridden with --calendar-expanded-cell-height.

PreviewCode
RTL
PreviewCode
RTL

Disabled

PreviewCode
RTL

Localised

PreviewCode
RTL

Inside a Form

PreviewCode
RTL

Listening for Changes

js
document.querySelector('sg-calendar').addEventListener('change', (e) => {
  console.log(e.detail.isoValue); // '2025-06-15' or null
});

API Reference

Props

PropTypeDefaultDescription
valuestringSelected date in ISO 8601 format (yyyy-MM-dd)
minstringEarliest selectable date (yyyy-MM-dd, inclusive)
maxstringLatest selectable date (yyyy-MM-dd, inclusive)
weekend-daysnumber[][]JSON array of day-of-week indices to disable (0 = Sunday … 6 = Saturday). e.g. weekend-days="[0,6]"
localestringbrowser localeBCP 47 locale string for day/month names
colorstringTheme color: primary | secondary | info | success | warning | error
sizestringmdComponent size: sm | md | lg
roundedstringBorder radius override
disabledbooleanfalseDisable all interaction
requiredbooleanfalseRequired field (form association)
namestringForm field name
eventsCalendarEvent[][]Calendar events to display. Dots in normal mode, pills in expanded mode. Set via JS property
expandedbooleanfalseExpanded calendar-app layout with tall cells and top-aligned day number circles
fullwidthbooleanfalseExpand calendar to full container width

CalendarEvent

FieldTypeRequiredDescription
idstringUnique identifier
datestringISO 8601 date the event falls on (yyyy-MM-dd)
labelstringShort label shown in the cell (pill text in expanded mode)
colorstringAny CSS color value. Falls back to the component's theme color

Events

EventDetailDescription
change{ isoValue: string | null }Fired when a date is selected. isoValue is the ISO 8601 date string or null when cleared

CSS Custom Properties

PropertyDefaultDescription
--calendar-bg--color-canvasCalendar background colour
--calendar-border-color--color-contrast-200Calendar border colour
--calendar-radius--rounded-xlCalendar border radius
--calendar-shadow--shadow-mdCalendar drop shadow
--calendar-day-selected-bgtheme focus colorBackground of the selected day cell
--calendar-day-today-colortheme focus colorColor of today's date number
--calendar-day-outside-opacity0.35Opacity of days outside the visible month
--calendar-expanded-cell-heightvar(--size-28)Minimum height of each day cell in the expanded layout

Parts

PartDescription
calendarThe root calendar panel
headerCalendar header (nav buttons + label)
gridThe day grid
dayIndividual day cell

Accessibility

sg-calendar implements the ARIA Grid Pattern.

  • Roles: the host element carries role="group" and aria-label (the currently visible month/year). The day grid uses role="grid" with role="columnheader" for weekday headers and role="gridcell" on each day cell.
  • Selection state: selected days have aria-selected="true"; unselected have aria-selected="false".
  • Today: today's cell has aria-current="date".
  • Disabled cells: out-of-range and disabled-weekday cells have aria-disabled="true" and tabindex="-1", removing them from tab order.
  • Disabled host: when disabled is set, the host gets aria-disabled="true", all cells become tabindex="-1", and no interaction is processed.

Keyboard Navigation

Day grid

KeyAction
ArrowRightMove focus to the next day
ArrowLeftMove focus to the previous day
ArrowDownMove focus one week forward (same weekday)
ArrowUpMove focus one week back (same weekday)
HomeMove focus to the first day of the current row
EndMove focus to the last day of the current row
Enter / SpaceSelect the focused day

Month and year grids

KeyAction
Enter / SpaceSelect the focused month / year

Header controls

KeyAction
Click / EnterPrevious / Next button — navigate by month or year
Click / EnterLabel button — cycle view: Day → Month → Year → Day

View Cycling

The header label button cycles through three views on each click:

  1. Day — the standard month grid; Previous/Next navigate by month
  2. Month — a 4×3 grid of abbreviated month names; click a month to return to day view at that month
  3. Year — a 4×3 grid of year numbers; click a year to go to month view for that year

The calendar panel maintains a stable size across all three views — day view reserves space for a maximum 6-week month, and month/year views fill the same width.

  • Date Picker — trigger + popup wrapper around the same calendar logic