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';