From 3011cf6c2f47d955ea7269dff4663b92ca552499 Mon Sep 17 00:00:00 2001 From: Gio Cettuzzi Date: Sun, 14 Jun 2026 19:08:42 +0200 Subject: [PATCH] feat(date-picker): add month/year drill-down navigation and keyboard input trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../shared/CustomDateTimePicker.test.tsx | 199 ++++- .../shared/CustomDateTimePicker.tsx | 728 ++++++++++++++---- 2 files changed, 736 insertions(+), 191 deletions(-) diff --git a/client/src/components/shared/CustomDateTimePicker.test.tsx b/client/src/components/shared/CustomDateTimePicker.test.tsx index cfd8ebcb8..e8984141c 100644 --- a/client/src/components/shared/CustomDateTimePicker.test.tsx +++ b/client/src/components/shared/CustomDateTimePicker.test.tsx @@ -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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + + 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(); + 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(); + 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'); + }); }); diff --git a/client/src/components/shared/CustomDateTimePicker.tsx b/client/src/components/shared/CustomDateTimePicker.tsx index d3dfa5c9c..20fba3506 100644 --- a/client/src/components/shared/CustomDateTimePicker.tsx +++ b/client/src/components/shared/CustomDateTimePicker.tsx @@ -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(null) - const dropRef = useRef(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('days'); + const [yearPageStart, setYearPageStart] = useState(0); + const ref = useRef(null); + const dropRef = useRef(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 (
{isTyping ? ( - setTextInput(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') handleTextSubmit(); if (e.key === 'Escape') setIsTyping(false) }} + 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', + }} + /> ) : ( - +
+ {/* Calendar trigger */} + + + {/* Keyboard / text-input trigger (non-compact only; compact gets it in the popup footer) */} + {!compact && !borderless && ( + + )} +
)} - {open && ReactDOM.createPortal( -
{ - 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 */} -
- - {monthLabel} - -
+ {open && + ReactDOM.createPortal( +
{ + 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 ── */} +
+ - {/* Weekday headers */} -
- {weekdays.map((d, i) => ( -
{d}
- ))} -
- - {/* Days grid */} -
- {Array.from({ length: startDay }, (_, i) =>
)} - {Array.from({ length: days }, (_, i) => { - const d = i + 1 - const sel = d === selectedDay - const td = isToday(d) - return ( - - ) - })} -
+ ) : ( + {headerLabel} + )} - {/* Clear */} - {value && ( -
-
- )} -
, - document.body - )} + + {/* ── Days view ── */} + {view === 'days' && ( + <> +
+ {weekdays.map((d, i) => ( +
+ {d} +
+ ))} +
+
+ {Array.from({ length: startDay }, (_, 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 ( + + ); + })} +
+ + )} + + {/* ── Months view ── */} + {view === 'months' && ( +
+ {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 ( + + ); + })} +
+ )} + + {/* ── Years view ── */} + {view === 'years' && ( +
+ {years.map((year) => { + const sel = !!(parsed && parsed.getUTCFullYear() === year); + const cur = today.getFullYear() === year; + return ( + + ); + })} +
+ )} + + {/* ── Footer: keyboard trigger (compact) + clear ── */} +
+ {/* In compact/borderless mode, the keyboard icon lives here instead of next to the trigger */} + {compact || borderless ? ( + + ) : ( +
+ )} + + {value && ( + + )} +
+
, + document.body + )}
- ) + ); } 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 (
@@ -228,7 +622,7 @@ export function CustomDateTimePicker({ value, onChange, placeholder, style = {}
- ) + ); } -import CustomTimePicker from './CustomTimePicker' +import CustomTimePicker from './CustomTimePicker';