feat(date-picker): add month/year drill-down navigation and keyboard input trigger

- Add three-level calendar view (days → months → years) via clickable
  header label, allowing fast navigation to distant dates without
  repeated arrow clicks
- Replace double-click text input affordance with a visible keyboard
  icon button; compact/borderless variants show the icon in the
  calendar footer
- Pre-fill text input with locale-aware numeric date (DD.MM.YYYY)
  when a value is already selected
- Add aria-label and aria-pressed to all interactive calendar elements
  for screen reader support
- Update existing tests to reflect new two-button trigger layout
- Add FE-COMP-DATEPICKER-018 through 027 covering drill-down
  view transitions, prev/next behaviour per view, aria-pressed
  state, and keyboard icon trigger
This commit is contained in:
Gio Cettuzzi
2026-06-14 19:08:42 +02:00
committed by Maurice
parent 7d87d87eec
commit 3011cf6c2f
2 changed files with 736 additions and 191 deletions
@@ -1,7 +1,7 @@
import { render, screen, fireEvent, act } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { CustomDatePicker, CustomDateTimePicker } from './CustomDateTimePicker';
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
import { useSettingsStore } from '../../store/settingsStore';
import { CustomDatePicker, CustomDateTimePicker } from './CustomDateTimePicker';
// ─── CustomDatePicker ─────────────────────────────────────────────────────────
@@ -24,7 +24,7 @@ describe('CustomDatePicker', () => {
it('FE-COMP-DATEPICKER-003: shows formatted date when value is set', () => {
render(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
const btn = screen.getByRole('button');
const btn = screen.getAllByRole('button')[0];
// Locale-formatted date should contain "Mar" or "15" or "2026"
expect(btn.textContent).toMatch(/Mar|15|2026/);
});
@@ -32,16 +32,16 @@ describe('CustomDatePicker', () => {
it('FE-COMP-DATEPICKER-004: clicking button opens calendar portal', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
await user.click(screen.getByRole('button'));
const dayBtns = screen.getAllByRole('button').filter(b => /^\d+$/.test(b.textContent?.trim() ?? ''));
await user.click(screen.getAllByRole('button')[0]);
const dayBtns = screen.getAllByRole('button').filter((b) => /^\d+$/.test(b.textContent?.trim() ?? ''));
expect(dayBtns.length).toBeGreaterThan(0);
});
it('FE-COMP-DATEPICKER-005: clicking a day calls onChange with correct ISO date', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="2026-03-01" onChange={onChange} />);
await user.click(screen.getByRole('button')); // open March 2026
const dayBtn = screen.getAllByRole('button').find(b => b.textContent?.trim() === '15');
await user.click(screen.getAllByRole('button')[0]); // open March 2026
const dayBtn = screen.getAllByRole('button').find((b) => b.textContent?.trim() === '15');
await user.click(dayBtn!);
expect(onChange).toHaveBeenCalledWith('2026-03-15');
});
@@ -49,18 +49,17 @@ describe('CustomDatePicker', () => {
it('FE-COMP-DATEPICKER-006: prev month navigation decrements month', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="2026-03-01" onChange={onChange} />);
await user.click(screen.getByRole('button')); // open March 2026
await user.click(screen.getAllByRole('button')[0]); // open March 2026
// Nav buttons have no text content (only SVG icons)
const emptyBtns = screen.getAllByRole('button').filter(b => b.textContent?.trim() === '');
await user.click(emptyBtns[0]); // left chevron = prev month
await user.click(screen.getByRole('button', { name: /previous month/i }));
expect(screen.getByText(/february 2026/i)).toBeTruthy();
});
it('FE-COMP-DATEPICKER-007: next month navigation increments month', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="2026-03-01" onChange={onChange} />);
await user.click(screen.getByRole('button')); // open March 2026
const emptyBtns = screen.getAllByRole('button').filter(b => b.textContent?.trim() === '');
await user.click(screen.getAllByRole('button')[0]); // open March 2026
const emptyBtns = screen.getAllByRole('button').filter((b) => b.textContent?.trim() === '');
await user.click(emptyBtns[emptyBtns.length - 1]); // right chevron = next month
expect(screen.getByText(/april 2026/i)).toBeTruthy();
});
@@ -68,7 +67,7 @@ describe('CustomDatePicker', () => {
it('FE-COMP-DATEPICKER-008: clear button calls onChange with empty string', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
await user.click(screen.getByRole('button')); // open
await user.click(screen.getAllByRole('button')[0]); // open
const clearBtn = screen.getByText('✕');
await user.click(clearBtn);
expect(onChange).toHaveBeenCalledWith('');
@@ -77,16 +76,18 @@ describe('CustomDatePicker', () => {
it('FE-COMP-DATEPICKER-009: clear button absent when no value', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="" onChange={onChange} />);
await user.click(screen.getByRole('button')); // open
await user.click(screen.getAllByRole('button')[0]); // open
expect(screen.queryByText('✕')).toBeNull();
});
it('FE-COMP-DATEPICKER-010: clicking outside calendar closes it', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
await user.click(screen.getByRole('button')); // open
await user.click(screen.getAllByRole('button')[0]); // open
// Verify calendar is open (day buttons present)
expect(screen.getAllByRole('button').filter(b => /^\d+$/.test(b.textContent?.trim() ?? '')).length).toBeGreaterThan(0);
expect(
screen.getAllByRole('button').filter((b) => /^\d+$/.test(b.textContent?.trim() ?? '')).length
).toBeGreaterThan(0);
// Fire mousedown outside both the component div and the portal
const outsideEl = document.createElement('div');
document.body.appendChild(outsideEl);
@@ -95,20 +96,20 @@ describe('CustomDatePicker', () => {
});
document.body.removeChild(outsideEl);
// Day buttons should be gone
expect(screen.getAllByRole('button').filter(b => /^\d+$/.test(b.textContent?.trim() ?? '')).length).toBe(0);
expect(screen.getAllByRole('button').filter((b) => /^\d+$/.test(b.textContent?.trim() ?? '')).length).toBe(0);
});
it('FE-COMP-DATEPICKER-011: double-click activates text input mode', async () => {
it('FE-COMP-DATEPICKER-011: keyboard icon activates text input mode', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="" onChange={onChange} />);
await user.dblClick(screen.getByRole('button'));
await user.click(screen.getByRole('button', { name: /enter date manually/i }));
expect(screen.getByPlaceholderText('DD.MM.YYYY')).toBeTruthy();
});
it('FE-COMP-DATEPICKER-012: text input accepts ISO format YYYY-MM-DD', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="" onChange={onChange} />);
await user.dblClick(screen.getByRole('button'));
await user.click(screen.getByRole('button', { name: /enter date manually/i }));
const input = screen.getByPlaceholderText('DD.MM.YYYY');
fireEvent.change(input, { target: { value: '2026-07-04' } });
fireEvent.keyDown(input, { key: 'Enter' });
@@ -118,7 +119,7 @@ describe('CustomDatePicker', () => {
it('FE-COMP-DATEPICKER-013: text input accepts EU format DD.MM.YYYY', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="" onChange={onChange} />);
await user.dblClick(screen.getByRole('button'));
await user.click(screen.getByRole('button', { name: /enter date manually/i }));
const input = screen.getByPlaceholderText('DD.MM.YYYY');
fireEvent.change(input, { target: { value: '04.07.2026' } });
fireEvent.keyDown(input, { key: 'Enter' });
@@ -128,11 +129,11 @@ describe('CustomDatePicker', () => {
it('FE-COMP-DATEPICKER-014: Escape in text input cancels text mode', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="" onChange={onChange} />);
await user.dblClick(screen.getByRole('button'));
await user.click(screen.getByRole('button', { name: /enter date manually/i }));
const input = screen.getByPlaceholderText('DD.MM.YYYY');
fireEvent.keyDown(input, { key: 'Escape' });
expect(screen.queryByPlaceholderText('DD.MM.YYYY')).toBeNull();
expect(screen.getByRole('button')).toBeTruthy();
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
});
});
@@ -164,7 +165,7 @@ describe('CustomDateTimePicker', () => {
const dateTrigger = screen.getAllByRole('button')[0];
await user.click(dateTrigger); // open calendar
// Click day 1
const day1 = screen.getAllByRole('button').find(b => b.textContent?.trim() === '1');
const day1 = screen.getAllByRole('button').find((b) => b.textContent?.trim() === '1');
await user.click(day1!);
// onChange should have been called with T12:00 suffix
expect(onChange).toHaveBeenCalledWith(expect.stringMatching(/T12:00$/));
@@ -176,4 +177,154 @@ describe('CustomDateTimePicker', () => {
fireEvent.change(timeInput, { target: { value: '10:00' } });
expect(onChange).toHaveBeenCalledWith('2026-06-01T10:00');
});
it('FE-COMP-DATEPICKER-018: clicking month/year label switches to months view', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
await user.click(screen.getAllByRole('button')[0]); // open calendar
// The header label button has aria-label "Select month" when in days view
const headerBtn = screen.getByRole('button', { name: /select month/i });
await user.click(headerBtn);
// Month grid should appear — at least Jan/Feb/Mar etc.
const monthBtns = screen
.getAllByRole('button')
.filter((b) => b.getAttribute('aria-pressed') !== null && /^\D/.test(b.textContent?.trim() ?? ''));
expect(monthBtns.length).toBe(12);
});
it('FE-COMP-DATEPICKER-019: selecting a month in months view returns to days view', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
await user.click(screen.getAllByRole('button')[0]); // open calendar
// Drill into months view
const headerBtn = screen.getByRole('button', { name: /select month/i });
await user.click(headerBtn);
// Click the month that has aria-pressed=false and corresponds to June
const junBtn = screen
.getAllByRole('button')
.find((b) => b.getAttribute('aria-label')?.includes('June') || b.getAttribute('aria-label')?.includes('Jun'));
await user.click(junBtn!);
// Should be back in days view: weekday headers visible
const dayBtns = screen.getAllByRole('button').filter((b) => /^\d+$/.test(b.textContent?.trim() ?? ''));
expect(dayBtns.length).toBeGreaterThan(0);
});
it('FE-COMP-DATEPICKER-020: clicking year label in months view switches to years view', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
await user.click(screen.getAllByRole('button')[0]); // open calendar
// Drill into months view
await user.click(screen.getByRole('button', { name: /select month/i }));
// The header now shows the year; aria-label is "Select year"
const yearHeaderBtn = screen.getByRole('button', { name: /select year/i });
await user.click(yearHeaderBtn);
// Years grid: buttons with 4-digit numeric text
const yearBtns = screen.getAllByRole('button').filter((b) => /^\d{4}$/.test(b.textContent?.trim() ?? ''));
expect(yearBtns.length).toBe(12);
});
it('FE-COMP-DATEPICKER-021: selecting a year in years view returns to months view', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
await user.click(screen.getAllByRole('button')[0]); // open calendar
// Drill into years view
await user.click(screen.getByRole('button', { name: /select month/i }));
await user.click(screen.getByRole('button', { name: /select year/i }));
// Pick 2028
const yr2027 = screen.getByRole('button', { name: '2027' });
await user.click(yr2027);
// Should return to months view: 12 month buttons visible
const monthBtns = screen
.getAllByRole('button')
.filter((b) => b.getAttribute('aria-pressed') !== null && /^\D/.test(b.textContent?.trim() ?? ''));
expect(monthBtns.length).toBe(12);
});
it('FE-COMP-DATEPICKER-022: prev/next in months view changes year, not month', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
await user.click(screen.getAllByRole('button')[0]); // open calendar
await user.click(screen.getByRole('button', { name: /select month/i }));
// The header now shows "2026"; click Previous year
await user.click(screen.getByRole('button', { name: /previous year/i }));
expect(screen.getByRole('button', { name: /select year/i }).textContent?.trim()).toBe('2025');
});
it('FE-COMP-DATEPICKER-023: prev/next in years view pages the year grid', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
await user.click(screen.getAllByRole('button')[0]); // open calendar
await user.click(screen.getByRole('button', { name: /select month/i }));
await user.click(screen.getByRole('button', { name: /select year/i }));
// Note current first year
const yearsBefore = screen
.getAllByRole('button')
.filter((b) => /^\d{4}$/.test(b.textContent?.trim() ?? ''))
.map((b) => parseInt(b.textContent!.trim()));
const firstBefore = Math.min(...yearsBefore);
await user.click(screen.getByRole('button', { name: /next years/i }));
const yearsAfter = screen
.getAllByRole('button')
.filter((b) => /^\d{4}$/.test(b.textContent?.trim() ?? ''))
.map((b) => parseInt(b.textContent!.trim()));
const firstAfter = Math.min(...yearsAfter);
expect(firstAfter).toBe(firstBefore + 12);
});
it('FE-COMP-DATEPICKER-024: calendar opens back in days view after being closed', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
await user.click(screen.getAllByRole('button')[0]); // open
await user.click(screen.getByRole('button', { name: /select month/i }));
const outsideEl = document.createElement('div');
document.body.appendChild(outsideEl);
await act(async () => {
fireEvent.mouseDown(outsideEl);
});
document.body.removeChild(outsideEl);
await user.click(screen.getAllByRole('button')[0]); // reopen
const dayBtns = screen.getAllByRole('button').filter((b) => /^\d+$/.test(b.textContent?.trim() ?? ''));
expect(dayBtns.length).toBeGreaterThan(0);
});
// ── Keyboard icon trigger ─────────────────────────────────────────────────
it('FE-COMP-DATEPICKER-025: selected month has aria-pressed=true in months view', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
await user.click(screen.getAllByRole('button')[0]); // open calendar
await user.click(screen.getByRole('button', { name: /select month/i }));
// March should be aria-pressed=true
const marBtn = screen.getAllByRole('button').find((b) => b.getAttribute('aria-label') === 'March 2026');
expect(marBtn?.getAttribute('aria-pressed')).toBe('true');
});
it('FE-COMP-DATEPICKER-026: selected year has aria-pressed=true in years view', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
await user.click(screen.getAllByRole('button')[0]); // open calendar
await user.click(screen.getByRole('button', { name: /select month/i }));
await user.click(screen.getByRole('button', { name: /select year/i }));
const yr2026 = screen.getByRole('button', { name: '2026' });
expect(yr2026.getAttribute('aria-pressed')).toBe('true');
});
});
@@ -1,225 +1,619 @@
import React, { useState, useRef, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { Calendar, Clock, ChevronLeft, ChevronRight, ChevronUp, ChevronDown } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Calendar, ChevronLeft, ChevronRight, Keyboard } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { useTranslation } from '../../i18n';
function daysInMonth(year: number, month: number): number { return new Date(year, month + 1, 0).getDate() }
function getWeekday(year: number, month: number, day: number): number { return new Date(year, month, day).getDay() }
function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function getWeekday(year: number, month: number, day: number): number {
return new Date(year, month, day).getDay();
}
const YEAR_PAGE_SIZE = 12;
type CalendarView = 'days' | 'months' | 'years';
interface CustomDatePickerProps {
value: string
onChange: (value: string) => void
placeholder?: string
style?: React.CSSProperties
compact?: boolean
borderless?: boolean
value: string;
onChange: (value: string) => void;
placeholder?: string;
style?: React.CSSProperties;
compact?: boolean;
borderless?: boolean;
}
export function CustomDatePicker({ value, onChange, placeholder, style = {}, compact = false, borderless = false }: CustomDatePickerProps) {
const { locale, t } = useTranslation()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
export function CustomDatePicker({
value,
onChange,
placeholder,
style = {},
compact = false,
borderless = false,
}: CustomDatePickerProps) {
const { locale, t } = useTranslation();
const [open, setOpen] = useState(false);
const [view, setView] = useState<CalendarView>('days');
const [yearPageStart, setYearPageStart] = useState(0);
const ref = useRef<HTMLDivElement>(null);
const dropRef = useRef<HTMLDivElement>(null);
const parsed = value ? new Date(value + 'T00:00:00Z') : null
const [viewYear, setViewYear] = useState(parsed?.getUTCFullYear() || new Date().getFullYear())
const [viewMonth, setViewMonth] = useState(parsed?.getUTCMonth() ?? new Date().getMonth())
const parsed = value ? new Date(value + 'T00:00:00Z') : null;
const [viewYear, setViewYear] = useState(parsed?.getUTCFullYear() || new Date().getFullYear());
const [viewMonth, setViewMonth] = useState(parsed?.getUTCMonth() ?? new Date().getMonth());
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return
if (dropRef.current?.contains(e.target as Node)) return
setOpen(false)
}
if (open) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
if (ref.current?.contains(e.target as Node)) return;
if (dropRef.current?.contains(e.target as Node)) return;
setOpen(false);
};
if (open) document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
useEffect(() => {
if (open && parsed) { setViewYear(parsed.getUTCFullYear()); setViewMonth(parsed.getUTCMonth()) }
}, [open])
if (open) {
if (parsed) {
setViewYear(parsed.getUTCFullYear());
setViewMonth(parsed.getUTCMonth());
}
setView('days');
}
}, [open]);
const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) } else setViewMonth(m => m - 1) }
const nextMonth = () => { if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1) } else setViewMonth(m => m + 1) }
const prevMonth = () => {
if (viewMonth === 0) {
setViewMonth(11);
setViewYear((y) => y - 1);
} else setViewMonth((m) => m - 1);
};
const nextMonth = () => {
if (viewMonth === 11) {
setViewMonth(0);
setViewYear((y) => y + 1);
} else setViewMonth((m) => m + 1);
};
const monthLabel = new Date(viewYear, viewMonth).toLocaleDateString(locale, { month: 'long', year: 'numeric' })
const days = daysInMonth(viewYear, viewMonth)
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7
const weekdays = Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' }))
const handlePrev = () => {
if (view === 'days') prevMonth();
else if (view === 'months') setViewYear((y) => y - 1);
else setYearPageStart((s) => s - YEAR_PAGE_SIZE);
};
const handleNext = () => {
if (view === 'days') nextMonth();
else if (view === 'months') setViewYear((y) => y + 1);
else setYearPageStart((s) => s + YEAR_PAGE_SIZE);
};
const displayValue = parsed ? parsed.toLocaleDateString(locale, compact ? { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' } : { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : null
const prevAriaLabel = view === 'days' ? 'Previous month' : view === 'months' ? 'Previous year' : 'Previous years';
const nextAriaLabel = view === 'days' ? 'Next month' : view === 'months' ? 'Next year' : 'Next years';
const monthLabel = new Date(viewYear, viewMonth).toLocaleDateString(locale, { month: 'long', year: 'numeric' });
const yearRangeLabel = `${yearPageStart} – ${yearPageStart + YEAR_PAGE_SIZE - 1}`;
const headerLabel = view === 'days' ? monthLabel : view === 'months' ? String(viewYear) : yearRangeLabel;
const headerAriaLabel = view === 'days' ? 'Select month' : view === 'months' ? 'Select year' : undefined;
const handleHeaderClick = () => {
if (view === 'days') {
setView('months');
} else if (view === 'months') {
setYearPageStart(Math.floor(viewYear / YEAR_PAGE_SIZE) * YEAR_PAGE_SIZE);
setView('years');
}
};
const days = daysInMonth(viewYear, viewMonth);
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7;
const weekdays = Array.from({ length: 7 }, (_, i) =>
new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' })
);
const monthNames = Array.from({ length: 12 }, (_, i) =>
new Date(viewYear, i).toLocaleDateString(locale, { month: 'short' })
);
const years = Array.from({ length: YEAR_PAGE_SIZE }, (_, i) => yearPageStart + i);
const displayValue = parsed
? parsed.toLocaleDateString(
locale,
compact
? { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' }
: { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }
)
: null;
const inputValue = parsed
? parsed.toLocaleDateString(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
})
: '';
const selectDay = (day: number) => {
const y = String(viewYear)
const m = String(viewMonth + 1).padStart(2, '0')
const d = String(day).padStart(2, '0')
onChange(`${y}-${m}-${d}`)
setOpen(false)
}
const y = String(viewYear);
const m = String(viewMonth + 1).padStart(2, '0');
const d = String(day).padStart(2, '0');
onChange(`${y}-${m}-${d}`);
setOpen(false);
};
const selectedDay = parsed && parsed.getUTCFullYear() === viewYear && parsed.getUTCMonth() === viewMonth ? parsed.getUTCDate() : null
const today = new Date()
const isToday = (d: number) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
const selectMonth = (month: number) => {
setViewMonth(month);
setView('days');
};
const [textInput, setTextInput] = useState('')
const [isTyping, setIsTyping] = useState(false)
const selectYear = (year: number) => {
setViewYear(year);
setView('months');
};
const selectedDay =
parsed && parsed.getUTCFullYear() === viewYear && parsed.getUTCMonth() === viewMonth ? parsed.getUTCDate() : null;
const today = new Date();
const isToday = (d: number) =>
today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d;
const [textInput, setTextInput] = useState('');
const [isTyping, setIsTyping] = useState(false);
const handleTextSubmit = () => {
setIsTyping(false)
if (!textInput.trim()) return
setIsTyping(false);
if (!textInput.trim()) return;
// Try to parse various date formats
const input = textInput.trim()
const input = textInput.trim();
// ISO: 2026-03-29
if (/^\d{4}-\d{2}-\d{2}$/.test(input)) { onChange(input); return }
if (/^\d{4}-\d{2}-\d{2}$/.test(input)) {
onChange(input);
return;
}
// EU: 29.03.2026 or 29/03/2026
const euMatch = input.match(/^(\d{1,2})[./](\d{1,2})[./](\d{2,4})$/)
const euMatch = input.match(/^(\d{1,2})[./](\d{1,2})[./](\d{2,4})$/);
if (euMatch) {
const y = euMatch[3].length === 2 ? 2000 + parseInt(euMatch[3]) : parseInt(euMatch[3])
onChange(`${y}-${String(euMatch[2]).padStart(2, '0')}-${String(euMatch[1]).padStart(2, '0')}`)
return
const y = euMatch[3].length === 2 ? 2000 + parseInt(euMatch[3]) : parseInt(euMatch[3]);
onChange(`${y}-${String(euMatch[2]).padStart(2, '0')}-${String(euMatch[1]).padStart(2, '0')}`);
return;
}
// Try native Date parse as fallback
const d = new Date(input)
const d = new Date(input);
if (!isNaN(d.getTime())) {
onChange(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
onChange(
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
);
}
}
};
const gridCellStyle = (selected: boolean, current: boolean): React.CSSProperties => ({
borderRadius: 8,
border: 'none',
background: selected ? 'var(--accent)' : 'transparent',
color: selected ? 'var(--accent-text)' : 'var(--text-primary)',
fontWeight: selected ? 700 : current ? 600 : 400,
cursor: 'pointer',
outline: current && !selected ? '2px solid var(--border-primary)' : 'none',
outlineOffset: -2,
transition: 'background 0.1s',
});
return (
<div ref={ref} style={{ position: 'relative', ...style }}>
{isTyping ? (
<input autoFocus type="text" value={textInput} onChange={e => setTextInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleTextSubmit(); if (e.key === 'Escape') setIsTyping(false) }}
<input
autoFocus
type="text"
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleTextSubmit();
if (e.key === 'Escape') setIsTyping(false);
}}
onBlur={handleTextSubmit}
placeholder="DD.MM.YYYY"
aria-label="Enter date manually"
style={{
width: '100%', padding: '8px 14px', borderRadius: 10, border: '1px solid var(--text-faint)',
background: 'var(--bg-input)', color: 'var(--text-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none',
}} />
width: '100%',
padding: '8px 14px',
borderRadius: 10,
border: '1px solid var(--text-faint)',
background: 'var(--bg-input)',
color: 'var(--text-primary)',
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
fontFamily: 'inherit',
outline: 'none',
}}
/>
) : (
<button type="button" onClick={() => setOpen(o => !o)} onDoubleClick={() => { setTextInput(value || ''); setIsTyping(true) }}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: compact ? 4 : 8,
padding: compact ? '4px 6px' : '8px 14px', borderRadius: compact ? 4 : 10,
border: borderless ? 'none' : '1px solid var(--border-primary)',
background: borderless ? 'transparent' : 'var(--bg-input)', color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', cursor: 'pointer', outline: 'none',
transition: 'border-color 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}>
{!compact && <Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />}
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayValue || placeholder || t('common.date')}</span>
</button>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
{/* Calendar trigger */}
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-label={displayValue || placeholder || t('common.date')}
aria-expanded={open}
aria-haspopup="dialog"
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: compact ? 4 : 8,
padding: compact ? '4px 6px' : '8px 14px',
borderRadius: compact ? 4 : 10,
border: borderless ? 'none' : '1px solid var(--border-primary)',
background: borderless ? 'transparent' : 'var(--bg-input)',
color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
fontFamily: 'inherit',
cursor: 'pointer',
outline: 'none',
transition: 'border-color 0.15s',
}}
onMouseEnter={(e) => (e.currentTarget.style.borderColor = 'var(--text-faint)')}
onMouseLeave={(e) => {
if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)';
}}
>
{!compact && <Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />}
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{displayValue || placeholder || t('common.date')}
</span>
</button>
{/* Keyboard / text-input trigger (non-compact only; compact gets it in the popup footer) */}
{!compact && !borderless && (
<button
type="button"
onClick={() => {
setTextInput(inputValue || '');
setIsTyping(true);
}}
aria-label="Enter date manually"
title="Type a date"
style={{
background: 'none',
border: '1px solid var(--border-primary)',
borderRadius: 8,
cursor: 'pointer',
padding: '7px 8px',
display: 'flex',
alignItems: 'center',
color: 'var(--text-faint)',
flexShrink: 0,
transition: 'color 0.15s, border-color 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--text-primary)';
e.currentTarget.style.borderColor = 'var(--text-faint)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--text-faint)';
e.currentTarget.style.borderColor = 'var(--border-primary)';
}}
>
<Keyboard size={13} />
</button>
)}
</div>
)}
{open && ReactDOM.createPortal(
<div ref={dropRef} style={{
position: 'fixed',
...(() => {
const r = ref.current?.getBoundingClientRect()
if (!r) return { top: 0, left: 0 }
const w = 268, pad = 8, h = 360
const vw = window.innerWidth
const vh = window.visualViewport?.height ?? window.innerHeight
let left = r.left
let top = r.bottom + 4
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
if (top + h > vh - pad) top = r.top - h - 4
top = Math.max(pad, Math.min(top, vh - h - pad))
if (vw < 360) left = Math.max(pad, (vw - w) / 2)
return { top, left }
})(),
zIndex: 99999,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)',
borderRadius: 14, boxShadow: '0 8px 32px rgba(0,0,0,0.12)', padding: 12, width: 268,
maxWidth: 'calc(100vw - 16px)',
animation: 'selectIn 0.15s ease-out',
backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
}}>
{/* Month nav */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<button type="button" onClick={prevMonth} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ChevronLeft size={16} />
</button>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{monthLabel}</span>
<button type="button" onClick={nextMonth} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ChevronRight size={16} />
</button>
</div>
{open &&
ReactDOM.createPortal(
<div
ref={dropRef}
role="dialog"
aria-label="Date picker"
style={{
position: 'fixed',
...(() => {
const r = ref.current?.getBoundingClientRect();
if (!r) return { top: 0, left: 0 };
const w = 268,
pad = 8,
h = 360;
const vw = window.innerWidth;
const vh = window.visualViewport?.height ?? window.innerHeight;
let left = r.left;
let top = r.bottom + 4;
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad);
if (top + h > vh - pad) top = r.top - h - 4;
top = Math.max(pad, Math.min(top, vh - h - pad));
if (vw < 360) left = Math.max(pad, (vw - w) / 2);
return { top, left };
})(),
zIndex: 99999,
background: 'var(--bg-card)',
border: '1px solid var(--border-primary)',
borderRadius: 14,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
padding: 12,
width: 268,
maxWidth: 'calc(100vw - 16px)',
animation: 'selectIn 0.15s ease-out',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
}}
>
{/* ── Header ── */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<button
type="button"
onClick={handlePrev}
aria-label={prevAriaLabel}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4,
borderRadius: 6,
display: 'flex',
color: 'var(--text-faint)',
}}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-faint)')}
>
<ChevronLeft size={16} />
</button>
{/* Weekday headers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2, marginBottom: 4 }}>
{weekdays.map((d, i) => (
<div key={i} style={{ textAlign: 'center', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '2px 0' }}>{d}</div>
))}
</div>
{/* Days grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
{Array.from({ length: startDay }, (_, i) => <div key={`e-${i}`} />)}
{Array.from({ length: days }, (_, i) => {
const d = i + 1
const sel = d === selectedDay
const td = isToday(d)
return (
<button key={d} type="button" onClick={() => selectDay(d)}
{/* Clickable label — drills down (days → months → years) */}
{view !== 'years' ? (
<button
type="button"
onClick={handleHeaderClick}
aria-label={headerAriaLabel}
style={{
width: 32, height: 32, borderRadius: 8, border: 'none',
background: sel ? 'var(--accent)' : 'transparent',
color: sel ? 'var(--accent-text)' : 'var(--text-primary)',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: sel ? 700 : td ? 600 : 400,
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
outline: td && !sel ? '2px solid var(--border-primary)' : 'none', outlineOffset: -2,
transition: 'background 0.1s',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px 8px',
borderRadius: 6,
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
fontWeight: 600,
color: 'var(--text-primary)',
}}
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = 'transparent' }}>
{d}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
>
{headerLabel}
</button>
)
})}
</div>
) : (
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{headerLabel}</span>
)}
{/* Clear */}
{value && (
<div style={{ marginTop: 8, display: 'flex', justifyContent: 'center' }}>
<button type="button" onClick={() => { onChange(''); setOpen(false) }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', padding: '3px 8px', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
✕
<button
type="button"
onClick={handleNext}
aria-label={nextAriaLabel}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4,
borderRadius: 6,
display: 'flex',
color: 'var(--text-faint)',
}}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-faint)')}
>
<ChevronRight size={16} />
</button>
</div>
)}
</div>,
document.body
)}
{/* ── Days view ── */}
{view === 'days' && (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2, marginBottom: 4 }}>
{weekdays.map((d, i) => (
<div
key={i}
style={{
textAlign: 'center',
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
fontWeight: 600,
color: 'var(--text-faint)',
padding: '2px 0',
}}
>
{d}
</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
{Array.from({ length: startDay }, (_, i) => (
<div key={`e-${i}`} />
))}
{Array.from({ length: days }, (_, i) => {
const d = i + 1;
const sel = d === selectedDay;
const td = isToday(d);
const isoDate = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const ariaLabel = new Date(isoDate + 'T00:00:00Z').toLocaleDateString(locale, {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
});
return (
<button
key={d}
type="button"
onClick={() => selectDay(d)}
aria-label={ariaLabel}
aria-pressed={sel}
style={{
width: 32,
height: 32,
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...gridCellStyle(sel, td),
}}
onMouseEnter={(e) => {
if (!sel) e.currentTarget.style.background = 'var(--bg-hover)';
}}
onMouseLeave={(e) => {
if (!sel) e.currentTarget.style.background = 'transparent';
}}
>
{d}
</button>
);
})}
</div>
</>
)}
{/* ── Months view ── */}
{view === 'months' && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4 }}>
{monthNames.map((name, i) => {
const sel = !!(parsed && parsed.getUTCFullYear() === viewYear && parsed.getUTCMonth() === i);
const cur = today.getFullYear() === viewYear && today.getMonth() === i;
const ariaLabel = new Date(viewYear, i).toLocaleDateString(locale, {
month: 'long',
year: 'numeric',
});
return (
<button
key={i}
type="button"
onClick={() => selectMonth(i)}
aria-label={ariaLabel}
aria-pressed={sel}
style={{
padding: '10px 4px',
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
...gridCellStyle(sel, cur),
}}
onMouseEnter={(e) => {
if (!sel) e.currentTarget.style.background = 'var(--bg-hover)';
}}
onMouseLeave={(e) => {
if (!sel) (e.currentTarget as HTMLButtonElement).style.background = 'transparent';
}}
>
{name}
</button>
);
})}
</div>
)}
{/* ── Years view ── */}
{view === 'years' && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4 }}>
{years.map((year) => {
const sel = !!(parsed && parsed.getUTCFullYear() === year);
const cur = today.getFullYear() === year;
return (
<button
key={year}
type="button"
onClick={() => selectYear(year)}
aria-label={String(year)}
aria-pressed={sel}
style={{
padding: '10px 4px',
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
...gridCellStyle(sel, cur),
}}
onMouseEnter={(e) => {
if (!sel) e.currentTarget.style.background = 'var(--bg-hover)';
}}
onMouseLeave={(e) => {
if (!sel) (e.currentTarget as HTMLButtonElement).style.background = 'transparent';
}}
>
{year}
</button>
);
})}
</div>
)}
{/* ── Footer: keyboard trigger (compact) + clear ── */}
<div style={{ marginTop: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{/* In compact/borderless mode, the keyboard icon lives here instead of next to the trigger */}
{compact || borderless ? (
<button
type="button"
onClick={() => {
setOpen(false);
setTextInput(inputValue || '');
setIsTyping(true);
}}
aria-label="Enter date manually"
title="Type a date"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '3px 6px',
borderRadius: 6,
display: 'flex',
color: 'var(--text-faint)',
}}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-faint)')}
>
<Keyboard size={13} />
</button>
) : (
<div />
)}
{value && (
<button
type="button"
onClick={() => {
onChange('');
setOpen(false);
}}
aria-label="Clear date"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
color: 'var(--text-faint)',
padding: '3px 8px',
borderRadius: 6,
}}
onMouseEnter={(e) => (e.currentTarget.style.color = '#ef4444')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-faint)')}
>
✕
</button>
)}
</div>
</div>,
document.body
)}
<style>{`@keyframes selectIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }`}</style>
</div>
)
);
}
interface CustomDateTimePickerProps {
value: string
onChange: (value: string) => void
placeholder?: string
style?: React.CSSProperties
value: string;
onChange: (value: string) => void;
placeholder?: string;
style?: React.CSSProperties;
}
export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }: CustomDateTimePickerProps) {
const { locale } = useTranslation()
const [datePart, timePart] = (value || '').split('T')
const { locale } = useTranslation();
const [datePart, timePart] = (value || '').split('T');
const handleDateChange = (d: string) => {
onChange(d ? `${d}T${timePart || '12:00'}` : '')
}
onChange(d ? `${d}T${timePart || '12:00'}` : '');
};
const handleTimeChange = (t: string) => {
const d = datePart || new Date().toISOString().split('T')[0]
onChange(t ? `${d}T${t}` : d)
}
const d = datePart || new Date().toISOString().split('T')[0];
onChange(t ? `${d}T${t}` : d);
};
return (
<div style={{ display: 'flex', gap: 8, ...style }}>
@@ -228,7 +622,7 @@ export function CustomDateTimePicker({ value, onChange, placeholder, style = {}
<CustomTimePicker value={timePart || ''} onChange={handleTimeChange} />
</div>
</div>
)
);
}
import CustomTimePicker from './CustomTimePicker'
import CustomTimePicker from './CustomTimePicker';