diff --git a/package.json b/package.json index 0b81a79..b5650d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@panic/web-ui", - "version": "0.1.17", + "version": "0.1.18", "license": "AGPL-3.0-only", "description": "Core components for panic.haus web applications", "type": "module", @@ -90,5 +90,8 @@ "vite": "^7.0.0", "vitest": "^4.0.18", "yjs": "^13.6.24" + }, + "dependencies": { + "@types/node": "^25.3.0" } } diff --git a/src/components/DatePicker.stories.tsx b/src/components/DatePicker.stories.tsx index 5af571b..f327d07 100644 --- a/src/components/DatePicker.stories.tsx +++ b/src/components/DatePicker.stories.tsx @@ -11,7 +11,7 @@ const meta = { docs: { description: { component: - 'Date selection field with InputField-compatible API, supporting date/time/datetime-local values, size/layout variants, and validation state.', + 'In-house date/time selection field with InputField-compatible API. Uses a custom popup (not native browser pickers) and supports date, time, and date-time modes.', }, }, }, @@ -27,10 +27,10 @@ const meta = { table: { type: { summary: 'string' } }, }, type: { - description: 'Native date input type.', - options: ['date', 'datetime-local', 'time'], + description: 'DatePicker mode.', + options: ['date', 'date-time', 'time'], control: 'inline-radio', - table: { type: { summary: "'date' | 'datetime-local' | 'time'" } }, + table: { type: { summary: "'date' | 'date-time' | 'time'" } }, }, size: { description: 'Input size.', @@ -55,6 +55,22 @@ const meta = { control: 'text', table: { type: { summary: 'string' } }, }, + format: { + description: + 'Optional input/output format. Supported tokens: `dd`, `mm`, `yyyy`, `HH` (for example `dd/mm/yyyy HH:mm`).', + control: 'text', + table: { type: { summary: 'string' } }, + }, + min: { + description: 'Optional minimum value in the same format as `value`.', + control: 'text', + table: { type: { summary: 'string' } }, + }, + max: { + description: 'Optional maximum value in the same format as `value`.', + control: 'text', + table: { type: { summary: 'string' } }, + }, name: { description: 'Native input `name` attribute.', control: 'text', @@ -108,7 +124,7 @@ const meta = { }, args: { label: 'Schedule at', - type: 'datetime-local', + type: 'date-time', value: '', size: 'md', width: 'md', @@ -125,7 +141,7 @@ export const DateOnly: Story = { label: 'Publish date', }, render: function DateOnlyRender(args) { - const [value, setValue] = useState('2031-05-20'); + const [value, setValue] = useState('2031/05/20'); return ( { + setValue(event.target.value); + args.onChange?.(event); + }} + /> + ); + }, +}; diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx index 48583be..012aeeb 100644 --- a/src/components/DatePicker.tsx +++ b/src/components/DatePicker.tsx @@ -1,8 +1,94 @@ -import type { ChangeEventHandler, FocusEventHandler, ReactNode, Ref } from 'react'; +import { + CalendarDaysIcon, + ChevronLeftIcon, + ChevronRightIcon, + ClockIcon, +} from '@heroicons/react/24/solid'; +import { createPortal } from 'react-dom'; +import { + type ClipboardEvent, + type ChangeEvent, + type ChangeEventHandler, + type FocusEvent, + type FocusEventHandler, + type KeyboardEvent as ReactKeyboardEvent, + type MutableRefObject, + type ReactNode, + type Ref, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import type { ComponentSize } from './types'; -type DatePickerKind = 'date' | 'datetime-local' | 'time'; +type DatePickerKind = 'date' | 'time' | 'date-time'; type Layout = 'stacked' | 'inline'; +type PopupPlacement = 'top' | 'bottom'; +type SegmentKind = 'day' | 'month' | 'year' | 'hour' | 'minute'; +type FormatToken = 'dd' | 'mm' | 'yyyy' | 'HH'; +type PickerValue = { + date: Date; + hour: number; + minute: number; +}; + +type PopupPosition = { + top: number; + left: number; + minWidth: number; + placement: PopupPlacement; +}; + +type RawFormatPart = + | { + type: 'literal'; + value: string; + } + | { + type: 'token'; + token: FormatToken; + }; + +type FormatPart = + | { + type: 'literal'; + value: string; + start: number; + end: number; + } + | { + type: 'segment'; + kind: SegmentKind; + token: FormatToken; + length: number; + start: number; + end: number; + segmentIndex: number; + }; + +type FormatSegment = Extract; +type FormatConfig = { + type: DatePickerKind; + format: string; + parts: FormatPart[]; + segments: FormatSegment[]; + totalLength: number; + literalChars: Set; +}; + +const HOURS = Array.from({ length: 24 }, (_, idx) => idx); +const MINUTES = Array.from({ length: 60 }, (_, idx) => idx); +const YEAR_WINDOW = 50; +const POPUP_GAP = 6; +const POPUP_MARGIN = 8; +const SEGMENT_EDIT_TIMEOUT_MS = 1500; +const DEFAULT_FORMAT: Record = { + date: 'yyyy/mm/dd', + time: 'HH:mm', + 'date-time': 'yyyy/mm/dd HH:mm', +}; export type DatePickerProps = { label?: string; @@ -12,6 +98,9 @@ export type DatePickerProps = { width?: ComponentSize; layout?: Layout; value: string; + format?: string; + min?: string; + max?: string; name?: string; onChange?: ChangeEventHandler; onBlur?: FocusEventHandler; @@ -24,6 +113,637 @@ export type DatePickerProps = { inputClassName?: string; }; +function pad2(value: number): string { + return String(value).padStart(2, '0'); +} + +function pad4(value: number): string { + return String(value).padStart(4, '0'); +} + +function clampNumber(value: number, minValue: number, maxValue: number): number { + return Math.min(maxValue, Math.max(minValue, value)); +} + +function createDateAtLocalMidnight(year: number, monthIndex: number, day: number): Date { + const candidate = new Date(0); + candidate.setHours(0, 0, 0, 0); + candidate.setFullYear(year, monthIndex, day); + return candidate; +} + +function createDateTimeFromPickerValue(value: PickerValue): Date { + const candidate = createDateAtLocalMidnight( + value.date.getFullYear(), + value.date.getMonth(), + value.date.getDate(), + ); + candidate.setHours(value.hour, value.minute, 0, 0); + return candidate; +} + +function startOfDay(value: Date): Date { + const candidate = new Date(value.getTime()); + candidate.setHours(0, 0, 0, 0); + return candidate; +} + +function startOfMonth(value: Date): Date { + const candidate = startOfDay(value); + candidate.setDate(1); + return candidate; +} + +function isSameDay(left: Date, right: Date): boolean { + return ( + left.getFullYear() === right.getFullYear() && + left.getMonth() === right.getMonth() && + left.getDate() === right.getDate() + ); +} + +function createValidatedDate(year: number, month: number, day: number): Date | null { + const candidate = createDateAtLocalMidnight(year, month - 1, day); + if ( + candidate.getFullYear() !== year || + candidate.getMonth() !== month - 1 || + candidate.getDate() !== day + ) { + return null; + } + + return candidate; +} + +function daysInMonth(year: number, month: number): number { + return createDateAtLocalMidnight(year, month, 0).getDate(); +} + +function clonePickerValue(value: PickerValue): PickerValue { + return { + date: startOfDay(value.date), + hour: value.hour, + minute: value.minute, + }; +} + +function resolveLocale(): string { + if (typeof navigator === 'undefined') { + return 'en-US'; + } + + return navigator.languages?.[0] ?? navigator.language ?? 'en-US'; +} + +function resolveWeekStart(locale: string): number { + if (typeof Intl === 'undefined' || typeof Intl.Locale !== 'function') { + return 0; + } + + try { + const localeInfo = new Intl.Locale(locale) as Intl.Locale & { + weekInfo?: { firstDay?: number }; + }; + const firstDay = localeInfo.weekInfo?.firstDay; + + if (typeof firstDay !== 'number') { + return 0; + } + + if (firstDay === 7) { + return 0; + } + + if (firstDay >= 1 && firstDay <= 6) { + return firstDay; + } + } catch { + return 0; + } + + return 0; +} + +function buildMonthGrid(viewMonth: Date, weekStart: number): Date[] { + const monthStart = startOfMonth(viewMonth); + const dayOffset = (monthStart.getDay() - weekStart + 7) % 7; + const gridStart = createDateAtLocalMidnight( + monthStart.getFullYear(), + monthStart.getMonth(), + monthStart.getDate() - dayOffset, + ); + + return Array.from({ length: 42 }, (_, index) => { + return createDateAtLocalMidnight( + gridStart.getFullYear(), + gridStart.getMonth(), + gridStart.getDate() + index, + ); + }); +} + +function joinClassNames(...parts: Array): string { + return parts.filter(Boolean).join(' ').trim(); +} + +function assignRef(ref: Ref | undefined, node: HTMLInputElement | null): void { + if (!ref) { + return; + } + + if (typeof ref === 'function') { + ref(node); + return; + } + + (ref as MutableRefObject).current = node; +} + +function tokenizeFormat(format: string): RawFormatPart[] { + const result: RawFormatPart[] = []; + let pointer = 0; + let literalBuffer = ''; + + const flushLiteral = () => { + if (literalBuffer.length > 0) { + result.push({ type: 'literal', value: literalBuffer }); + literalBuffer = ''; + } + }; + + while (pointer < format.length) { + if (format.startsWith('yyyy', pointer)) { + flushLiteral(); + result.push({ type: 'token', token: 'yyyy' }); + pointer += 4; + continue; + } + if (format.startsWith('dd', pointer)) { + flushLiteral(); + result.push({ type: 'token', token: 'dd' }); + pointer += 2; + continue; + } + if (format.startsWith('HH', pointer)) { + flushLiteral(); + result.push({ type: 'token', token: 'HH' }); + pointer += 2; + continue; + } + if (format.startsWith('mm', pointer)) { + flushLiteral(); + result.push({ type: 'token', token: 'mm' }); + pointer += 2; + continue; + } + + literalBuffer += format[pointer]; + pointer += 1; + } + + flushLiteral(); + return result; +} + +function buildFormatConfigOrNull(type: DatePickerKind, format: string): FormatConfig | null { + const rawParts = tokenizeFormat(format); + if (rawParts.length === 0) { + return null; + } + + const parts: FormatPart[] = []; + const literalChars = new Set(); + const counts: Record = { + day: 0, + month: 0, + year: 0, + hour: 0, + minute: 0, + }; + + let hourSeen = false; + let segmentIndex = 0; + let offset = 0; + + for (const part of rawParts) { + if (part.type === 'literal') { + for (const char of part.value) { + literalChars.add(char); + } + + parts.push({ + type: 'literal', + value: part.value, + start: offset, + end: offset + part.value.length, + }); + offset += part.value.length; + continue; + } + + let kind: SegmentKind; + if (part.token === 'dd') { + kind = 'day'; + } else if (part.token === 'yyyy') { + kind = 'year'; + } else if (part.token === 'HH') { + kind = 'hour'; + hourSeen = true; + } else if (type === 'date') { + kind = 'month'; + } else if (type === 'time') { + kind = 'minute'; + } else { + kind = hourSeen ? 'minute' : 'month'; + } + + counts[kind] += 1; + + const length = part.token.length; + parts.push({ + type: 'segment', + kind, + token: part.token, + length, + start: offset, + end: offset + length, + segmentIndex, + }); + segmentIndex += 1; + offset += length; + } + + const isValid = + (type === 'date' && + counts.day === 1 && + counts.month === 1 && + counts.year === 1 && + counts.hour === 0 && + counts.minute === 0) || + (type === 'time' && + counts.day === 0 && + counts.month === 0 && + counts.year === 0 && + counts.hour === 1 && + counts.minute === 1) || + (type === 'date-time' && + counts.day === 1 && + counts.month === 1 && + counts.year === 1 && + counts.hour === 1 && + counts.minute === 1); + + if (!isValid) { + return null; + } + + const segments = parts.filter((part): part is FormatSegment => part.type === 'segment'); + if (segments.length === 0) { + return null; + } + + return { + type, + format, + parts, + segments, + totalLength: offset, + literalChars, + }; +} + +function buildFormatConfig(type: DatePickerKind, requestedFormat?: string): FormatConfig { + if (requestedFormat != null && requestedFormat.trim() !== '') { + const explicit = buildFormatConfigOrNull(type, requestedFormat.trim()); + if (explicit) { + return explicit; + } + } + + const fallback = buildFormatConfigOrNull(type, DEFAULT_FORMAT[type]); + if (!fallback) { + throw new Error('Failed to initialize DatePicker format configuration.'); + } + + return fallback; +} + +function parsePickerValueWithFormat(rawValue: string, config: FormatConfig): PickerValue | null { + const value = rawValue.trim(); + if (value.length !== config.totalLength) { + return null; + } + + let year: number | null = null; + let month: number | null = null; + let day: number | null = null; + let hour: number | null = null; + let minute: number | null = null; + + for (const part of config.parts) { + const chunk = value.slice(part.start, part.end); + + if (part.type === 'literal') { + if (chunk !== part.value) { + return null; + } + continue; + } + + if (!/^\d+$/.test(chunk)) { + return null; + } + + const numeric = Number(chunk); + if (!Number.isFinite(numeric)) { + return null; + } + + if (part.kind === 'year') { + year = numeric; + } else if (part.kind === 'month') { + month = numeric; + } else if (part.kind === 'day') { + day = numeric; + } else if (part.kind === 'hour') { + hour = numeric; + } else { + minute = numeric; + } + } + + if (config.type !== 'time') { + if (year == null || month == null || day == null) { + return null; + } + if (year < 0 || year > 9999) { + return null; + } + + const parsedDate = createValidatedDate(year, month, day); + if (!parsedDate) { + return null; + } + + if (config.type === 'date') { + return { + date: parsedDate, + hour: 0, + minute: 0, + }; + } + + if (hour == null || minute == null) { + return null; + } + + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + return null; + } + + return { + date: parsedDate, + hour, + minute, + }; + } + + if (hour == null || minute == null) { + return null; + } + + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + return null; + } + + return { + date: startOfDay(new Date()), + hour, + minute, + }; +} + +function formatPickerValueWithFormat(value: PickerValue, config: FormatConfig): string { + const fields: Record = { + day: pad2(value.date.getDate()), + month: pad2(value.date.getMonth() + 1), + year: pad4(value.date.getFullYear()), + hour: pad2(value.hour), + minute: pad2(value.minute), + }; + + return config.parts + .map((part) => { + if (part.type === 'literal') { + return part.value; + } + + return fields[part.kind]; + }) + .join(''); +} + +function comparePickerValue(left: PickerValue, right: PickerValue, type: DatePickerKind): number { + if (type === 'time') { + const leftTotal = left.hour * 60 + left.minute; + const rightTotal = right.hour * 60 + right.minute; + return leftTotal - rightTotal; + } + + if (type === 'date') { + return startOfDay(left.date).getTime() - startOfDay(right.date).getTime(); + } + + const leftDateTime = createDateTimeFromPickerValue(left).getTime(); + const rightDateTime = createDateTimeFromPickerValue(right).getTime(); + + return leftDateTime - rightDateTime; +} + +function normalizeRange( + minValue: PickerValue | null, + maxValue: PickerValue | null, + type: DatePickerKind, +): { minValue: PickerValue | null; maxValue: PickerValue | null } { + if (minValue == null || maxValue == null) { + return { + minValue, + maxValue, + }; + } + + if (comparePickerValue(minValue, maxValue, type) <= 0) { + return { minValue, maxValue }; + } + + return { + minValue: maxValue, + maxValue: minValue, + }; +} + +function clampPickerToRange( + candidate: PickerValue, + minValue: PickerValue | null, + maxValue: PickerValue | null, + type: DatePickerKind, +): PickerValue { + if (minValue && comparePickerValue(candidate, minValue, type) < 0) { + return clonePickerValue(minValue); + } + + if (maxValue && comparePickerValue(candidate, maxValue, type) > 0) { + return clonePickerValue(maxValue); + } + + return clonePickerValue(candidate); +} + +function isWithinRange( + candidate: PickerValue, + minValue: PickerValue | null, + maxValue: PickerValue | null, + type: DatePickerKind, +): boolean { + if (minValue && comparePickerValue(candidate, minValue, type) < 0) { + return false; + } + + if (maxValue && comparePickerValue(candidate, maxValue, type) > 0) { + return false; + } + + return true; +} + +function applySegmentDigits(baseValue: PickerValue, kind: SegmentKind, digits: string): PickerValue { + const parsedDigits = Number(digits); + if (!Number.isFinite(parsedDigits)) { + return clonePickerValue(baseValue); + } + + let year = baseValue.date.getFullYear(); + let month = baseValue.date.getMonth() + 1; + let day = baseValue.date.getDate(); + let hour = baseValue.hour; + let minute = baseValue.minute; + + if (kind === 'year') { + year = clampNumber(parsedDigits, 0, 9999); + } else if (kind === 'month') { + month = clampNumber(parsedDigits, 1, 12); + } else if (kind === 'day') { + const maxDay = daysInMonth(year, month); + day = clampNumber(parsedDigits, 1, maxDay); + } else if (kind === 'hour') { + hour = clampNumber(parsedDigits, 0, 23); + } else { + minute = clampNumber(parsedDigits, 0, 59); + } + + const maxDayForCurrentMonth = daysInMonth(year, month); + day = clampNumber(day, 1, maxDayForCurrentMonth); + + const nextDate = createValidatedDate(year, month, day) ?? createDateAtLocalMidnight(year, month - 1, day); + return { + date: nextDate, + hour, + minute, + }; +} + +function findSegmentIndexByCaret(segments: FormatSegment[], caretPosition: number | null): number { + if (segments.length === 0) { + return 0; + } + + if (caretPosition == null) { + return 0; + } + + for (const segment of segments) { + if (caretPosition >= segment.start && caretPosition <= segment.end) { + return segment.segmentIndex; + } + + if (caretPosition < segment.start) { + return segment.segmentIndex; + } + } + + return segments[segments.length - 1].segmentIndex; +} + +function isDateSelectableForRange( + dateValue: Date, + type: DatePickerKind, + minValue: PickerValue | null, + maxValue: PickerValue | null, +): boolean { + if (type === 'date') { + return isWithinRange( + { + date: startOfDay(dateValue), + hour: 0, + minute: 0, + }, + minValue, + maxValue, + type, + ); + } + + const dayStart: PickerValue = { + date: startOfDay(dateValue), + hour: 0, + minute: 0, + }; + const dayEnd: PickerValue = { + date: startOfDay(dateValue), + hour: 23, + minute: 59, + }; + + if (minValue && comparePickerValue(dayEnd, minValue, type) < 0) { + return false; + } + + if (maxValue && comparePickerValue(dayStart, maxValue, type) > 0) { + return false; + } + + return true; +} + +function isHourSelectableForRange( + dateValue: Date, + hour: number, + type: DatePickerKind, + minValue: PickerValue | null, + maxValue: PickerValue | null, +): boolean { + const candidateStart: PickerValue = { + date: startOfDay(dateValue), + hour, + minute: 0, + }; + const candidateEnd: PickerValue = { + date: startOfDay(dateValue), + hour, + minute: 59, + }; + + if (minValue && comparePickerValue(candidateEnd, minValue, type) < 0) { + return false; + } + + if (maxValue && comparePickerValue(candidateStart, maxValue, type) > 0) { + return false; + } + + return true; +} + export function DatePicker({ label, placeholder = '', @@ -32,6 +752,9 @@ export function DatePicker({ width = 'md', layout = 'stacked', value, + format, + min, + max, name, onChange, onBlur, @@ -43,6 +766,75 @@ export function DatePicker({ className = '', inputClassName = '', }: Readonly) { + const internalInputRef = useRef(null); + const inputWrapperRef = useRef(null); + const popupRef = useRef(null); + const changeHandledRef = useRef(false); + const bufferedDigitsRef = useRef<{ segmentIndex: number; digits: string; timestamp: number } | null>( + null, + ); + const activeSegmentIndexRef = useRef(0); + const pendingSelectionTimerRef = useRef(null); + + const [isOpen, setIsOpen] = useState(false); + const [viewMonth, setViewMonth] = useState(() => startOfMonth(new Date())); + const [activeChooser, setActiveChooser] = useState<'month' | 'year' | null>(null); + const [popupPosition, setPopupPosition] = useState(null); + + const closePicker = useCallback(() => { + setIsOpen(false); + setActiveChooser(null); + setPopupPosition(null); + }, []); + + const locale = useMemo(() => resolveLocale(), []); + const weekStart = useMemo(() => resolveWeekStart(locale), [locale]); + const formatConfig = useMemo(() => buildFormatConfig(type, format), [type, format]); + + const fallbackValue = useMemo(() => { + const today = startOfDay(new Date()); + return { + date: today, + hour: 0, + minute: 0, + }; + }, []); + + const parsedValue = useMemo( + () => parsePickerValueWithFormat(value, formatConfig), + [formatConfig, value], + ); + const selectedValue = parsedValue ?? fallbackValue; + + const parsedMinValue = useMemo(() => { + if (min == null || min.trim() === '') { + return null; + } + return parsePickerValueWithFormat(min, formatConfig); + }, [formatConfig, min]); + + const parsedMaxValue = useMemo(() => { + if (max == null || max.trim() === '') { + return null; + } + return parsePickerValueWithFormat(max, formatConfig); + }, [formatConfig, max]); + + const { minValue: normalizedMinValue, maxValue: normalizedMaxValue } = useMemo(() => { + return normalizeRange(parsedMinValue, parsedMaxValue, type); + }, [parsedMaxValue, parsedMinValue, type]); + + const clampedSelectedValue = useMemo(() => { + return clampPickerToRange(selectedValue, normalizedMinValue, normalizedMaxValue, type); + }, [normalizedMaxValue, normalizedMinValue, selectedValue, type]); + + const selectedDate = clampedSelectedValue.date; + const selectedHour = clampedSelectedValue.hour; + const selectedMinute = clampedSelectedValue.minute; + const displayValue = useMemo(() => { + return formatPickerValueWithFormat(clampedSelectedValue, formatConfig); + }, [clampedSelectedValue, formatConfig]); + const containerWidthClass = { sm: 'max-w-xs', md: 'max-w-sm', @@ -60,31 +852,924 @@ export function DatePicker({ const wrapperClass = layout === 'inline' ? 'inline-flex w-auto items-center gap-2' : 'block w-full gap-1'; const labelClass = layout === 'inline' ? 'text-xs ui-body-secondary' : ''; - const hasTrailingIcon = Boolean(rightIcon); const inputWrapperClass = layout === 'inline' ? 'relative' : 'relative mt-1'; + const defaultRightIcon = + type === 'time' ? ( +