Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Datepicker

A single-date picker with an MD3 modal calendar or an iOS inline calendar sheet.

A date form control with three modes — a single date, a date and time, or a date range. The trigger renders as an input-like field showing the formatted value; opening reveals a platform-appropriate calendar — a centered Material 3 modal (headline, month/year navigation, Cancel/OK) and an iOS-style inline calendar presented in a bottom sheet (Cancel/Done toolbar). One component, forked entirely by the cascade.

Installation

npx touchcn add datepicker

Usage

<tcn-datepicker [(value)]="date" label="Birthday" placeholder="Choose a date" />
import { TcnDatepicker } from '@/components/ui/datepicker';

<TcnDatepicker value={date} onValueChange={setDate} label="Birthday" placeholder="Choose a date" />
<script setup lang="ts">
import { TcnDatepicker } from '@/components/ui/datepicker';
</script>

<template>
  <TcnDatepicker v-model="date" label="Birthday" placeholder="Choose a date" />
</template>

Constraining the selectable days

min and max take ISO date strings and disable out-of-range days in every mode; the month chevrons stop at the bounds.

<tcn-datepicker [(value)]="date" [min]="'2024-01-01'" [max]="'2024-12-31'" />
<TcnDatepicker value={date} onValueChange={setDate} min="2024-01-01" max="2024-12-31" />
<TcnDatepicker v-model="date" min="2024-01-01" max="2024-12-31" />

Modes

The mode prop selects what the picker collects. Because the two range endpoints are typed differently from a single value, range mode uses its own range / onRangeChange (React) or [(range)] (Angular) model — keeping single-date usage precisely typed and unchanged.

Date & time (datetime)

Adds a time section below the calendar. On Material this is an MD3 time input (two HH / MM fields, with an AM/PM segmented control on 12-hour locales — the locale’s hour cycle is detected via Intl). On iOS it is the classic momentum wheel — hour / minute drums powered by the shared Picker engine, with correct centering, momentum and snap, and the selected value scrolled into view on open. The committed value is an ISO local datetime string, YYYY-MM-DDTHH:mm.

<tcn-datepicker [(value)]="reminder" mode="datetime" label="Reminder" />
<TcnDatepicker value={reminder} onValueChange={setReminder} mode="datetime" label="Reminder" />
<TcnDatepicker v-model="reminder" mode="datetime" label="Reminder" />

Date range (range)

Tap a start day, then an end day; the span between highlights, and the value is a { start, end } pair of ISO dates (ordered chronologically regardless of tap order). Tapping again after a complete range starts a new one.

<tcn-datepicker [(range)]="stay" mode="range" label="Stay" />
<TcnDatepicker range={stay} onRangeChange={setStay} mode="range" label="Stay" />
<TcnDatepicker v-model:range="stay" mode="range" label="Stay" />

The value is an ISO string, not a Date

The picker takes and emits ISO strings — never a JavaScript Date. A Date carries a time and an implicit timezone, which silently shifts a “date” across midnight (a day picked in UTC+13 can read as the previous day in UTC). An ISO string has no zone, so the value you store is exactly what the user tapped:

  • date — an ISO calendar day, YYYY-MM-DD.
  • datetime — an ISO local datetime, YYYY-MM-DDTHH:mm (24-hour, no seconds, no timezone). It has deliberate local wall-clock semantics: apply the user’s timezone at the edge (storage / display) if you need an absolute instant.
  • range — a { start, end } pair of ISO calendar days.

Internally the calendar math (in @touchcn/core) uses a local Date at midnight purely for whole-day arithmetic — no zone conversion ever crosses the API boundary.

Month and weekday names, and the first day of the week, come from Intl.DateTimeFormat / Intl.Locale (falling back to Monday); pass a locale prop to override the browser default. There is no date-library dependency.

Accessibility

The calendar is a WAI-ARIA grid. Arrow keys move focus by day and week (crossing month boundaries), PageUp / PageDown change month, Home / End jump to the week bounds, and Enter / Space select the focused day. In range mode every cell within the selection carries aria-selected (both endpoints and the band between), per the WAI date-range grid guidance. The time inputs are labelled (Hour / Minute, AM or PM). Focus is trapped in the overlay while open and restored to the trigger on close.

Props

Prop Type Default Description
mode 'date' | 'datetime' | 'range' 'date' What the picker collects.
value string | null null date / datetime value (two-way on Angular).
onValueChange (value: string) => void date / datetime selection callback (React).
range { start, end } | null null range value (two-way [(range)] on Angular).
onRangeChange (range: { start, end }) => void range selection callback (React).
min string | null null Earliest selectable day (ISO); earlier days disabled.
max string | null null Latest selectable day (ISO); later days disabled.
locale string browser locale BCP-47 locale for names, week start, and 12/24-hour time.
label string '' Field label.
placeholder string 'Select date' Shown when nothing is selected.
disabled boolean false Disable the control.

Future work

A couple of items are deliberately deferred to a future round:

  • MD3 clock-dial time picker — out of scope; the MD side ships the spec’s time-input variant. (The iOS momentum wheel is now shipped via the Picker engine.)
  • Month/year navigation stays at month granularity.
Source · Angular
export * from './tcn-datepicker';
import { booleanAttribute, Component, computed, effect, inject, input, model, signal } from '@angular/core';
import {
  addMonths,
  clampIso,
  formatDisplayDate,
  formatDisplayDateTime,
  formatIso,
  formatIsoDateTime,
  formatMonthYear,
  getMonthMatrix,
  getRangeCellState,
  getWeekdayLabels,
  getYearRange,
  isInRange,
  isLocale12Hour,
  isSameDay,
  normalizeRange,
  parseIso,
  parseIsoDateTime,
  resolveFirstDayOfWeek,
  resolveLocale,
  todayIso,
  type DateRange,
} from '@touchcn/core';
import { TcnOverlayDirective } from '@touchcn/angular/overlay';
import { TcnCalendarGridDirective } from '@touchcn/angular/datepicker';
import { TcnPicker, TcnPickerColumn } from '@/components/ui/picker';

/** Start/end date pair used by `range` mode (ISO `YYYY-MM-DD` strings). */
export type TcnDateRange = DateRange;

/**
 * Date picker with three modes. The trigger renders as an input-like field
 * showing the formatted value; opening reveals a platform-appropriate calendar —
 * a centered MD3 modal on Material, an inline calendar in a bottom sheet on iOS —
 * one panel, forked by the cascade. Behaviour is composed from the shared overlay
 * primitive (focus trap, scroll lock, Escape, backdrop) plus the engine
 * calendar-grid directive (roving-focus keyboard navigation).
 *
 * `mode`:
 * - `'date'` (default) — a single ISO calendar day (`YYYY-MM-DD`).
 * - `'datetime'` — a day plus a time-of-day; value is `YYYY-MM-DDTHH:mm` (24-hour,
 *   no seconds, no timezone — local wall-clock semantics). The MD UI is an MD3
 *   time input (HH:MM fields, AM/PM segmented on 12-hour locales); iOS shows the
 *   classic momentum wheel (hour/minute columns) via the shared Picker engine —
 *   centering and momentum are correct and the selected value scrolls into view
 *   on open.
 * - `'range'` — a start/end pair; value is `{ start, end }` (ISO days). Tap a
 *   start day then an end day; the span between highlights.
 *
 * Values cross the API as ISO strings, never `Date` objects, to avoid timezone
 * drift; all date/range math lives in `@touchcn/core`. Selecting updates an
 * internal draft; Cancel/OK (Done) commit or discard. `min`/`max` (ISO days)
 * disable out-of-range days in every mode.
 *
 * API shape: `date`/`datetime` use the `value` model (`string | null`); `range`
 * uses the separately-typed `range` model (`TcnDateRange | null`). Two precisely
 * typed models beat one polymorphic one under Angular's invariant two-way binding
 * and keep single-date usage unchanged.
 */
@Component({
  selector: 'tcn-datepicker',
  imports: [TcnOverlayDirective, TcnCalendarGridDirective, TcnPicker, TcnPickerColumn],
  template: `
    <div class="tcn-datepicker relative block">
      @if (label()) {
        <span class="tcn-datepicker-label" [id]="labelId">{{ label() }}</span>
      }
      <button
        type="button"
        class="tcn-datepicker-trigger"
        [attr.aria-haspopup]="'dialog'"
        [attr.aria-expanded]="open()"
        [attr.aria-labelledby]="label() ? labelId : null"
        [disabled]="disabled()"
        (click)="openPicker()"
      >
        <svg
          class="tcn-datepicker-trigger-icon"
          width="20"
          height="20"
          viewBox="0 0 24 24"
          fill="none"
          aria-hidden="true"
        >
          <rect x="3" y="4.5" width="18" height="16" rx="2.5" stroke="currentColor" stroke-width="1.8" />
          <path d="M3 9h18M8 3v3M16 3v3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
        </svg>
        <span class="tcn-datepicker-value" [class.tcn-datepicker-placeholder]="!triggerLabel()">
          {{ triggerLabel() || placeholder() }}
        </span>
      </button>

      <div class="tcn-datepicker-overlay" [class.pointer-events-none]="!open()" [attr.data-state]="state()">
        <div class="tcn-overlay-backdrop tcn-datepicker-backdrop fixed inset-0 z-40" (click)="overlay.dismiss()"></div>
        <div
          #overlay="tcnOverlay"
          tcnOverlay
          [(open)]="open"
          (dismissed)="cancel()"
          class="tcn-overlay-panel tcn-datepicker-panel z-50"
        >
          <!-- iOS bottom-sheet toolbar -->
          <div class="tcn-datepicker-toolbar if-ios">
            <button type="button" class="tcn-datepicker-toolbar-action" (click)="cancel()">Cancel</button>
            <span class="tcn-datepicker-toolbar-title">{{ label() || defaultTitle() }}</span>
            <button type="button" class="tcn-datepicker-toolbar-action tcn-datepicker-toolbar-confirm" (click)="confirm()">
              Done
            </button>
          </div>

          <!-- MD3 modal header -->
          <div class="tcn-datepicker-header if-md">
            <span class="tcn-datepicker-supporting">{{ defaultTitle() }}</span>
            <span class="tcn-datepicker-headline">{{ headline() }}</span>
          </div>

          <div class="tcn-datepicker-nav">
            <button
              type="button"
              class="tcn-datepicker-month-toggle"
              [attr.aria-expanded]="yearView()"
              (click)="toggleYearView()"
            >
              <span>{{ monthLabel() }}</span>
              <svg
                class="tcn-datepicker-month-caret"
                width="18"
                height="18"
                viewBox="0 0 24 24"
                fill="none"
                aria-hidden="true"
              >
                <path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
              </svg>
            </button>
            <div class="tcn-datepicker-arrows" [class.tcn-datepicker-arrows-hidden]="yearView()">
              <button
                type="button"
                class="tcn-datepicker-arrow"
                aria-label="Previous month"
                [disabled]="prevDisabled()"
                (click)="prevMonth()"
              >
                <svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                  <path d="m15 18-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
                </svg>
              </button>
              <button
                type="button"
                class="tcn-datepicker-arrow"
                aria-label="Next month"
                [disabled]="nextDisabled()"
                (click)="nextMonth()"
              >
                <svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                  <path d="m9 18 6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
                </svg>
              </button>
            </div>
          </div>

          @if (yearView()) {
            <div class="tcn-datepicker-years" role="listbox" aria-label="Year">
              @for (year of years(); track year) {
                <button
                  type="button"
                  role="option"
                  class="tcn-datepicker-year"
                  [attr.aria-selected]="year === viewYear()"
                  [attr.data-selected]="year === viewYear() || null"
                  (click)="selectYear(year)"
                >
                  {{ year }}
                </button>
              }
            </div>
          } @else {
            <div class="tcn-datepicker-weekdays" aria-hidden="true">
              @for (weekday of weekdays(); track $index) {
                <span class="tcn-datepicker-weekday">{{ weekday }}</span>
              }
            </div>
            <div
              #grid="tcnCalendarGrid"
              tcnCalendarGrid
              role="grid"
              [attr.aria-labelledby]="label() ? labelId : null"
              class="tcn-datepicker-grid"
              [focusedDate]="focusedDate()"
              [active]="open() && !yearView()"
              [min]="min()"
              [max]="max()"
              [firstDayOfWeek]="firstDayOfWeek()"
              (focusedDateChange)="onFocusedDateChange($event)"
              (select)="selectDay($event)"
            >
              @for (week of weeks(); track $index) {
                <div role="row" class="tcn-datepicker-week">
                  @for (cell of week; track cell.iso) {
                    <div
                      role="gridcell"
                      class="tcn-datepicker-cell"
                      [attr.aria-selected]="ariaSelected(cell.iso)"
                      [attr.data-range-start]="isRangeStart(cell.iso) || null"
                      [attr.data-range-end]="isRangeEnd(cell.iso) || null"
                      [attr.data-in-range]="isInBand(cell.iso) || null"
                    >
                      <button
                        type="button"
                        class="tcn-datepicker-day"
                        [attr.data-iso]="cell.iso"
                        [attr.data-outside]="!cell.inCurrentMonth || null"
                        [attr.data-today]="isToday(cell.iso) || null"
                        [attr.data-selected]="isEndpoint(cell.iso) || null"
                        [attr.aria-current]="isToday(cell.iso) ? 'date' : null"
                        [attr.aria-label]="cellLabel(cell.iso)"
                        [attr.tabindex]="cell.iso === focusedDate() ? 0 : -1"
                        [disabled]="!inRange(cell.iso)"
                        (click)="selectDay(cell.iso)"
                      >
                        {{ cell.day }}
                      </button>
                    </div>
                  }
                </div>
              }
            </div>

            @if (mode() === 'datetime') {
              <div class="tcn-datepicker-time">
                <!-- iOS: the classic momentum wheel, powered by the shared Picker engine. -->
                <tcn-picker class="tcn-datepicker-time-picker if-ios">
                  <tcn-picker-column
                    label="Hour"
                    [options]="hourOptions()"
                    [value]="draftHour()"
                    (valueChange)="setHour($any($event))"
                  />
                  <tcn-picker-column
                    label="Minute"
                    [options]="minuteOptions()"
                    [value]="draftMinute()"
                    (valueChange)="setMinute($any($event))"
                  />
                </tcn-picker>
                <!-- MD3 time input: HH:MM fields, AM/PM segmented on 12-hour locales. -->
                <div class="tcn-datepicker-time-fields if-md">
                  <input
                    class="tcn-datepicker-time-input"
                    inputmode="numeric"
                    aria-label="Hour"
                    [value]="pad(displayHour())"
                    (change)="onHourInput($any($event.target).value)"
                  />
                  <span class="tcn-datepicker-time-colon" aria-hidden="true">:</span>
                  <input
                    class="tcn-datepicker-time-input"
                    inputmode="numeric"
                    aria-label="Minute"
                    [value]="pad(draftMinute())"
                    (change)="onMinuteInput($any($event.target).value)"
                  />
                  @if (hour12()) {
                    <div class="tcn-datepicker-meridiem" role="group" aria-label="AM or PM">
                      <button
                        type="button"
                        class="tcn-datepicker-meridiem-option"
                        [attr.data-selected]="meridiem() === 'AM' || null"
                        (click)="setMeridiem('AM')"
                      >
                        AM
                      </button>
                      <button
                        type="button"
                        class="tcn-datepicker-meridiem-option"
                        [attr.data-selected]="meridiem() === 'PM' || null"
                        (click)="setMeridiem('PM')"
                      >
                        PM
                      </button>
                    </div>
                  }
                </div>
              </div>
            }
          }

          <!-- MD3 confirm/cancel actions -->
          <div class="tcn-datepicker-actions if-md">
            <button type="button" class="tcn-datepicker-action" (click)="cancel()">Cancel</button>
            <button type="button" class="tcn-datepicker-action tcn-datepicker-action-confirm" (click)="confirm()">
              OK
            </button>
          </div>
        </div>
      </div>
    </div>
  `,
  host: { class: 'block' },
})
export class TcnDatepicker {
  readonly value = model<string | null>(null);
  readonly range = model<TcnDateRange | null>(null);
  readonly mode = input<'date' | 'datetime' | 'range'>('date');
  readonly min = input<string | null>(null);
  readonly max = input<string | null>(null);
  readonly locale = input('');
  readonly label = input('');
  readonly placeholder = input('Select date');
  readonly disabled = input(false, { transform: booleanAttribute });
  readonly open = model(false);

  protected readonly labelId = `tcn-datepicker-label-${nextId++}`;

  protected readonly draft = signal<string | null>(null);
  protected readonly draftHour = signal(0);
  protected readonly draftMinute = signal(0);
  protected readonly draftStart = signal<string | null>(null);
  protected readonly draftEnd = signal<string | null>(null);
  protected readonly focusedDate = signal<string | null>(null);
  protected readonly viewYear = signal(new Date().getFullYear());
  protected readonly viewMonth = signal(new Date().getMonth());
  protected readonly yearView = signal(false);

  private readonly localeOption = computed(() => resolveLocale(this.locale() || undefined));

  protected readonly firstDayOfWeek = computed(() => resolveFirstDayOfWeek(this.localeOption()));
  protected readonly weekdays = computed(() => getWeekdayLabels(this.localeOption(), this.firstDayOfWeek()));
  protected readonly weeks = computed(() => getMonthMatrix(this.viewYear(), this.viewMonth(), this.firstDayOfWeek()));
  protected readonly monthLabel = computed(() => formatMonthYear(this.viewYear(), this.viewMonth(), this.localeOption()));
  protected readonly years = computed(() => getYearRange(this.min(), this.max()));
  protected readonly hour12 = computed(() => isLocale12Hour(this.localeOption()));
  protected readonly hours = computed(() => Array.from({ length: 24 }, (_, index) => index));
  protected readonly minutes = computed(() => Array.from({ length: 60 }, (_, index) => index));
  protected readonly hourOptions = computed(() => this.hours().map((hour) => ({ label: this.pad(hour), value: hour })));
  protected readonly minuteOptions = computed(() =>
    this.minutes().map((minute) => ({ label: this.pad(minute), value: minute })),
  );
  protected readonly displayHour = computed(() => (this.hour12() ? this.draftHour() % 12 || 12 : this.draftHour()));
  protected readonly meridiem = computed<'AM' | 'PM'>(() => (this.draftHour() < 12 ? 'AM' : 'PM'));

  protected readonly defaultTitle = computed(() =>
    this.mode() === 'range' ? 'Select dates' : this.mode() === 'datetime' ? 'Select date & time' : 'Select date',
  );

  protected readonly triggerLabel = computed(() => {
    if (this.mode() === 'range') {
      const range = this.range();
      if (!range?.start) {
        return '';
      }
      const start = formatDisplayDate(range.start, this.localeOption());
      const end = range.end ? formatDisplayDate(range.end, this.localeOption()) : '';
      return end ? `${start} – ${end}` : start;
    }
    const value = this.value();
    if (!value) {
      return '';
    }
    return this.mode() === 'datetime'
      ? formatDisplayDateTime(value, this.localeOption())
      : formatDisplayDate(value, this.localeOption());
  });

  protected readonly headline = computed(() => {
    const locale = this.localeOption();
    if (this.mode() === 'range') {
      const start = this.draftStart();
      if (!start) {
        return 'Select dates';
      }
      const startLabel = formatDisplayDate(start, locale, { month: 'short', day: 'numeric' });
      const end = this.draftEnd();
      const endLabel = end ? formatDisplayDate(end, locale, { month: 'short', day: 'numeric' }) : 'End';
      return `${startLabel} – ${endLabel}`;
    }
    const draft = this.draft();
    if (!draft) {
      return this.defaultTitle();
    }
    if (this.mode() === 'datetime') {
      return formatDisplayDateTime(formatIsoDateTime(draft, this.draftHour(), this.draftMinute()), locale, {
        weekday: 'short',
        month: 'short',
        day: 'numeric',
        hour: 'numeric',
        minute: '2-digit',
      });
    }
    return formatDisplayDate(draft, locale, { weekday: 'short', month: 'short', day: 'numeric' });
  });

  protected readonly state = computed(() => (this.open() ? 'open' : 'closed'));

  protected readonly prevDisabled = computed(() => {
    const min = parseIso(this.min());
    if (!min) {
      return false;
    }
    return this.viewYear() < min.year || (this.viewYear() === min.year && this.viewMonth() <= min.month);
  });
  protected readonly nextDisabled = computed(() => {
    const max = parseIso(this.max());
    if (!max) {
      return false;
    }
    return this.viewYear() > max.year || (this.viewYear() === max.year && this.viewMonth() >= max.month);
  });

  constructor() {
    // Reset the year view whenever the picker closes so it reopens on the grid.
    effect(() => {
      if (!this.open()) {
        this.yearView.set(false);
      }
    });
  }

  protected openPicker(): void {
    if (this.disabled()) {
      return;
    }
    let anchor: string;
    if (this.mode() === 'range') {
      const range = this.range();
      this.draftStart.set(range?.start ?? null);
      this.draftEnd.set(range?.end ?? null);
      anchor = clampIso(range?.start ?? todayIso(), this.min(), this.max());
    } else if (this.mode() === 'datetime') {
      const parts = parseIsoDateTime(this.value());
      anchor = clampIso(parts?.date ?? todayIso(), this.min(), this.max());
      this.draft.set(anchor);
      this.draftHour.set(parts?.hour ?? 0);
      this.draftMinute.set(parts?.minute ?? 0);
    } else {
      const iso = this.value();
      anchor = clampIso(iso ?? todayIso(), this.min(), this.max());
      this.draft.set(iso);
    }
    this.focusedDate.set(anchor);
    this.setViewFromIso(anchor);
    this.yearView.set(false);
    this.open.set(true);
  }

  protected selectDay(iso: string): void {
    if (!this.inRange(iso)) {
      return;
    }
    if (this.mode() === 'range') {
      // First tap (or restart after a complete range) sets the start; the second
      // tap sets the end, ordered chronologically so the band renders either way.
      if (!this.draftStart() || this.draftEnd()) {
        this.draftStart.set(iso);
        this.draftEnd.set(null);
      } else {
        const { start, end } = normalizeRange(this.draftStart(), iso);
        this.draftStart.set(start);
        this.draftEnd.set(end);
      }
    } else {
      this.draft.set(iso);
    }
    this.focusedDate.set(iso);
    this.setViewFromIso(iso);
  }

  protected onFocusedDateChange(iso: string): void {
    this.focusedDate.set(iso);
    this.setViewFromIso(iso);
  }

  protected confirm(): void {
    if (this.mode() === 'range') {
      const { start, end } = normalizeRange(this.draftStart(), this.draftEnd());
      if (start) {
        this.range.set({ start, end: end ?? start });
      }
    } else if (this.mode() === 'datetime') {
      const draft = this.draft();
      if (draft) {
        this.value.set(formatIsoDateTime(draft, this.draftHour(), this.draftMinute()));
      }
    } else if (this.draft()) {
      this.value.set(this.draft());
    }
    this.open.set(false);
  }

  protected cancel(): void {
    this.open.set(false);
  }

  protected setHour(hour: number): void {
    this.draftHour.set(hour);
  }

  protected setMinute(minute: number): void {
    this.draftMinute.set(minute);
  }

  protected onHourInput(raw: string): void {
    const parsed = parseInt(raw, 10);
    if (Number.isNaN(parsed)) {
      return;
    }
    if (this.hour12()) {
      const clamped = Math.min(Math.max(parsed, 1), 12) % 12;
      this.draftHour.set(this.meridiem() === 'PM' ? clamped + 12 : clamped);
    } else {
      this.draftHour.set(Math.min(Math.max(parsed, 0), 23));
    }
  }

  protected onMinuteInput(raw: string): void {
    const parsed = parseInt(raw, 10);
    if (Number.isNaN(parsed)) {
      return;
    }
    this.draftMinute.set(Math.min(Math.max(parsed, 0), 59));
  }

  protected setMeridiem(meridiem: 'AM' | 'PM'): void {
    const base = this.displayHour() % 12;
    this.draftHour.set(meridiem === 'PM' ? base + 12 : base);
  }

  protected prevMonth(): void {
    this.shiftMonth(-1);
  }

  protected nextMonth(): void {
    this.shiftMonth(1);
  }

  protected toggleYearView(): void {
    this.yearView.update((open) => !open);
  }

  protected selectYear(year: number): void {
    this.viewYear.set(year);
    const focused = parseIso(this.focusedDate() ?? todayIso());
    if (focused) {
      const next = clampIso(formatIso({ year, month: this.viewMonth(), day: focused.day }), this.min(), this.max());
      this.focusedDate.set(next);
      this.setViewFromIso(next);
    }
    this.yearView.set(false);
  }

  protected pad(value: number): string {
    return String(value).padStart(2, '0');
  }

  protected rangeState(iso: string): ReturnType<typeof getRangeCellState> {
    return getRangeCellState(iso, this.draftStart(), this.draftEnd());
  }

  protected isEndpoint(iso: string): boolean {
    if (this.mode() === 'range') {
      const state = this.rangeState(iso);
      return state === 'start' || state === 'end' || state === 'both';
    }
    return isSameDay(iso, this.draft());
  }

  protected isRangeStart(iso: string): boolean {
    return this.mode() === 'range' && this.rangeState(iso) === 'start';
  }

  protected isRangeEnd(iso: string): boolean {
    return this.mode() === 'range' && this.rangeState(iso) === 'end';
  }

  protected isInBand(iso: string): boolean {
    return this.mode() === 'range' && this.rangeState(iso) === 'inside';
  }

  protected ariaSelected(iso: string): boolean {
    return this.mode() === 'range' ? this.rangeState(iso) !== 'none' : this.isEndpoint(iso);
  }

  protected isToday(iso: string): boolean {
    return isSameDay(iso, todayIso());
  }

  protected inRange(iso: string): boolean {
    return isInRange(iso, this.min(), this.max());
  }

  protected cellLabel(iso: string): string {
    return formatDisplayDate(iso, this.localeOption(), { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
  }

  private shiftMonth(delta: number): void {
    // Keep the roving-focus target inside the newly shown month so the grid
    // always has a focusable cell (mouse clicks focus silently; keyboard rings).
    const base = this.focusedDate() ?? formatIso({ year: this.viewYear(), month: this.viewMonth(), day: 1 });
    const next = clampIso(addMonths(base, delta), this.min(), this.max());
    this.focusedDate.set(next);
    this.setViewFromIso(next);
  }

  private setViewFromIso(iso: string): void {
    const parts = parseIso(iso);
    if (parts) {
      this.viewYear.set(parts.year);
      this.viewMonth.set(parts.month);
    }
  }
}

let nextId = 0;
Source · React
export * from './tcn-datepicker';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import {
  addMonths,
  clampIso,
  formatDisplayDate,
  formatDisplayDateTime,
  formatIso,
  formatIsoDateTime,
  formatMonthYear,
  getMonthMatrix,
  getRangeCellState,
  getWeekdayLabels,
  getYearRange,
  isInRange,
  isLocale12Hour,
  isSameDay,
  normalizeRange,
  parseIso,
  parseIsoDateTime,
  resolveFirstDayOfWeek,
  resolveLocale,
  todayIso,
  type DateRange,
} from '@touchcn/core';
import { useCalendarGridKeyNav, useOverlayPresence } from '@touchcn/react';
import { TcnPicker, TcnPickerColumn } from '@/components/ui/picker';

/** Start/end date pair used by `range` mode (ISO `YYYY-MM-DD` strings). */
export type TcnDateRange = DateRange;

export interface TcnDatepickerProps {
  /** Selected value for `date` / `datetime` mode (`YYYY-MM-DD` or `YYYY-MM-DDTHH:mm`). */
  value?: string | null;
  onValueChange?(value: string): void;
  /** Selected pair for `range` mode. */
  range?: TcnDateRange | null;
  onRangeChange?(range: TcnDateRange): void;
  /** `'date'` (default), `'datetime'` (day + time), or `'range'` (start/end pair). */
  mode?: 'date' | 'datetime' | 'range';
  /** Inclusive lower bound (ISO day); earlier days are disabled. */
  min?: string | null;
  /** Inclusive upper bound (ISO day); later days are disabled. */
  max?: string | null;
  /** BCP-47 locale for month/weekday names, week start, and 12/24-hour time; defaults to the browser. */
  locale?: string;
  label?: string;
  placeholder?: string;
  disabled?: boolean;
}

/**
 * Date picker with three modes. The trigger renders as an input-like field
 * showing the formatted value; opening reveals a platform-appropriate calendar —
 * a centered MD3 modal on Material, an inline calendar in a bottom sheet on iOS —
 * one panel, forked by `theme.css`. Built on Radix Dialog (focus trap, scroll
 * lock, Escape) plus the engine `useCalendarGridKeyNav` (roving-focus keyboard
 * navigation), keeping the copied markup identical to the Angular component.
 *
 * `mode`:
 * - `'date'` (default) — a single ISO calendar day (`YYYY-MM-DD`).
 * - `'datetime'` — a day plus a time-of-day; value is `YYYY-MM-DDTHH:mm` (24-hour,
 *   no seconds, no timezone — local wall-clock semantics). The MD UI is an MD3
 *   time input (HH:MM fields, AM/PM segmented on 12-hour locales); iOS shows an
 *   interim pair of scroll-snap columns (the seam to swap for the wheel Picker
 *   engine in Round B — the full momentum wheel is out of scope here).
 * - `'range'` — a start/end pair; value is `{ start, end }` (ISO days).
 *
 * Values cross the API as ISO strings, never `Date` objects, to avoid timezone
 * drift; all date/range math lives in `@touchcn/core`. `date`/`datetime` use the
 * `value` / `onValueChange` pair; `range` uses `range` / `onRangeChange` — two
 * precisely typed models, symmetric with the Angular component.
 */
export function TcnDatepicker({
  value,
  onValueChange,
  range: rangeValue,
  onRangeChange,
  mode = 'date',
  min,
  max,
  locale,
  label,
  placeholder = 'Select date',
  disabled,
}: TcnDatepickerProps) {
  const [open, setOpen] = useState(false);
  const [draft, setDraft] = useState<string | null>(null);
  const [draftHour, setDraftHour] = useState(0);
  const [draftMinute, setDraftMinute] = useState(0);
  const [draftStart, setDraftStart] = useState<string | null>(null);
  const [draftEnd, setDraftEnd] = useState<string | null>(null);
  const [focusedDate, setFocusedDate] = useState<string | null>(null);
  const [view, setView] = useState(() => {
    const now = new Date();
    return { year: now.getFullYear(), month: now.getMonth() };
  });
  const [yearView, setYearView] = useState(false);

  const { present, active, panelRef } = useOverlayPresence(open);
  const gridRef = useRef<HTMLDivElement>(null);
  const labelId = useId();

  const localeOption = resolveLocale(locale);
  const firstDayOfWeek = useMemo(() => resolveFirstDayOfWeek(localeOption), [localeOption]);
  const weekdays = useMemo(() => getWeekdayLabels(localeOption, firstDayOfWeek), [localeOption, firstDayOfWeek]);
  const weeks = useMemo(
    () => getMonthMatrix(view.year, view.month, firstDayOfWeek),
    [view.year, view.month, firstDayOfWeek],
  );
  const monthLabel = useMemo(() => formatMonthYear(view.year, view.month, localeOption), [view, localeOption]);
  const years = useMemo(() => getYearRange(min, max), [min, max]);
  const hour12 = useMemo(() => isLocale12Hour(localeOption), [localeOption]);
  const hours = useMemo(() => Array.from({ length: 24 }, (_, index) => index), []);
  const minutes = useMemo(() => Array.from({ length: 60 }, (_, index) => index), []);
  const hourOptions = useMemo(
    () => hours.map((hour) => ({ label: String(hour).padStart(2, '0'), value: hour })),
    [hours],
  );
  const minuteOptions = useMemo(
    () => minutes.map((minute) => ({ label: String(minute).padStart(2, '0'), value: minute })),
    [minutes],
  );
  const displayHour = hour12 ? draftHour % 12 || 12 : draftHour;
  const meridiem: 'AM' | 'PM' = draftHour < 12 ? 'AM' : 'PM';

  const defaultTitle = mode === 'range' ? 'Select dates' : mode === 'datetime' ? 'Select date & time' : 'Select date';

  const pad = (input: number) => String(input).padStart(2, '0');

  const triggerLabel = (() => {
    if (mode === 'range') {
      if (!rangeValue?.start) {
        return '';
      }
      const start = formatDisplayDate(rangeValue.start, localeOption);
      const end = rangeValue.end ? formatDisplayDate(rangeValue.end, localeOption) : '';
      return end ? `${start} – ${end}` : start;
    }
    if (!value) {
      return '';
    }
    return mode === 'datetime' ? formatDisplayDateTime(value, localeOption) : formatDisplayDate(value, localeOption);
  })();

  const headline = (() => {
    if (mode === 'range') {
      if (!draftStart) {
        return 'Select dates';
      }
      const startLabel = formatDisplayDate(draftStart, localeOption, { month: 'short', day: 'numeric' });
      const endLabel = draftEnd ? formatDisplayDate(draftEnd, localeOption, { month: 'short', day: 'numeric' }) : 'End';
      return `${startLabel} – ${endLabel}`;
    }
    if (!draft) {
      return defaultTitle;
    }
    if (mode === 'datetime') {
      return formatDisplayDateTime(formatIsoDateTime(draft, draftHour, draftMinute), localeOption, {
        weekday: 'short',
        month: 'short',
        day: 'numeric',
        hour: 'numeric',
        minute: '2-digit',
      });
    }
    return formatDisplayDate(draft, localeOption, { weekday: 'short', month: 'short', day: 'numeric' });
  })();

  const minParts = parseIso(min ?? undefined);
  const maxParts = parseIso(max ?? undefined);
  const prevDisabled = !!minParts && (view.year < minParts.year || (view.year === minParts.year && view.month <= minParts.month));
  const nextDisabled = !!maxParts && (view.year > maxParts.year || (view.year === maxParts.year && view.month >= maxParts.month));

  const setViewFromIso = (iso: string) => {
    const parts = parseIso(iso);
    if (parts) {
      setView({ year: parts.year, month: parts.month });
    }
  };

  useEffect(() => {
    if (!open) {
      setYearView(false);
    }
  }, [open]);

  const openPicker = () => {
    if (disabled) {
      return;
    }
    let anchor: string;
    if (mode === 'range') {
      setDraftStart(rangeValue?.start ?? null);
      setDraftEnd(rangeValue?.end ?? null);
      anchor = clampIso(rangeValue?.start ?? todayIso(), min, max);
    } else if (mode === 'datetime') {
      const parts = parseIsoDateTime(value);
      anchor = clampIso(parts?.date ?? todayIso(), min, max);
      setDraft(anchor);
      setDraftHour(parts?.hour ?? 0);
      setDraftMinute(parts?.minute ?? 0);
    } else {
      const iso = value ?? null;
      anchor = clampIso(iso ?? todayIso(), min, max);
      setDraft(iso);
    }
    setFocusedDate(anchor);
    setViewFromIso(anchor);
    setYearView(false);
    setOpen(true);
  };

  const inRange = (iso: string) => isInRange(iso, min, max);

  const selectDay = (iso: string) => {
    if (!inRange(iso)) {
      return;
    }
    if (mode === 'range') {
      // First tap (or restart after a complete range) sets the start; the second
      // tap sets the end, ordered chronologically so the band renders either way.
      if (!draftStart || draftEnd) {
        setDraftStart(iso);
        setDraftEnd(null);
      } else {
        const { start, end } = normalizeRange(draftStart, iso);
        setDraftStart(start);
        setDraftEnd(end);
      }
    } else {
      setDraft(iso);
    }
    setFocusedDate(iso);
    setViewFromIso(iso);
  };

  const onFocusedDateChange = (iso: string) => {
    setFocusedDate(iso);
    setViewFromIso(iso);
  };

  const confirm = () => {
    if (mode === 'range') {
      const { start, end } = normalizeRange(draftStart, draftEnd);
      if (start) {
        onRangeChange?.({ start, end: end ?? start });
      }
    } else if (mode === 'datetime') {
      if (draft) {
        onValueChange?.(formatIsoDateTime(draft, draftHour, draftMinute));
      }
    } else if (draft) {
      onValueChange?.(draft);
    }
    setOpen(false);
  };

  const cancel = () => setOpen(false);

  const onHourInput = (raw: string) => {
    const parsed = parseInt(raw, 10);
    if (Number.isNaN(parsed)) {
      return;
    }
    if (hour12) {
      const clamped = Math.min(Math.max(parsed, 1), 12) % 12;
      setDraftHour(meridiem === 'PM' ? clamped + 12 : clamped);
    } else {
      setDraftHour(Math.min(Math.max(parsed, 0), 23));
    }
  };

  const onMinuteInput = (raw: string) => {
    const parsed = parseInt(raw, 10);
    if (Number.isNaN(parsed)) {
      return;
    }
    setDraftMinute(Math.min(Math.max(parsed, 0), 59));
  };

  const setMeridiem = (next: 'AM' | 'PM') => {
    const base = displayHour % 12;
    setDraftHour(next === 'PM' ? base + 12 : base);
  };

  const shiftMonth = (delta: number) => {
    // Keep the roving-focus target inside the newly shown month so the grid
    // always has a focusable cell (mouse clicks focus silently; keyboard rings).
    const base = focusedDate ?? formatIso({ year: view.year, month: view.month, day: 1 });
    const next = clampIso(addMonths(base, delta), min, max);
    setFocusedDate(next);
    setViewFromIso(next);
  };

  const selectYear = (year: number) => {
    const focused = parseIso(focusedDate ?? todayIso());
    if (focused) {
      const next = clampIso(formatIso({ year, month: view.month, day: focused.day }), min, max);
      setFocusedDate(next);
      setViewFromIso(next);
    } else {
      setView((prev) => ({ ...prev, year }));
    }
    setYearView(false);
  };

  const { onKeyDown } = useCalendarGridKeyNav(gridRef, {
    focusedDate,
    active: active && !yearView,
    min,
    max,
    firstDayOfWeek,
    onFocusedDateChange,
    onSelect: selectDay,
  });

  const cellLabel = (iso: string) =>
    formatDisplayDate(iso, localeOption, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });

  const rangeState = (iso: string) => getRangeCellState(iso, draftStart, draftEnd);
  const isEndpoint = (iso: string) => {
    if (mode === 'range') {
      const state = rangeState(iso);
      return state === 'start' || state === 'end' || state === 'both';
    }
    return isSameDay(iso, draft);
  };
  const isRangeStart = (iso: string) => mode === 'range' && rangeState(iso) === 'start';
  const isRangeEnd = (iso: string) => mode === 'range' && rangeState(iso) === 'end';
  const isInBand = (iso: string) => mode === 'range' && rangeState(iso) === 'inside';
  const ariaSelected = (iso: string) => (mode === 'range' ? rangeState(iso) !== 'none' : isEndpoint(iso));

  const state = active ? 'open' : 'closed';

  return (
    <div className="tcn-datepicker relative block">
      {label && (
        <span className="tcn-datepicker-label" id={labelId}>
          {label}
        </span>
      )}
      <button
        type="button"
        className="tcn-datepicker-trigger"
        aria-haspopup="dialog"
        aria-expanded={open}
        aria-labelledby={label ? labelId : undefined}
        disabled={disabled}
        onClick={openPicker}
      >
        <svg className="tcn-datepicker-trigger-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
          <rect x="3" y="4.5" width="18" height="16" rx="2.5" stroke="currentColor" strokeWidth="1.8" />
          <path d="M3 9h18M8 3v3M16 3v3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
        </svg>
        <span className={`tcn-datepicker-value${triggerLabel ? '' : ' tcn-datepicker-placeholder'}`}>
          {triggerLabel || placeholder}
        </span>
      </button>

      {present && (
        <Dialog.Root open onOpenChange={(next) => !next && cancel()}>
          <Dialog.Portal>
            <div className="tcn-datepicker-overlay" data-state={state}>
              <Dialog.Overlay className="tcn-overlay-backdrop tcn-datepicker-backdrop fixed inset-0 z-40" />
              <Dialog.Content
                ref={panelRef}
                aria-describedby={undefined}
                className="tcn-overlay-panel tcn-datepicker-panel z-50"
              >
                <Dialog.Title className="sr-only">{label || defaultTitle}</Dialog.Title>

                {/* iOS bottom-sheet toolbar */}
                <div className="tcn-datepicker-toolbar if-ios">
                  <button type="button" className="tcn-datepicker-toolbar-action" onClick={cancel}>
                    Cancel
                  </button>
                  <span className="tcn-datepicker-toolbar-title">{label || defaultTitle}</span>
                  <button
                    type="button"
                    className="tcn-datepicker-toolbar-action tcn-datepicker-toolbar-confirm"
                    onClick={confirm}
                  >
                    Done
                  </button>
                </div>

                {/* MD3 modal header */}
                <div className="tcn-datepicker-header if-md">
                  <span className="tcn-datepicker-supporting">{defaultTitle}</span>
                  <span className="tcn-datepicker-headline">{headline}</span>
                </div>

                <div className="tcn-datepicker-nav">
                  <button
                    type="button"
                    className="tcn-datepicker-month-toggle"
                    aria-expanded={yearView}
                    onClick={() => setYearView((prev) => !prev)}
                  >
                    <span>{monthLabel}</span>
                    <svg className="tcn-datepicker-month-caret" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                      <path d="m6 9 6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
                    </svg>
                  </button>
                  <div className={`tcn-datepicker-arrows${yearView ? ' tcn-datepicker-arrows-hidden' : ''}`}>
                    <button
                      type="button"
                      className="tcn-datepicker-arrow"
                      aria-label="Previous month"
                      disabled={prevDisabled}
                      onClick={() => shiftMonth(-1)}
                    >
                      <svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                        <path d="m15 18-6-6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
                      </svg>
                    </button>
                    <button
                      type="button"
                      className="tcn-datepicker-arrow"
                      aria-label="Next month"
                      disabled={nextDisabled}
                      onClick={() => shiftMonth(1)}
                    >
                      <svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                        <path d="m9 18 6-6-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
                      </svg>
                    </button>
                  </div>
                </div>

                {yearView ? (
                  <div className="tcn-datepicker-years" role="listbox" aria-label="Year">
                    {years.map((year) => (
                      <button
                        key={year}
                        type="button"
                        role="option"
                        className="tcn-datepicker-year"
                        aria-selected={year === view.year}
                        data-selected={year === view.year || undefined}
                        onClick={() => selectYear(year)}
                      >
                        {year}
                      </button>
                    ))}
                  </div>
                ) : (
                  <>
                    <div className="tcn-datepicker-weekdays" aria-hidden="true">
                      {weekdays.map((weekday, index) => (
                        <span key={index} className="tcn-datepicker-weekday">
                          {weekday}
                        </span>
                      ))}
                    </div>
                    <div
                      ref={gridRef}
                      role="grid"
                      aria-labelledby={label ? labelId : undefined}
                      className="tcn-datepicker-grid"
                      onKeyDown={onKeyDown}
                    >
                      {weeks.map((week, weekIndex) => (
                        <div key={weekIndex} role="row" className="tcn-datepicker-week">
                          {week.map((cell) => {
                            const today = isSameDay(cell.iso, todayIso());
                            return (
                              <div
                                key={cell.iso}
                                role="gridcell"
                                className="tcn-datepicker-cell"
                                aria-selected={ariaSelected(cell.iso)}
                                data-range-start={isRangeStart(cell.iso) || undefined}
                                data-range-end={isRangeEnd(cell.iso) || undefined}
                                data-in-range={isInBand(cell.iso) || undefined}
                              >
                                <button
                                  type="button"
                                  className="tcn-datepicker-day"
                                  data-iso={cell.iso}
                                  data-outside={!cell.inCurrentMonth || undefined}
                                  data-today={today || undefined}
                                  data-selected={isEndpoint(cell.iso) || undefined}
                                  aria-current={today ? 'date' : undefined}
                                  aria-label={cellLabel(cell.iso)}
                                  tabIndex={cell.iso === focusedDate ? 0 : -1}
                                  disabled={!inRange(cell.iso)}
                                  onClick={() => selectDay(cell.iso)}
                                >
                                  {cell.day}
                                </button>
                              </div>
                            );
                          })}
                        </div>
                      ))}
                    </div>

                    {mode === 'datetime' && (
                      <div className="tcn-datepicker-time">
                        {/* iOS: the classic momentum wheel, powered by the shared Picker engine. */}
                        <TcnPicker className="tcn-datepicker-time-picker if-ios">
                          <TcnPickerColumn
                            label="Hour"
                            options={hourOptions}
                            value={draftHour}
                            onValueChange={(value) => setDraftHour(Number(value))}
                          />
                          <TcnPickerColumn
                            label="Minute"
                            options={minuteOptions}
                            value={draftMinute}
                            onValueChange={(value) => setDraftMinute(Number(value))}
                          />
                        </TcnPicker>
                        {/* MD3 time input: HH:MM fields, AM/PM segmented on 12-hour locales. */}
                        <div className="tcn-datepicker-time-fields if-md">
                          <input
                            className="tcn-datepicker-time-input"
                            inputMode="numeric"
                            aria-label="Hour"
                            value={pad(displayHour)}
                            onChange={(event) => onHourInput(event.target.value)}
                          />
                          <span className="tcn-datepicker-time-colon" aria-hidden="true">
                            :
                          </span>
                          <input
                            className="tcn-datepicker-time-input"
                            inputMode="numeric"
                            aria-label="Minute"
                            value={pad(draftMinute)}
                            onChange={(event) => onMinuteInput(event.target.value)}
                          />
                          {hour12 && (
                            <div className="tcn-datepicker-meridiem" role="group" aria-label="AM or PM">
                              <button
                                type="button"
                                className="tcn-datepicker-meridiem-option"
                                data-selected={meridiem === 'AM' || undefined}
                                onClick={() => setMeridiem('AM')}
                              >
                                AM
                              </button>
                              <button
                                type="button"
                                className="tcn-datepicker-meridiem-option"
                                data-selected={meridiem === 'PM' || undefined}
                                onClick={() => setMeridiem('PM')}
                              >
                                PM
                              </button>
                            </div>
                          )}
                        </div>
                      </div>
                    )}
                  </>
                )}

                {/* MD3 confirm/cancel actions */}
                <div className="tcn-datepicker-actions if-md">
                  <button type="button" className="tcn-datepicker-action" onClick={cancel}>
                    Cancel
                  </button>
                  <button type="button" className="tcn-datepicker-action tcn-datepicker-action-confirm" onClick={confirm}>
                    OK
                  </button>
                </div>
              </Dialog.Content>
            </div>
          </Dialog.Portal>
        </Dialog.Root>
      )}
    </div>
  );
}
Source · Vue
<script lang="ts">
import type { DateRange } from '@touchcn/core';

/** Start/end date pair used by `range` mode (ISO `YYYY-MM-DD` strings). */
export type TcnDateRange = DateRange;
</script>

<script setup lang="ts">
import { computed, ref, useId, watch } from 'vue';
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui';
import {
  addMonths,
  clampIso,
  formatDisplayDate,
  formatDisplayDateTime,
  formatIso,
  formatIsoDateTime,
  formatMonthYear,
  getMonthMatrix,
  getRangeCellState,
  getWeekdayLabels,
  getYearRange,
  isInRange,
  isLocale12Hour,
  isSameDay,
  normalizeRange,
  parseIso,
  parseIsoDateTime,
  resolveFirstDayOfWeek,
  resolveLocale,
  todayIso,
} from '@touchcn/core';
import { useCalendarGridKeyNav, useOverlayPresence } from '@touchcn/vue';
import { TcnPicker, TcnPickerColumn } from '@/components/ui/picker';

/**
 * Date picker with three modes. The trigger renders as an input-like field
 * showing the formatted value; opening reveals a platform-appropriate calendar —
 * a centered MD3 modal on Material, an inline calendar in a bottom sheet on iOS —
 * one panel, forked by `theme.css`. Built on reka-ui Dialog (focus trap, scroll
 * lock, Escape) plus the engine `useCalendarGridKeyNav` (roving-focus keyboard
 * navigation), keeping the copied markup identical across frameworks.
 *
 * `mode`:
 * - `'date'` (default) — a single ISO calendar day (`YYYY-MM-DD`).
 * - `'datetime'` — a day plus a time-of-day; value is `YYYY-MM-DDTHH:mm` (24-hour,
 *   no seconds, no timezone — local wall-clock semantics). The MD UI is an MD3
 *   time input (HH:MM fields, AM/PM segmented on 12-hour locales); iOS shows the
 *   classic momentum wheel, powered by the shared Picker engine.
 * - `'range'` — a start/end pair; value is `{ start, end }` (ISO days).
 *
 * Values cross the API as ISO strings, never `Date` objects, to avoid timezone
 * drift; all date/range math lives in `@touchcn/core`. `date`/`datetime` use the
 * default `v-model`; `range` uses `v-model:range` — two precisely typed models,
 * symmetric with the Angular / React components.
 */
const props = withDefaults(
  defineProps<{
    /** `'date'` (default), `'datetime'` (day + time), or `'range'` (start/end pair). */
    mode?: 'date' | 'datetime' | 'range';
    /** Inclusive lower bound (ISO day); earlier days are disabled. */
    min?: string | null;
    /** Inclusive upper bound (ISO day); later days are disabled. */
    max?: string | null;
    /** BCP-47 locale for month/weekday names, week start, and 12/24-hour time; defaults to the browser. */
    locale?: string;
    label?: string;
    placeholder?: string;
    disabled?: boolean;
  }>(),
  { mode: 'date', placeholder: 'Select date' },
);

/** Selected value for `date` / `datetime` mode (`YYYY-MM-DD` or `YYYY-MM-DDTHH:mm`). */
const value = defineModel<string | null>({ default: null });
/** Selected pair for `range` mode. */
const rangeValue = defineModel<TcnDateRange | null>('range', { default: null });

const open = ref(false);
const draft = ref<string | null>(null);
const draftHour = ref(0);
const draftMinute = ref(0);
const draftStart = ref<string | null>(null);
const draftEnd = ref<string | null>(null);
const focusedDate = ref<string | null>(null);
const initialNow = new Date();
const view = ref({ year: initialNow.getFullYear(), month: initialNow.getMonth() });
const yearView = ref(false);

const { present, active, setPanel } = useOverlayPresence(open);
const gridRef = ref<HTMLElement | null>(null);
const labelId = useId();

const localeOption = computed(() => resolveLocale(props.locale));
const firstDayOfWeek = computed(() => resolveFirstDayOfWeek(localeOption.value));
const weekdays = computed(() => getWeekdayLabels(localeOption.value, firstDayOfWeek.value));
const weeks = computed(() => getMonthMatrix(view.value.year, view.value.month, firstDayOfWeek.value));
const monthLabel = computed(() => formatMonthYear(view.value.year, view.value.month, localeOption.value));
const years = computed(() => getYearRange(props.min, props.max));
const hour12 = computed(() => isLocale12Hour(localeOption.value));
const hourOptions = computed(() =>
  Array.from({ length: 24 }, (_, index) => ({ label: String(index).padStart(2, '0'), value: index })),
);
const minuteOptions = computed(() =>
  Array.from({ length: 60 }, (_, index) => ({ label: String(index).padStart(2, '0'), value: index })),
);
const displayHour = computed(() => (hour12.value ? draftHour.value % 12 || 12 : draftHour.value));
const meridiem = computed<'AM' | 'PM'>(() => (draftHour.value < 12 ? 'AM' : 'PM'));

const defaultTitle = computed(() =>
  props.mode === 'range' ? 'Select dates' : props.mode === 'datetime' ? 'Select date & time' : 'Select date',
);

const pad = (input: number): string => String(input).padStart(2, '0');

const triggerLabel = computed(() => {
  if (props.mode === 'range') {
    if (!rangeValue.value?.start) {
      return '';
    }
    const start = formatDisplayDate(rangeValue.value.start, localeOption.value);
    const end = rangeValue.value.end ? formatDisplayDate(rangeValue.value.end, localeOption.value) : '';
    return end ? `${start} – ${end}` : start;
  }
  if (!value.value) {
    return '';
  }
  return props.mode === 'datetime'
    ? formatDisplayDateTime(value.value, localeOption.value)
    : formatDisplayDate(value.value, localeOption.value);
});

const headline = computed(() => {
  if (props.mode === 'range') {
    if (!draftStart.value) {
      return 'Select dates';
    }
    const startLabel = formatDisplayDate(draftStart.value, localeOption.value, { month: 'short', day: 'numeric' });
    const endLabel = draftEnd.value
      ? formatDisplayDate(draftEnd.value, localeOption.value, { month: 'short', day: 'numeric' })
      : 'End';
    return `${startLabel} – ${endLabel}`;
  }
  if (!draft.value) {
    return defaultTitle.value;
  }
  if (props.mode === 'datetime') {
    return formatDisplayDateTime(
      formatIsoDateTime(draft.value, draftHour.value, draftMinute.value),
      localeOption.value,
      { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' },
    );
  }
  return formatDisplayDate(draft.value, localeOption.value, { weekday: 'short', month: 'short', day: 'numeric' });
});

const minParts = computed(() => parseIso(props.min ?? undefined));
const maxParts = computed(() => parseIso(props.max ?? undefined));
const prevDisabled = computed(
  () =>
    !!minParts.value &&
    (view.value.year < minParts.value.year ||
      (view.value.year === minParts.value.year && view.value.month <= minParts.value.month)),
);
const nextDisabled = computed(
  () =>
    !!maxParts.value &&
    (view.value.year > maxParts.value.year ||
      (view.value.year === maxParts.value.year && view.value.month >= maxParts.value.month)),
);

const setViewFromIso = (iso: string): void => {
  const parts = parseIso(iso);
  if (parts) {
    view.value = { year: parts.year, month: parts.month };
  }
};

watch(open, (isOpen) => {
  if (!isOpen) {
    yearView.value = false;
  }
});

const openPicker = (): void => {
  if (props.disabled) {
    return;
  }
  let anchor: string;
  if (props.mode === 'range') {
    draftStart.value = rangeValue.value?.start ?? null;
    draftEnd.value = rangeValue.value?.end ?? null;
    anchor = clampIso(rangeValue.value?.start ?? todayIso(), props.min, props.max);
  } else if (props.mode === 'datetime') {
    const parts = parseIsoDateTime(value.value);
    anchor = clampIso(parts?.date ?? todayIso(), props.min, props.max);
    draft.value = anchor;
    draftHour.value = parts?.hour ?? 0;
    draftMinute.value = parts?.minute ?? 0;
  } else {
    const iso = value.value ?? null;
    anchor = clampIso(iso ?? todayIso(), props.min, props.max);
    draft.value = iso;
  }
  focusedDate.value = anchor;
  setViewFromIso(anchor);
  yearView.value = false;
  open.value = true;
};

const inRange = (iso: string): boolean => isInRange(iso, props.min, props.max);

const selectDay = (iso: string): void => {
  if (!inRange(iso)) {
    return;
  }
  if (props.mode === 'range') {
    // First tap (or restart after a complete range) sets the start; the second
    // tap sets the end, ordered chronologically so the band renders either way.
    if (!draftStart.value || draftEnd.value) {
      draftStart.value = iso;
      draftEnd.value = null;
    } else {
      const { start, end } = normalizeRange(draftStart.value, iso);
      draftStart.value = start;
      draftEnd.value = end;
    }
  } else {
    draft.value = iso;
  }
  focusedDate.value = iso;
  setViewFromIso(iso);
};

const onFocusedDateChange = (iso: string): void => {
  focusedDate.value = iso;
  setViewFromIso(iso);
};

const confirm = (): void => {
  if (props.mode === 'range') {
    const { start, end } = normalizeRange(draftStart.value, draftEnd.value);
    if (start) {
      rangeValue.value = { start, end: end ?? start };
    }
  } else if (props.mode === 'datetime') {
    if (draft.value) {
      value.value = formatIsoDateTime(draft.value, draftHour.value, draftMinute.value);
    }
  } else if (draft.value) {
    value.value = draft.value;
  }
  open.value = false;
};

const cancel = (): void => {
  open.value = false;
};

const onRekaUpdate = (next: boolean): void => {
  if (!next) {
    cancel();
  }
};

const onHourInput = (raw: string): void => {
  const parsed = parseInt(raw, 10);
  if (Number.isNaN(parsed)) {
    return;
  }
  if (hour12.value) {
    const clamped = Math.min(Math.max(parsed, 1), 12) % 12;
    draftHour.value = meridiem.value === 'PM' ? clamped + 12 : clamped;
  } else {
    draftHour.value = Math.min(Math.max(parsed, 0), 23);
  }
};

const onMinuteInput = (raw: string): void => {
  const parsed = parseInt(raw, 10);
  if (Number.isNaN(parsed)) {
    return;
  }
  draftMinute.value = Math.min(Math.max(parsed, 0), 59);
};

const setMeridiem = (next: 'AM' | 'PM'): void => {
  const base = displayHour.value % 12;
  draftHour.value = next === 'PM' ? base + 12 : base;
};

const shiftMonth = (delta: number): void => {
  // Keep the roving-focus target inside the newly shown month so the grid always
  // has a focusable cell (mouse clicks focus silently; keyboard rings).
  const base = focusedDate.value ?? formatIso({ year: view.value.year, month: view.value.month, day: 1 });
  const next = clampIso(addMonths(base, delta), props.min, props.max);
  focusedDate.value = next;
  setViewFromIso(next);
};

const selectYear = (year: number): void => {
  const focused = parseIso(focusedDate.value ?? todayIso());
  if (focused) {
    const next = clampIso(formatIso({ year, month: view.value.month, day: focused.day }), props.min, props.max);
    focusedDate.value = next;
    setViewFromIso(next);
  } else {
    view.value = { ...view.value, year };
  }
  yearView.value = false;
};

const { onKeyDown } = useCalendarGridKeyNav(gridRef, {
  focusedDate: () => focusedDate.value,
  active: () => active.value && !yearView.value,
  min: () => props.min,
  max: () => props.max,
  firstDayOfWeek: () => firstDayOfWeek.value,
  onFocusedDateChange,
  onSelect: selectDay,
});

const cellLabel = (iso: string): string =>
  formatDisplayDate(iso, localeOption.value, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });

const rangeState = (iso: string) => getRangeCellState(iso, draftStart.value, draftEnd.value);
const isEndpoint = (iso: string): boolean => {
  if (props.mode === 'range') {
    const state = rangeState(iso);
    return state === 'start' || state === 'end' || state === 'both';
  }
  return isSameDay(iso, draft.value);
};
const isRangeStart = (iso: string): boolean => props.mode === 'range' && rangeState(iso) === 'start';
const isRangeEnd = (iso: string): boolean => props.mode === 'range' && rangeState(iso) === 'end';
const isInBand = (iso: string): boolean => props.mode === 'range' && rangeState(iso) === 'inside';
const ariaSelected = (iso: string): boolean =>
  props.mode === 'range' ? rangeState(iso) !== 'none' : isEndpoint(iso);

const state = computed(() => (active.value ? 'open' : 'closed'));
const isToday = (iso: string): boolean => isSameDay(iso, todayIso());
</script>

<template>
  <div class="tcn-datepicker relative block">
    <span v-if="label" class="tcn-datepicker-label" :id="labelId">{{ label }}</span>
    <button
      type="button"
      class="tcn-datepicker-trigger"
      aria-haspopup="dialog"
      :aria-expanded="open"
      :aria-labelledby="label ? labelId : undefined"
      :disabled="disabled"
      @click="openPicker"
    >
      <svg
        class="tcn-datepicker-trigger-icon"
        width="20"
        height="20"
        viewBox="0 0 24 24"
        fill="none"
        aria-hidden="true"
      >
        <rect x="3" y="4.5" width="18" height="16" rx="2.5" stroke="currentColor" stroke-width="1.8" />
        <path d="M3 9h18M8 3v3M16 3v3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
      </svg>
      <span :class="['tcn-datepicker-value', !triggerLabel && 'tcn-datepicker-placeholder']">
        {{ triggerLabel || placeholder }}
      </span>
    </button>

    <DialogRoot v-if="present" :open="true" @update:open="onRekaUpdate">
      <DialogPortal>
        <div class="tcn-datepicker-overlay" :data-state="state">
          <DialogOverlay class="tcn-overlay-backdrop tcn-datepicker-backdrop fixed inset-0 z-40" />
          <DialogContent
            :ref="setPanel"
            :aria-describedby="undefined"
            class="tcn-overlay-panel tcn-datepicker-panel z-50"
          >
            <DialogTitle class="sr-only">{{ label || defaultTitle }}</DialogTitle>

            <!-- iOS bottom-sheet toolbar -->
            <div class="tcn-datepicker-toolbar if-ios">
              <button type="button" class="tcn-datepicker-toolbar-action" @click="cancel">Cancel</button>
              <span class="tcn-datepicker-toolbar-title">{{ label || defaultTitle }}</span>
              <button
                type="button"
                class="tcn-datepicker-toolbar-action tcn-datepicker-toolbar-confirm"
                @click="confirm"
              >
                Done
              </button>
            </div>

            <!-- MD3 modal header -->
            <div class="tcn-datepicker-header if-md">
              <span class="tcn-datepicker-supporting">{{ defaultTitle }}</span>
              <span class="tcn-datepicker-headline">{{ headline }}</span>
            </div>

            <div class="tcn-datepicker-nav">
              <button
                type="button"
                class="tcn-datepicker-month-toggle"
                :aria-expanded="yearView"
                @click="yearView = !yearView"
              >
                <span>{{ monthLabel }}</span>
                <svg
                  class="tcn-datepicker-month-caret"
                  width="18"
                  height="18"
                  viewBox="0 0 24 24"
                  fill="none"
                  aria-hidden="true"
                >
                  <path
                    d="m6 9 6 6 6-6"
                    stroke="currentColor"
                    stroke-width="2"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                  />
                </svg>
              </button>
              <div :class="['tcn-datepicker-arrows', yearView && 'tcn-datepicker-arrows-hidden']">
                <button
                  type="button"
                  class="tcn-datepicker-arrow"
                  aria-label="Previous month"
                  :disabled="prevDisabled"
                  @click="shiftMonth(-1)"
                >
                  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                    <path
                      d="m15 18-6-6 6-6"
                      stroke="currentColor"
                      stroke-width="2"
                      stroke-linecap="round"
                      stroke-linejoin="round"
                    />
                  </svg>
                </button>
                <button
                  type="button"
                  class="tcn-datepicker-arrow"
                  aria-label="Next month"
                  :disabled="nextDisabled"
                  @click="shiftMonth(1)"
                >
                  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                    <path
                      d="m9 18 6-6-6-6"
                      stroke="currentColor"
                      stroke-width="2"
                      stroke-linecap="round"
                      stroke-linejoin="round"
                    />
                  </svg>
                </button>
              </div>
            </div>

            <div v-if="yearView" class="tcn-datepicker-years" role="listbox" aria-label="Year">
              <button
                v-for="year in years"
                :key="year"
                type="button"
                role="option"
                class="tcn-datepicker-year"
                :aria-selected="year === view.year"
                :data-selected="year === view.year || undefined"
                @click="selectYear(year)"
              >
                {{ year }}
              </button>
            </div>
            <template v-else>
              <div class="tcn-datepicker-weekdays" aria-hidden="true">
                <span v-for="(weekday, index) in weekdays" :key="index" class="tcn-datepicker-weekday">
                  {{ weekday }}
                </span>
              </div>
              <div
                ref="gridRef"
                role="grid"
                :aria-labelledby="label ? labelId : undefined"
                class="tcn-datepicker-grid"
                @keydown="onKeyDown"
              >
                <div
                  v-for="(week, weekIndex) in weeks"
                  :key="weekIndex"
                  role="row"
                  class="tcn-datepicker-week"
                >
                  <div
                    v-for="cell in week"
                    :key="cell.iso"
                    role="gridcell"
                    class="tcn-datepicker-cell"
                    :aria-selected="ariaSelected(cell.iso)"
                    :data-range-start="isRangeStart(cell.iso) || undefined"
                    :data-range-end="isRangeEnd(cell.iso) || undefined"
                    :data-in-range="isInBand(cell.iso) || undefined"
                  >
                    <button
                      type="button"
                      class="tcn-datepicker-day"
                      :data-iso="cell.iso"
                      :data-outside="!cell.inCurrentMonth || undefined"
                      :data-today="isToday(cell.iso) || undefined"
                      :data-selected="isEndpoint(cell.iso) || undefined"
                      :aria-current="isToday(cell.iso) ? 'date' : undefined"
                      :aria-label="cellLabel(cell.iso)"
                      :tabindex="cell.iso === focusedDate ? 0 : -1"
                      :disabled="!inRange(cell.iso)"
                      @click="selectDay(cell.iso)"
                    >
                      {{ cell.day }}
                    </button>
                  </div>
                </div>
              </div>

              <div v-if="mode === 'datetime'" class="tcn-datepicker-time">
                <!-- iOS: the classic momentum wheel, powered by the shared Picker engine. -->
                <TcnPicker class="tcn-datepicker-time-picker if-ios">
                  <TcnPickerColumn
                    label="Hour"
                    :options="hourOptions"
                    :model-value="draftHour"
                    @update:model-value="draftHour = Number($event)"
                  />
                  <TcnPickerColumn
                    label="Minute"
                    :options="minuteOptions"
                    :model-value="draftMinute"
                    @update:model-value="draftMinute = Number($event)"
                  />
                </TcnPicker>
                <!-- MD3 time input: HH:MM fields, AM/PM segmented on 12-hour locales. -->
                <div class="tcn-datepicker-time-fields if-md">
                  <input
                    class="tcn-datepicker-time-input"
                    inputmode="numeric"
                    aria-label="Hour"
                    :value="pad(displayHour)"
                    @input="onHourInput(($event.target as HTMLInputElement).value)"
                  />
                  <span class="tcn-datepicker-time-colon" aria-hidden="true">:</span>
                  <input
                    class="tcn-datepicker-time-input"
                    inputmode="numeric"
                    aria-label="Minute"
                    :value="pad(draftMinute)"
                    @input="onMinuteInput(($event.target as HTMLInputElement).value)"
                  />
                  <div
                    v-if="hour12"
                    class="tcn-datepicker-meridiem"
                    role="group"
                    aria-label="AM or PM"
                  >
                    <button
                      type="button"
                      class="tcn-datepicker-meridiem-option"
                      :data-selected="meridiem === 'AM' || undefined"
                      @click="setMeridiem('AM')"
                    >
                      AM
                    </button>
                    <button
                      type="button"
                      class="tcn-datepicker-meridiem-option"
                      :data-selected="meridiem === 'PM' || undefined"
                      @click="setMeridiem('PM')"
                    >
                      PM
                    </button>
                  </div>
                </div>
              </div>
            </template>

            <!-- MD3 confirm/cancel actions -->
            <div class="tcn-datepicker-actions if-md">
              <button type="button" class="tcn-datepicker-action" @click="cancel">Cancel</button>
              <button
                type="button"
                class="tcn-datepicker-action tcn-datepicker-action-confirm"
                @click="confirm"
              >
                OK
              </button>
            </div>
          </DialogContent>
        </div>
      </DialogPortal>
    </DialogRoot>
  </div>
</template>
export { default as TcnDatepicker } from './TcnDatepicker.vue';
export type { TcnDateRange } from './TcnDatepicker.vue';

Last updated on July 24, 2026

Was this page helpful?