diff --git a/.changeset/flat-moons-kneel.md b/.changeset/flat-moons-kneel.md new file mode 100644 index 0000000000..b0fd01a90a --- /dev/null +++ b/.changeset/flat-moons-kneel.md @@ -0,0 +1,5 @@ +--- + +--- + +Add the DateTimeInput catalog component with an internal Lynx calendar/time picker implementation. diff --git a/.github/a2ui-catalog.instructions.md b/.github/a2ui-catalog.instructions.md index 3890112bc2..9fa1a04232 100644 --- a/.github/a2ui-catalog.instructions.md +++ b/.github/a2ui-catalog.instructions.md @@ -33,3 +33,5 @@ When verifying `packages/genui/a2ui-playground`, remember that `pnpm -F @lynx-js For known A2UI playground examples, keep the web preview URL on `?demo=` instead of swapping it to the payload-store `messagesUrl`. `render.html` intentionally fetches known demo JSON in the browser shell and passes resolved messages into Lynx, avoiding fetch differences in the Lynx worker runtime; use payload-store URLs for custom edited JSON. For interactive A2UI playground component examples, bind mutable props through `{ path: ... }` and provide matching example data so the component preview emits an initial `updateDataModel` before `updateComponents`. Literal values render the initial state but cannot be changed by `setValue`, which only writes back to data-bound props. + +For the built-in `DateTimeInput`, keep date-enabled default output as `YYYY-MM-DD` unless `outputFormat` is explicitly provided. Implement calendar behavior inside `packages/genui/a2ui` with local helpers that borrow the `lynx-ui-calendar` windowing/date patterns as needed, because `@lynx-js/lynx-ui-calendar` is not an available package dependency here. diff --git a/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx b/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx index 6add387420..2895e503ab 100644 --- a/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx +++ b/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx @@ -8,6 +8,7 @@ import { CheckBox, ChoicePicker, Column, + DateTimeInput, Divider, Icon, Image, @@ -38,6 +39,7 @@ import cardManifest from '@lynx-js/a2ui-reactlynx/catalog/Card/catalog.json'; import checkBoxManifest from '@lynx-js/a2ui-reactlynx/catalog/CheckBox/catalog.json'; import choicePickerManifest from '@lynx-js/a2ui-reactlynx/catalog/ChoicePicker/catalog.json'; import columnManifest from '@lynx-js/a2ui-reactlynx/catalog/Column/catalog.json'; +import dateTimeInputManifest from '@lynx-js/a2ui-reactlynx/catalog/DateTimeInput/catalog.json'; import dividerManifest from '@lynx-js/a2ui-reactlynx/catalog/Divider/catalog.json'; import iconManifest from '@lynx-js/a2ui-reactlynx/catalog/Icon/catalog.json'; import imageManifest from '@lynx-js/a2ui-reactlynx/catalog/Image/catalog.json'; @@ -97,6 +99,7 @@ const ALL_BUILTINS: readonly CatalogInput[] = [ manifestEntry(Icon, iconManifest), manifestEntry(CheckBox, checkBoxManifest), manifestEntry(ChoicePicker, choicePickerManifest), + manifestEntry(DateTimeInput, dateTimeInputManifest), manifestEntry(LineChart, lineChartManifest), manifestEntry(PieChart, pieChartManifest), manifestEntry(RadioGroup, radioGroupManifest), diff --git a/packages/genui/a2ui-playground/src/catalog/a2ui.ts b/packages/genui/a2ui-playground/src/catalog/a2ui.ts index 1d0ae9ee5e..bbb535de1e 100644 --- a/packages/genui/a2ui-playground/src/catalog/a2ui.ts +++ b/packages/genui/a2ui-playground/src/catalog/a2ui.ts @@ -6,6 +6,7 @@ import cardManifest from '@lynx-js/a2ui-reactlynx/catalog/Card/catalog.json'; import checkBoxManifest from '@lynx-js/a2ui-reactlynx/catalog/CheckBox/catalog.json'; import choicePickerManifest from '@lynx-js/a2ui-reactlynx/catalog/ChoicePicker/catalog.json'; import columnManifest from '@lynx-js/a2ui-reactlynx/catalog/Column/catalog.json'; +import dateTimeInputManifest from '@lynx-js/a2ui-reactlynx/catalog/DateTimeInput/catalog.json'; import dividerManifest from '@lynx-js/a2ui-reactlynx/catalog/Divider/catalog.json'; import iconManifest from '@lynx-js/a2ui-reactlynx/catalog/Icon/catalog.json'; import imageManifest from '@lynx-js/a2ui-reactlynx/catalog/Image/catalog.json'; @@ -1428,6 +1429,68 @@ export const COMPONENT_CATALOG: ComponentDoc[] = [ openui: [], }, }, + { + name: 'DateTimeInput', + category: 'Input', + description: + 'A date and/or time input with a calendar panel and configurable output format.', + props: schemaToProps(dateTimeInputManifest), + usage: { + a2ui: { + id: 'date-input', + component: 'DateTimeInput', + label: 'Date', + value: { path: '/date' }, + enableDate: true, + enableTime: false, + }, + openui: {}, + }, + usageExamples: { + a2ui: [ + { + label: 'Date', + value: { + id: 'date-input', + component: 'DateTimeInput', + label: 'Date', + value: { path: '/date' }, + enableDate: true, + enableTime: false, + }, + data: { date: '2026-05-26' }, + }, + { + label: 'Date & Time', + value: { + id: 'datetime-input', + component: 'DateTimeInput', + label: 'Event date', + value: { path: '/eventDate' }, + enableDate: true, + enableTime: true, + outputFormat: 'YYYY-MM-DD HH:mm', + }, + data: { eventDate: '2026-05-26 19:30' }, + }, + { + label: 'Date Range', + value: { + id: 'booking-date', + component: 'DateTimeInput', + label: 'Booking date', + value: { path: '/bookingDate' }, + enableDate: true, + enableTime: false, + min: '2026-05-01', + max: '2026-05-31', + }, + data: { bookingDate: '2026-05-26' }, + }, + ], + openui: [], + }, + }, { name: 'ChoicePicker', category: 'Input', diff --git a/packages/genui/a2ui/package.json b/packages/genui/a2ui/package.json index 3634b220d6..abff77270f 100644 --- a/packages/genui/a2ui/package.json +++ b/packages/genui/a2ui/package.json @@ -100,6 +100,11 @@ "default": "./dist/catalog/ChoicePicker/index.js" }, "./catalog/ChoicePicker/catalog.json": "./dist/catalog/ChoicePicker/catalog.json", + "./catalog/DateTimeInput": { + "types": "./dist/catalog/DateTimeInput/index.d.ts", + "default": "./dist/catalog/DateTimeInput/index.js" + }, + "./catalog/DateTimeInput/catalog.json": "./dist/catalog/DateTimeInput/catalog.json", "./catalog/RadioGroup": { "types": "./dist/catalog/RadioGroup/index.d.ts", "default": "./dist/catalog/RadioGroup/index.js" diff --git a/packages/genui/a2ui/src/catalog/DateTimeInput/index.tsx b/packages/genui/a2ui/src/catalog/DateTimeInput/index.tsx new file mode 100644 index 0000000000..2afb44f775 --- /dev/null +++ b/packages/genui/a2ui/src/catalog/DateTimeInput/index.tsx @@ -0,0 +1,434 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { + DialogBackdrop, + DialogContent, + DialogRoot, + DialogView, +} from '@lynx-js/lynx-ui'; +import { useEffect, useState } from '@lynx-js/react'; + +import { + addMonths, + buildDateTimeMonthPage, + dateTimePartsToDate, + formatDateTimeInputValue, + getDateTimeDialogTitle, + getDateTimeInputPlaceholder, + getDefaultDateTimeParts, + getWeekdayLabels, + incrementDateTimePart, + isDateTimeAfterMax, + isDateTimeBeforeMin, + normalizeDateTimeInputLabel, + normalizeDateTimeInputMode, + normalizeDateTimeInputValue, + startOfMonth, + withDate, +} from './utils.js'; +import { useChecks } from '../../react/useChecks.js'; +import type { CheckLike } from '../../react/useChecks.js'; +import type { GenericComponentProps } from '../../store/types.js'; + +import '../../../styles/catalog/DateTimeInput.css'; + +const MONTH_LABELS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +function formatMonthCaption(month: Date): string { + return `${MONTH_LABELS[month.getMonth()]} ${month.getFullYear()}`; +} + +function joinClassNames(values: Array): string { + return values.filter(Boolean).join(' '); +} + +function getPartsKey(parts: ReturnType) { + return parts + ? formatDateTimeInputValue( + parts, + 'YYYY-MM-DD HH:mm', + { enableDate: true, enableTime: true }, + ) + : ''; +} + +/** + * @a2uiCatalog DateTimeInput + */ +export interface DateTimeInputProps extends GenericComponentProps { + /** The current date/time value. Typically bound to a data path. */ + value: string | { path: string }; + /** The text label for the input field. */ + label?: string | { path: string } | { + call: string; + args: Record; + returnType?: + | 'string' + | 'number' + | 'boolean' + | 'array' + | 'object' + | 'any' + | 'void'; + }; + /** Whether to show the date picker. */ + enableDate?: boolean; + /** Whether to show the time picker. */ + enableTime?: boolean; + /** Format string for the output value. Supports YYYY, MM, DD, HH, and mm. */ + outputFormat?: string; + /** Minimum allowed date/time value. */ + min?: string; + /** Maximum allowed date/time value. */ + max?: string; + /** A list of checks to perform. */ + checks?: Array<{ + /** The condition that indicates whether the check passes. */ + condition: boolean | { path: string } | { + call: string; + args: Record; + returnType?: + | 'string' + | 'number' + | 'boolean' + | 'array' + | 'object' + | 'any' + | 'void'; + }; + /** The error message to display if the check fails. */ + message: string; + }>; +} + +export function DateTimeInput( + props: DateTimeInputProps, +): import('@lynx-js/react').ReactNode { + const { + dataContextPath, + id, + label, + max, + min, + outputFormat, + setValue, + surface, + } = props; + const mode = normalizeDateTimeInputMode(props.enableDate, props.enableTime); + const valueParts = normalizeDateTimeInputValue(props.value); + const minParts = normalizeDateTimeInputValue(min); + const maxParts = normalizeDateTimeInputValue(max); + const valueKey = getPartsKey(valueParts); + const initialParts = valueParts ?? getDefaultDateTimeParts(); + const [open, setOpen] = useState(false); + const [draftParts, setDraftParts] = useState(initialParts); + const [visibleMonth, setVisibleMonth] = useState( + startOfMonth(dateTimePartsToDate(initialParts)), + ); + const checks = props.checks as CheckLike[] | undefined; + + const { ok, firstFailureMessage } = useChecks({ + checks, + componentId: id ?? '', + surface, + dataContextPath, + }); + + useEffect(() => { + if (open) return undefined; + const nextParts = valueParts ?? getDefaultDateTimeParts(); + setDraftParts(nextParts); + setVisibleMonth(startOfMonth(dateTimePartsToDate(nextParts))); + return undefined; + }, [open, valueKey]); + + const minDate = minParts ? dateTimePartsToDate(minParts) : null; + const maxDate = maxParts ? dateTimePartsToDate(maxParts) : null; + const draftDate = dateTimePartsToDate(draftParts); + const monthPage = buildDateTimeMonthPage({ + month: visibleMonth, + selectedDate: draftDate, + today: new Date(), + minDate, + maxDate, + }); + const weekdayLabels = getWeekdayLabels(); + const draftOutOfRange = isDateTimeBeforeMin(draftParts, minParts) + || isDateTimeAfterMax(draftParts, maxParts); + const currentOutOfRange = valueParts + ? isDateTimeBeforeMin(valueParts, minParts) + || isDateTimeAfterMax(valueParts, maxParts) + : false; + const invalid = !ok || currentOutOfRange; + const labelText = normalizeDateTimeInputLabel(label); + const displayValue = valueParts + ? formatDateTimeInputValue(valueParts, outputFormat, mode) + : getDateTimeInputPlaceholder(mode); + + const handleOpen = () => { + const nextParts = valueParts ?? draftParts ?? getDefaultDateTimeParts(); + setDraftParts(nextParts); + setVisibleMonth(startOfMonth(dateTimePartsToDate(nextParts))); + setOpen(true); + }; + + const handleCancel = () => { + setOpen(false); + }; + + const handleConfirm = () => { + if (draftOutOfRange) return; + setValue?.( + 'value', + formatDateTimeInputValue(draftParts, outputFormat, mode), + ); + setOpen(false); + }; + + const handlePreviousMonth = () => { + setVisibleMonth(addMonths(visibleMonth, -1)); + }; + + const handleNextMonth = () => { + setVisibleMonth(addMonths(visibleMonth, 1)); + }; + + const handleTimeStep = (part: 'hour' | 'minute', delta: number) => { + setDraftParts((current) => incrementDateTimePart(current, part, delta)); + }; + + const rootClassName = joinClassNames([ + 'datetime-input', + invalid && 'datetime-input-invalid', + ]); + + return ( + + {labelText + ? {labelText} + : null} + + {displayValue} + calendar_today + + {invalid && firstFailureMessage + ? {firstFailureMessage} + : null} + {currentOutOfRange + ? Date is out of range + : null} + + { + setOpen(nextOpen); + }} + > + + + + + + {getDateTimeDialogTitle(labelText, mode)} + + + close + + + + {mode.enableDate + ? ( + + + + + chevron_left + + + + {formatMonthCaption(visibleMonth)} + + + + chevron_right + + + + + + {weekdayLabels.map((weekday, weekdayIndex) => ( + + + {weekday} + + + ))} + + + + {monthPage.days.map((day, dayIndex) => ( + { + if (day.disabled) return; + setDraftParts((current) => + withDate(current, day.date) + ); + if (day.outside) { + setVisibleMonth(startOfMonth(day.date)); + } + }} + event-through={false} + > + + {String(day.day)} + + + ))} + + + ) + : null} + + {mode.enableTime + ? ( + + Time + + + handleTimeStep('hour', 1)} + event-through={false} + > + + expand_less + + + + {String(draftParts.hour).padStart(2, '0')} + + handleTimeStep('hour', -1)} + event-through={false} + > + + expand_more + + + + : + + handleTimeStep('minute', 1)} + event-through={false} + > + + expand_less + + + + {String(draftParts.minute).padStart(2, '0')} + + handleTimeStep('minute', -1)} + event-through={false} + > + + expand_more + + + + + + ) + : null} + + {draftOutOfRange + ? ( + + Date is out of range + + ) + : null} + + + + + Cancel + + + + + Done + + + + + + + + ); +} diff --git a/packages/genui/a2ui/src/catalog/DateTimeInput/utils.ts b/packages/genui/a2ui/src/catalog/DateTimeInput/utils.ts new file mode 100644 index 0000000000..09e225e00a --- /dev/null +++ b/packages/genui/a2ui/src/catalog/DateTimeInput/utils.ts @@ -0,0 +1,405 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +const DAYS_PER_WEEK = 7; +const DATE_TIME_DAYS_PER_MONTH_PAGE = 42; +const DEFAULT_WEEKDAY_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; + +export interface DateTimeParts { + year: number; + month: number; + day: number; + hour: number; + minute: number; +} + +export interface DateTimeInputMode { + enableDate: boolean; + enableTime: boolean; +} + +export interface DateTimeDayInfo { + date: Date; + dateKey: string; + day: number; + outside: boolean; + selected: boolean; + today: boolean; + disabled: boolean; +} + +export interface DateTimeMonthPage { + month: Date; + monthKey: string; + days: DateTimeDayInfo[]; +} + +export interface BuildDateTimeMonthPageOptions { + month: Date; + selectedDate: Date | null; + today: Date; + minDate: Date | null; + maxDate: Date | null; + weekStartsOn?: number; +} + +function pad2(value: number): string { + return value < 10 ? `0${value}` : `${value}`; +} + +function pad4(value: number): string { + if (value < 10) return `000${value}`; + if (value < 100) return `00${value}`; + if (value < 1000) return `0${value}`; + return `${value}`; +} + +function createLocalDate( + year: number, + month: number, + day = 1, + hour = 0, + minute = 0, +): Date { + return new Date(year, month, day, hour, minute, 0, 0); +} + +function isValidDate(value: Date): boolean { + return !Number.isNaN(value.getTime()); +} + +function daysInMonth(year: number, month: number): number { + return createLocalDate(year, month, 0).getDate(); +} + +function isValidDatePart(year: number, month: number, day: number): boolean { + return month >= 1 + && month <= 12 + && day >= 1 + && day <= daysInMonth(year, month); +} + +function isValidTimePart(hour: number, minute: number): boolean { + return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59; +} + +function partsFromDate(value: Date): DateTimeParts { + return { + year: value.getFullYear(), + month: value.getMonth() + 1, + day: value.getDate(), + hour: value.getHours(), + minute: value.getMinutes(), + }; +} + +function parseDateTimeString(value: string): DateTimeParts | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + const dateTimeMatch = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?$/ + .exec(trimmed); + if (dateTimeMatch) { + const [, year, month, day, hour = '0', minute = '0'] = dateTimeMatch; + const yearValue = Number(year); + const monthValue = Number(month); + const dayValue = Number(day); + const hourValue = Number(hour); + const minuteValue = Number(minute); + if ( + !isValidDatePart(yearValue, monthValue, dayValue) + || !isValidTimePart(hourValue, minuteValue) + ) { + return null; + } + const date = createLocalDate( + yearValue, + monthValue - 1, + dayValue, + hourValue, + minuteValue, + ); + return isValidDate(date) ? partsFromDate(date) : null; + } + + const timeMatch = /^(\d{2}):(\d{2})$/.exec(trimmed); + if (timeMatch) { + const today = new Date(); + const [, hour, minute] = timeMatch; + const hourValue = Number(hour); + const minuteValue = Number(minute); + if (!isValidTimePart(hourValue, minuteValue)) return null; + const date = createLocalDate( + today.getFullYear(), + today.getMonth(), + today.getDate(), + hourValue, + minuteValue, + ); + return isValidDate(date) ? partsFromDate(date) : null; + } + + const date = new Date(trimmed); + return isValidDate(date) ? partsFromDate(date) : null; +} + +export function normalizeDateTimeInputValue( + value: unknown, +): DateTimeParts | null { + if (value instanceof Date) { + return isValidDate(value) ? partsFromDate(value) : null; + } + + if (typeof value === 'number') { + const date = new Date(value); + return isValidDate(date) ? partsFromDate(date) : null; + } + + if (typeof value === 'string') { + return parseDateTimeString(value); + } + + return null; +} + +export function getDefaultDateTimeParts(now = new Date()): DateTimeParts { + return { + year: now.getFullYear(), + month: now.getMonth() + 1, + day: now.getDate(), + hour: now.getHours(), + minute: now.getMinutes(), + }; +} + +export function dateTimePartsToDate(parts: DateTimeParts): Date { + return createLocalDate( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + ); +} + +export function dateTimePartsToDateKey(parts: DateTimeParts): string { + return `${pad4(parts.year)}-${pad2(parts.month)}-${pad2(parts.day)}`; +} + +export function formatDateKey(date: Date): string { + return [ + pad4(date.getFullYear()), + pad2(date.getMonth() + 1), + pad2(date.getDate()), + ].join('-'); +} + +export function formatMonthKey(date: Date): string { + return `${pad4(date.getFullYear())}-${pad2(date.getMonth() + 1)}`; +} + +export function startOfMonth(date: Date): Date { + return createLocalDate(date.getFullYear(), date.getMonth(), 1); +} + +export function addMonths(date: Date, offset: number): Date { + return createLocalDate(date.getFullYear(), date.getMonth() + offset, 1); +} + +export function getWeekdayLabels( + weekStartsOn = 0, + labels = DEFAULT_WEEKDAY_LABELS, +): string[] { + const start = Math.max(0, Math.min(6, Math.floor(weekStartsOn))); + return Array.from({ length: DAYS_PER_WEEK }, (_, index) => { + const weekdayIndex = (start + index) % DAYS_PER_WEEK; + return labels[weekdayIndex] ?? DEFAULT_WEEKDAY_LABELS[weekdayIndex]!; + }); +} + +function compareDateOnly(a: Date, b: Date): number { + const left = a.getFullYear() * 10000 + + (a.getMonth() + 1) * 100 + + a.getDate(); + const right = b.getFullYear() * 10000 + + (b.getMonth() + 1) * 100 + + b.getDate(); + return left - right; +} + +export function compareDateTimeParts( + a: DateTimeParts, + b: DateTimeParts, +): number { + const left = a.year * 100000000 + + a.month * 1000000 + + a.day * 10000 + + a.hour * 100 + + a.minute; + const right = b.year * 100000000 + + b.month * 1000000 + + b.day * 10000 + + b.hour * 100 + + b.minute; + return left - right; +} + +export function isDateTimeBeforeMin( + value: DateTimeParts, + min: DateTimeParts | null, +): boolean { + return min !== null && compareDateTimeParts(value, min) < 0; +} + +export function isDateTimeAfterMax( + value: DateTimeParts, + max: DateTimeParts | null, +): boolean { + return max !== null && compareDateTimeParts(value, max) > 0; +} + +export function buildDateTimeMonthPage({ + month, + selectedDate, + today, + minDate, + maxDate, + weekStartsOn = 0, +}: BuildDateTimeMonthPageOptions): DateTimeMonthPage { + const monthStart = startOfMonth(month); + const normalizedWeekStart = Math.max( + 0, + Math.min( + 6, + Math.floor( + weekStartsOn, + ), + ), + ); + const firstWeekdayOffset = + (monthStart.getDay() - normalizedWeekStart + DAYS_PER_WEEK) + % DAYS_PER_WEEK; + const gridStart = createLocalDate( + monthStart.getFullYear(), + monthStart.getMonth(), + 1 - firstWeekdayOffset, + ); + const selectedDateKey = selectedDate ? formatDateKey(selectedDate) : null; + const todayKey = formatDateKey(today); + + const days = Array.from( + { length: DATE_TIME_DAYS_PER_MONTH_PAGE }, + (_, index): DateTimeDayInfo => { + const date = createLocalDate( + gridStart.getFullYear(), + gridStart.getMonth(), + gridStart.getDate() + index, + ); + const dateKey = formatDateKey(date); + const outside = date.getMonth() !== monthStart.getMonth(); + const disabled = (minDate !== null && compareDateOnly(date, minDate) < 0) + || (maxDate !== null && compareDateOnly(date, maxDate) > 0); + + return { + date, + dateKey, + day: date.getDate(), + outside, + selected: dateKey === selectedDateKey, + today: dateKey === todayKey, + disabled, + }; + }, + ); + + return { + month: monthStart, + monthKey: formatMonthKey(monthStart), + days, + }; +} + +export function normalizeDateTimeInputMode( + enableDate: unknown, + enableTime: unknown, +): DateTimeInputMode { + const date = enableDate !== false; + const time = enableTime !== false; + if (!date && !time) { + return { enableDate: true, enableTime: false }; + } + return { enableDate: date, enableTime: time }; +} + +export function normalizeDateTimeInputLabel(value: unknown): string { + if ( + typeof value === 'string' + || typeof value === 'number' + || typeof value === 'boolean' + ) { + return String(value); + } + return ''; +} + +function getDefaultOutputFormat(mode: DateTimeInputMode): string { + if (!mode.enableDate && mode.enableTime) return 'HH:mm'; + return 'YYYY-MM-DD'; +} + +export function formatDateTimeInputValue( + parts: DateTimeParts, + outputFormat: unknown, + mode: DateTimeInputMode, +): string { + const format = typeof outputFormat === 'string' && outputFormat.trim() + ? outputFormat + : getDefaultOutputFormat(mode); + + return format + .replaceAll('YYYY', pad4(parts.year)) + .replaceAll('MM', pad2(parts.month)) + .replaceAll('DD', pad2(parts.day)) + .replaceAll('HH', pad2(parts.hour)) + .replaceAll('mm', pad2(parts.minute)); +} + +export function getDateTimeInputPlaceholder(mode: DateTimeInputMode): string { + if (mode.enableDate && mode.enableTime) return 'Select date and time'; + if (mode.enableTime) return 'Select time'; + return 'Select date'; +} + +export function getDateTimeDialogTitle( + label: string, + mode: DateTimeInputMode, +): string { + return label || getDateTimeInputPlaceholder(mode); +} + +export function withDate( + parts: DateTimeParts, + date: Date, +): DateTimeParts { + return { + ...parts, + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate(), + }; +} + +export function incrementDateTimePart( + parts: DateTimeParts, + part: 'hour' | 'minute', + delta: number, +): DateTimeParts { + const limit = part === 'hour' ? 24 : 60; + const current = parts[part]; + const next = ((current + delta) % limit + limit) % limit; + return { + ...parts, + [part]: next, + }; +} diff --git a/packages/genui/a2ui/src/catalog/README.md b/packages/genui/a2ui/src/catalog/README.md index c7df4bdc52..1d697e43fd 100644 --- a/packages/genui/a2ui/src/catalog/README.md +++ b/packages/genui/a2ui/src/catalog/README.md @@ -71,6 +71,7 @@ import { Card, CheckBox, ChoicePicker, + DateTimeInput, Column, Divider, Icon, @@ -98,6 +99,9 @@ import checkBoxManifest from '@lynx-js/a2ui-reactlynx/catalog/CheckBox/catalog.j import choicePickerManifest from '@lynx-js/a2ui-reactlynx/catalog/ChoicePicker/catalog.json' with { type: 'json', }; +import dateTimeInputManifest from '@lynx-js/a2ui-reactlynx/catalog/DateTimeInput/catalog.json' with { + type: 'json', +}; import columnManifest from '@lynx-js/a2ui-reactlynx/catalog/Column/catalog.json' with { type: 'json', }; @@ -156,6 +160,7 @@ export const allBuiltins = defineCatalog([ [TextField, textFieldManifest], [CheckBox, checkBoxManifest], [ChoicePicker, choicePickerManifest], + [DateTimeInput, dateTimeInputManifest], [Icon, iconManifest], [RadioGroup, radioGroupManifest], [Slider, sliderManifest], diff --git a/packages/genui/a2ui/src/catalog/index.ts b/packages/genui/a2ui/src/catalog/index.ts index 65ed855789..c6c2567966 100755 --- a/packages/genui/a2ui/src/catalog/index.ts +++ b/packages/genui/a2ui/src/catalog/index.ts @@ -28,6 +28,7 @@ export { Button } from './Button/index.jsx'; export { Card } from './Card/index.jsx'; export { CheckBox } from './CheckBox/index.jsx'; export { ChoicePicker } from './ChoicePicker/index.jsx'; +export { DateTimeInput } from './DateTimeInput/index.jsx'; export { LineChart } from './LineChart/index.jsx'; export { PieChart } from './PieChart/index.jsx'; export { Column } from './Column/index.jsx'; diff --git a/packages/genui/a2ui/src/index.ts b/packages/genui/a2ui/src/index.ts index 162119d258..5ab55e7624 100644 --- a/packages/genui/a2ui/src/index.ts +++ b/packages/genui/a2ui/src/index.ts @@ -90,6 +90,7 @@ export { Card, CheckBox, ChoicePicker, + DateTimeInput, Column, Divider, Image, diff --git a/packages/genui/a2ui/styles/catalog/DateTimeInput.css b/packages/genui/a2ui/styles/catalog/DateTimeInput.css new file mode 100644 index 0000000000..852df66ba7 --- /dev/null +++ b/packages/genui/a2ui/styles/catalog/DateTimeInput.css @@ -0,0 +1,375 @@ +@import "../theme.css"; + +.datetime-input { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + min-width: 0; +} + +.datetime-input-label { + color: var(--a2ui-color-text-muted); + font-size: 13px; + font-weight: 600; + line-height: 1.5; +} + +.datetime-input-control { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; + min-height: 44px; + padding-top: 8px; + padding-right: 12px; + padding-bottom: 8px; + padding-left: 12px; + border-width: 1px; + border-style: solid; + border-color: var(--a2ui-color-border-strong); + border-radius: 12px; + background-color: var(--a2ui-color-surface-strong); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.45) inset; +} + +.datetime-input-invalid .datetime-input-control { + border-color: var(--a2ui-color-input-error); +} + +.datetime-input-value { + min-width: 0; + color: var(--a2ui-color-on-input); + font-size: 15px; + line-height: 1.5; +} + +.datetime-input-control-placeholder .datetime-input-value { + color: var(--a2ui-color-input-placeholder); +} + +.datetime-input-icon, +.datetime-dialog-close-icon, +.datetime-calendar-nav-icon, +.datetime-time-stepper-icon { + font-family: var(--a2ui-icon-font-family); + font-style: normal; + font-weight: normal; + line-height: 1; +} + +.datetime-input-icon { + flex-shrink: 0; + color: var(--a2ui-icon-color-muted); + font-size: 20px; +} + +.datetime-input-error, +.datetime-dialog-error { + color: var(--a2ui-color-input-error); + font-size: 11px; + line-height: 1.5; +} + +.datetime-dialog-view { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: 20px; + box-sizing: border-box; +} + +.datetime-dialog-backdrop { + background-color: rgba(15, 23, 42, 0.28); + transition: opacity 0.18s ease; +} + +.datetime-dialog-backdrop.ui-entering, +.datetime-dialog-backdrop.ui-open { + opacity: 1; +} + +.datetime-dialog-backdrop.ui-leaving, +.datetime-dialog-backdrop.ui-closed { + opacity: 0; +} + +.datetime-dialog-content { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + max-width: 380px; + padding: 18px; + border-width: 1px; + border-style: solid; + border-color: var(--a2ui-color-border-strong); + border-radius: 20px; + background-color: var(--a2ui-color-surface-strong); + box-shadow: 0 16px 40px var(--a2ui-color-overlay); + box-sizing: border-box; + transition: opacity 0.18s ease, transform 0.18s ease; +} + +.datetime-dialog-content.ui-entering, +.datetime-dialog-content.ui-open { + opacity: 1; + transform: translateY(0); +} + +.datetime-dialog-content.ui-leaving, +.datetime-dialog-content.ui-closed { + opacity: 0; + transform: translateY(8px); +} + +.datetime-dialog-header, +.datetime-calendar-header, +.datetime-dialog-actions { + display: flex; + flex-direction: row; + align-items: center; +} + +.datetime-dialog-header, +.datetime-calendar-header { + justify-content: space-between; + gap: 12px; +} + +.datetime-dialog-title { + min-width: 0; + color: var(--a2ui-color-on-surface); + font-size: 17px; + font-weight: 700; + line-height: 1.4; +} + +.datetime-dialog-close, +.datetime-calendar-nav { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 34px; + height: 34px; + border-radius: 999px; + background-color: var(--a2ui-color-surface-muted); + color: var(--a2ui-color-text-muted); +} + +.datetime-dialog-close.ui-active, +.datetime-calendar-nav.ui-active { + background-color: var(--a2ui-color-secondary); +} + +.datetime-dialog-close-icon, +.datetime-calendar-nav-icon { + color: inherit; + font-size: 20px; +} + +.datetime-calendar { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + min-width: 0; +} + +.datetime-calendar-caption { + min-width: 0; + color: var(--a2ui-color-on-surface); + font-size: 15px; + font-weight: 700; + line-height: 1.4; + text-align: center; +} + +.datetime-weekdays, +.datetime-month { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr; + width: 100%; +} + +.datetime-weekday { + display: flex; + align-items: center; + justify-content: center; + height: 24px; +} + +.datetime-weekday-text { + color: var(--a2ui-color-text-subtle); + font-size: 11px; + font-weight: 700; + line-height: 1.2; +} + +.datetime-month { + grid-template-rows: 40px 40px 40px 40px 40px 40px; +} + +.datetime-day { + display: flex; + align-items: center; + justify-content: center; + margin: 2px; + border-width: 1px; + border-style: solid; + border-color: transparent; + border-radius: 10px; + background-color: transparent; +} + +.datetime-day.ui-active { + background-color: var(--a2ui-color-surface-muted); +} + +.datetime-day-text { + color: var(--a2ui-color-on-surface); + font-size: 14px; + font-weight: 600; + line-height: 1.2; +} + +.datetime-day-outside .datetime-day-text { + color: var(--a2ui-color-text-subtle); +} + +.datetime-day-today { + border-color: var(--a2ui-color-primary); +} + +.datetime-day-selected { + border-color: var(--a2ui-color-primary); + background-color: var(--a2ui-color-primary); +} + +.datetime-day-selected .datetime-day-text { + color: var(--a2ui-color-on-primary); +} + +.datetime-day-disabled { + opacity: 0.36; +} + +.datetime-time { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + min-width: 0; +} + +.datetime-time-label { + color: var(--a2ui-color-text-muted); + font-size: 13px; + font-weight: 600; + line-height: 1.5; +} + +.datetime-time-fields { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 12px; +} + +.datetime-time-field { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 72px; + min-width: 0; + overflow: hidden; + border-width: 1px; + border-style: solid; + border-color: var(--a2ui-color-border-strong); + border-radius: 14px; + background-color: var(--a2ui-color-surface); +} + +.datetime-time-value { + color: var(--a2ui-color-on-surface); + font-size: 24px; + font-weight: 700; + line-height: 1.4; +} + +.datetime-time-separator { + color: var(--a2ui-color-text-muted); + font-size: 24px; + font-weight: 700; + line-height: 1.4; +} + +.datetime-time-stepper { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 30px; + color: var(--a2ui-color-text-muted); +} + +.datetime-time-stepper.ui-active { + background-color: var(--a2ui-color-surface-muted); +} + +.datetime-time-stepper-icon { + color: inherit; + font-size: 22px; +} + +.datetime-dialog-actions { + justify-content: flex-end; + gap: 10px; +} + +.datetime-dialog-button { + display: flex; + align-items: center; + justify-content: center; + min-width: 86px; + min-height: 40px; + padding-top: 8px; + padding-right: 16px; + padding-bottom: 8px; + padding-left: 16px; + border-radius: 999px; +} + +.datetime-dialog-button-secondary { + background-color: var(--a2ui-color-surface-muted); +} + +.datetime-dialog-button-primary { + background-color: var(--a2ui-color-primary); +} + +.datetime-dialog-button-disabled { + opacity: 0.45; +} + +.datetime-dialog-button-text-secondary, +.datetime-dialog-button-text-primary { + font-size: 14px; + font-weight: 700; + line-height: 1.4; +} + +.datetime-dialog-button-text-secondary { + color: var(--a2ui-color-on-surface); +} + +.datetime-dialog-button-text-primary { + color: var(--a2ui-color-on-primary); +} diff --git a/packages/genui/a2ui/test/dateTimeInput.test.ts b/packages/genui/a2ui/test/dateTimeInput.test.ts new file mode 100644 index 0000000000..1068d1cc30 --- /dev/null +++ b/packages/genui/a2ui/test/dateTimeInput.test.ts @@ -0,0 +1,191 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { describe, expect, test } from '@rstest/core'; + +import { + buildDateTimeMonthPage, + compareDateTimeParts, + formatDateTimeInputValue, + getDateTimeDialogTitle, + getDateTimeInputPlaceholder, + getWeekdayLabels, + incrementDateTimePart, + normalizeDateTimeInputLabel, + normalizeDateTimeInputMode, + normalizeDateTimeInputValue, +} from '../src/catalog/DateTimeInput/utils.js'; + +describe('DateTimeInput utils', () => { + test('parses date-only values as local calendar parts', () => { + expect(normalizeDateTimeInputValue('2026-05-26')).toEqual({ + year: 2026, + month: 5, + day: 26, + hour: 0, + minute: 0, + }); + }); + + test('parses local date-time values', () => { + expect(normalizeDateTimeInputValue('2026-05-26 09:30')).toEqual({ + year: 2026, + month: 5, + day: 26, + hour: 9, + minute: 30, + }); + expect(normalizeDateTimeInputValue('2026-05-26T09:30')).toEqual({ + year: 2026, + month: 5, + day: 26, + hour: 9, + minute: 30, + }); + }); + + test('ignores unsupported values', () => { + expect(normalizeDateTimeInputValue(undefined)).toBeNull(); + expect(normalizeDateTimeInputValue('bad')).toBeNull(); + expect(normalizeDateTimeInputValue({ path: '/date' })).toBeNull(); + expect(normalizeDateTimeInputValue('2026-02-31')).toBeNull(); + expect(normalizeDateTimeInputValue('2026-13-01')).toBeNull(); + expect(normalizeDateTimeInputValue('24:99')).toBeNull(); + }); + + test('normalizes modes with a usable fallback', () => { + expect(normalizeDateTimeInputMode(undefined, undefined)).toEqual({ + enableDate: true, + enableTime: true, + }); + expect(normalizeDateTimeInputMode(true, false)).toEqual({ + enableDate: true, + enableTime: false, + }); + expect(normalizeDateTimeInputMode(false, false)).toEqual({ + enableDate: true, + enableTime: false, + }); + }); + + test('formats output values', () => { + const parts = { + year: 2026, + month: 5, + day: 26, + hour: 9, + minute: 7, + }; + expect( + formatDateTimeInputValue( + parts, + undefined, + { enableDate: true, enableTime: true }, + ), + ).toBe('2026-05-26'); + expect( + formatDateTimeInputValue( + parts, + 'YYYY-MM-DD HH:mm', + { enableDate: true, enableTime: true }, + ), + ).toBe('2026-05-26 09:07'); + expect( + formatDateTimeInputValue( + parts, + undefined, + { enableDate: false, enableTime: true }, + ), + ).toBe('09:07'); + }); + + test('builds a six-week month page so long months are complete', () => { + const page = buildDateTimeMonthPage({ + month: new Date(2026, 7, 1), + selectedDate: new Date(2026, 7, 31), + today: new Date(2026, 7, 2), + minDate: null, + maxDate: null, + }); + + expect(page.days).toHaveLength(42); + expect(page.days.some((day) => day.dateKey === '2026-08-31')).toBe(true); + expect( + page.days.find((day) => day.dateKey === '2026-08-31')?.selected, + ).toBe(true); + }); + + test('marks days outside min and max as disabled', () => { + const page = buildDateTimeMonthPage({ + month: new Date(2026, 4, 1), + selectedDate: null, + today: new Date(2026, 4, 1), + minDate: new Date(2026, 4, 10), + maxDate: new Date(2026, 4, 20), + }); + + expect(page.days.find((day) => day.dateKey === '2026-05-09')?.disabled) + .toBe(true); + expect(page.days.find((day) => day.dateKey === '2026-05-10')?.disabled) + .toBe(false); + expect(page.days.find((day) => day.dateKey === '2026-05-21')?.disabled) + .toBe(true); + }); + + test('compares date-time parts and wraps time stepping', () => { + expect( + compareDateTimeParts( + { year: 2026, month: 5, day: 26, hour: 9, minute: 30 }, + { year: 2026, month: 5, day: 26, hour: 9, minute: 31 }, + ), + ).toBeLessThan(0); + expect( + incrementDateTimePart( + { year: 2026, month: 5, day: 26, hour: 23, minute: 59 }, + 'hour', + 1, + ).hour, + ).toBe(0); + expect( + incrementDateTimePart( + { year: 2026, month: 5, day: 26, hour: 23, minute: 59 }, + 'minute', + 1, + ).minute, + ).toBe(0); + expect( + incrementDateTimePart( + { year: 2026, month: 5, day: 26, hour: 23, minute: 59 }, + 'minute', + -121, + ).minute, + ).toBe(58); + }); + + test('normalizes labels, titles, placeholders, and weekday labels', () => { + expect(normalizeDateTimeInputLabel('Due')).toBe('Due'); + expect(normalizeDateTimeInputLabel(7)).toBe('7'); + expect(normalizeDateTimeInputLabel({ path: '/label' })).toBe(''); + expect( + getDateTimeDialogTitle('', { enableDate: false, enableTime: true }), + ).toBe('Select time'); + expect( + getDateTimeDialogTitle('', { enableDate: true, enableTime: false }), + ).toBe('Select date'); + expect( + getDateTimeDialogTitle('Due', { enableDate: false, enableTime: true }), + ).toBe('Due'); + expect( + getDateTimeInputPlaceholder({ enableDate: true, enableTime: true }), + ).toBe('Select date and time'); + expect(getWeekdayLabels(1)).toEqual([ + 'Mo', + 'Tu', + 'We', + 'Th', + 'Fr', + 'Sa', + 'Su', + ]); + }); +}); diff --git a/packages/genui/server/agent/a2ui-catalog.ts b/packages/genui/server/agent/a2ui-catalog.ts index c1c06899c9..430eb5f60c 100644 --- a/packages/genui/server/agent/a2ui-catalog.ts +++ b/packages/genui/server/agent/a2ui-catalog.ts @@ -9,6 +9,7 @@ import cardManifest from './catalog/Card/catalog.json'; import checkBoxManifest from './catalog/CheckBox/catalog.json'; import choicePickerManifest from './catalog/ChoicePicker/catalog.json'; import columnManifest from './catalog/Column/catalog.json'; +import dateTimeInputManifest from './catalog/DateTimeInput/catalog.json'; import dividerManifest from './catalog/Divider/catalog.json'; import iconManifest from './catalog/Icon/catalog.json'; import imageManifest from './catalog/Image/catalog.json'; @@ -95,6 +96,7 @@ const CATALOG_MANIFESTS = [ textFieldManifest, checkBoxManifest, choicePickerManifest, + dateTimeInputManifest, radioGroupManifest, sliderManifest, ] as const; @@ -108,6 +110,8 @@ const COMPONENT_SUMMARIES: Record = { ChoicePicker: 'Single- or multi-select choice picker with checkbox and chip display styles.', Column: 'Vertical layout container.', + DateTimeInput: + 'Date and/or time input with a calendar panel. Without outputFormat, date-enabled inputs write YYYY-MM-DD.', Divider: 'Horizontal or vertical separator line.', Icon: 'Display an icon by name.', Image: 'Display an image by URL.', diff --git a/packages/genui/server/agent/catalog/DateTimeInput/catalog.json b/packages/genui/server/agent/catalog/DateTimeInput/catalog.json new file mode 100644 index 0000000000..c54d7b97e7 --- /dev/null +++ b/packages/genui/server/agent/catalog/DateTimeInput/catalog.json @@ -0,0 +1,165 @@ +{ + "DateTimeInput": { + "properties": { + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + ], + "description": "The current date/time value. Typically bound to a data path." + }, + "label": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "call": { + "type": "string" + }, + "args": { + "type": "object", + "additionalProperties": true + }, + "returnType": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "object", + "array", + "any", + "void" + ] + } + }, + "required": [ + "call", + "args" + ], + "additionalProperties": false + } + ], + "description": "The text label for the input field." + }, + "enableDate": { + "type": "boolean", + "description": "Whether to show the date picker." + }, + "enableTime": { + "type": "boolean", + "description": "Whether to show the time picker." + }, + "outputFormat": { + "type": "string", + "description": "Format string for the output value. Supports YYYY, MM, DD, HH, and mm." + }, + "min": { + "type": "string", + "description": "Minimum allowed date/time value." + }, + "max": { + "type": "string", + "description": "Maximum allowed date/time value." + }, + "checks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "condition": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "call": { + "type": "string" + }, + "args": { + "type": "object", + "additionalProperties": true + }, + "returnType": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "object", + "array", + "any", + "void" + ] + } + }, + "required": [ + "call", + "args" + ], + "additionalProperties": false + } + ], + "description": "The condition that indicates whether the check passes." + }, + "message": { + "type": "string", + "description": "The error message to display if the check fails." + } + }, + "required": [ + "condition", + "message" + ], + "additionalProperties": false + }, + "description": "A list of checks to perform." + } + }, + "required": [ + "value" + ] + } +}