From 7d7f87f7f0c948e6b9bd06eb6739c0fc930fd0f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:49:38 +0000 Subject: [PATCH 1/5] feat(fields): record forms draw date fields with the platform date picker A record form's date and datetime fields render a locale-format text box with a calendar popover, keyboard entry, clear, and today / tomorrow shortcuts instead of the browser's native input. The value written is unchanged: YYYY-MM-DD for date, an ISO instant for datetime. Only the form registration takes the picker; inline editors and getLazyFieldWidget keep the native input. Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- .changeset/12622-form-date-picker.md | 12 + packages/fields/src/index.tsx | 29 ++- .../widgets/DatePickerField-12622.test.tsx | 179 ++++++++++++++ .../fields/src/widgets/DatePickerField.tsx | 232 ++++++++++++++++++ .../fields/src/widgets/localeDateText.test.ts | 78 ++++++ packages/fields/src/widgets/localeDateText.ts | 170 +++++++++++++ .../fields/src/widgets/useFieldTranslation.ts | 8 + packages/i18n/src/locales/ar.ts | 8 + packages/i18n/src/locales/de.ts | 8 + packages/i18n/src/locales/en.ts | 11 + packages/i18n/src/locales/es.ts | 8 + packages/i18n/src/locales/fr.ts | 8 + packages/i18n/src/locales/ja.ts | 8 + packages/i18n/src/locales/ko.ts | 8 + packages/i18n/src/locales/pt.ts | 8 + packages/i18n/src/locales/ru.ts | 8 + packages/i18n/src/locales/zh.ts | 8 + .../recordFormDatePicker-12622.test.tsx | 95 +++++++ 18 files changed, 883 insertions(+), 3 deletions(-) create mode 100644 .changeset/12622-form-date-picker.md create mode 100644 packages/fields/src/widgets/DatePickerField-12622.test.tsx create mode 100644 packages/fields/src/widgets/DatePickerField.tsx create mode 100644 packages/fields/src/widgets/localeDateText.test.ts create mode 100644 packages/fields/src/widgets/localeDateText.ts create mode 100644 packages/plugin-form/src/__tests__/recordFormDatePicker-12622.test.tsx diff --git a/.changeset/12622-form-date-picker.md b/.changeset/12622-form-date-picker.md new file mode 100644 index 0000000000..83587ef9e1 --- /dev/null +++ b/.changeset/12622-form-date-picker.md @@ -0,0 +1,12 @@ +--- +'@object-ui/fields': patch +'@object-ui/i18n': patch +--- + +Record forms draw `date` and `datetime` fields with the platform date picker instead of the browser's native date input (objectui#12622). + +The picker is a text box in the display locale's format (`10/16/2026` in English, `2026/10/16` in Chinese), with a calendar popover, keyboard entry, a clear button, and Today / Tomorrow shortcuts. The native input took its placeholder and digit order from the machine, so an English console could show a Chinese machine's placeholder. + +- **The value written is unchanged.** A `date` field writes `YYYY-MM-DD` and a `datetime` field writes an ISO instant, as the native inputs did. Clearing writes an empty value, as before. Typed text that names no date is not written: the box is marked invalid and a notice names the expected format. +- **Where it applies.** The form registration of `field:date` / `field:datetime` (what `registerField` / `registerAllFields` register) takes the picker. The inline editors (`FieldEditWidget`, the detail inline editor, the kanban required-fields dialog), `getLazyFieldWidget` (the action-param and bulk-action dialogs), and a form without `@object-ui/fields` registered keep the native input. The read-only face is unchanged. +- **New language-pack keys** in all ten packs: `fields.datePicker.today`, `tomorrow`, `clear`, `openCalendar` and `invalid`. diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 8d8a77e75a..9bce63878f 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -4483,8 +4483,9 @@ const lazyFieldWidgets = new Map>(); /** * Lazily-loaded form field widget for a field type. Shares the exact loaders - * of {@link fieldWidgetMap} (the same components `registerField` registers for - * forms), wrapped in `React.lazy` and cached per type — so a consumer can + * of {@link fieldWidgetMap} (the components `registerField` registers for + * forms, except the form-only faces in `FORM_FACE_LOADERS`), wrapped in + * `React.lazy` and cached per type — so a consumer can * render any form-supported field type without eagerly bundling every widget. * Render inside a `` boundary. Unknown types resolve to the `text` * widget via {@link resolveFormWidgetType}. @@ -4716,6 +4717,26 @@ export const FIELD_WIDGET_LABELLING: Record< 'permission-ref': 'control', }; +/** + * The FORM face of a field type, where it differs from the widget + * {@link fieldWidgetMap} loads (objectui#12622). + * + * A record form draws `date` / `datetime` with the platform date picker: a + * text box in the display locale's format, a calendar popover, clear, and + * today / tomorrow shortcuts. The ruling scopes it to record forms, so only + * the registry registration (what a form resolves `field:date` / + * `field:datetime` to) takes it. {@link getLazyFieldWidget} (the action-param + * and bulk-action dialogs) and the inline editors (`FieldEditWidget`, which + * imports `DateField` / `DateTimeField` directly) keep the native input. + * + * The value written is the native input's: `YYYY-MM-DD` for `date`, an ISO + * instant for `datetime` (see `widgets/DatePickerField.tsx`). + */ +const FORM_FACE_LOADERS: Partial> = { + date: () => import('./widgets/DatePickerField.js').then(m => ({ default: m.DatePickerField })), + datetime: () => import('./widgets/DatePickerField.js').then(m => ({ default: m.DateTimePickerField })), +}; + export function registerField(fieldType: string): void { const loader = fieldWidgetLoaderByKey[fieldType]; if (!loader) { @@ -4724,7 +4745,9 @@ export function registerField(fieldType: string): void { } // Create lazy component - const LazyFieldWidget = React.lazy(loader); + const LazyFieldWidget = React.lazy( + FORM_FACE_LOADERS[fieldType as RegisteredFieldWidgetType] ?? loader, + ); // Register with field namespace. No LAYOUT wrapper — the form renderer owns // label/description/spacing. The only thing wrapped is the metadata carrier: diff --git a/packages/fields/src/widgets/DatePickerField-12622.test.tsx b/packages/fields/src/widgets/DatePickerField-12622.test.tsx new file mode 100644 index 0000000000..55800eeabd --- /dev/null +++ b/packages/fields/src/widgets/DatePickerField-12622.test.tsx @@ -0,0 +1,179 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12622 — the record form's date picker. + * + * A `date` / `datetime` field in a record form drew the browser's native + * input, whose placeholder and digit order are the MACHINE's (`年/月/日` in an + * English console). The form face is now a text box in the display locale's + * format with a calendar popover, keyboard entry, clear, and today / tomorrow + * shortcuts. The value written is unchanged: `YYYY-MM-DD` for `date`, an ISO + * instant for `datetime`. + */ + +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { DatePickerField, DateTimePickerField } from './DatePickerField'; + +afterEach(() => cleanup()); + +const FIELD = { name: 'close_date', type: 'date' } as any; +const DT_FIELD = { name: 'remind_at', type: 'datetime' } as any; + +function inSession(language: string, node: React.ReactNode) { + return ( + + {node} + + ); +} + +const textBox = (container: HTMLElement) => + container.querySelector('[data-slot="date-picker"] input') as HTMLInputElement; + +const typeAndCommit = (input: HTMLInputElement, text: string) => { + fireEvent.change(input, { target: { value: text } }); + fireEvent.blur(input); +}; + +describe('DatePickerField — the picker, not the native input (objectui#12622)', () => { + it('renders a text box in the display locale format, never input[type=date]', () => { + const { container } = render( {}} field={FIELD} />); + expect(container.querySelector('input[type="date"]')).toBeNull(); + const input = textBox(container); + expect(input.type).toBe('text'); + expect(input.value).toBe('10/16/2026'); + expect(input.placeholder).toBe('MM/DD/YYYY'); + expect(screen.getByTestId('date-picker-trigger')).toBeTruthy(); + }); + + it('zh session: the same value in the zh format', () => { + const { container } = render(inSession('zh', {}} field={FIELD} />)); + const input = textBox(container); + expect(input.value).toBe('2026/10/16'); + expect(input.placeholder).toBe('YYYY/MM/DD'); + }); + + it('typing a date in the locale format writes the ISO day', () => { + const onChange = vi.fn(); + const { container } = render(); + typeAndCommit(textBox(container), '10/20/2026'); + expect(onChange).toHaveBeenCalledWith('2026-10-20'); + expect(textBox(container).value).toBe('10/20/2026'); + }); + + it('Enter commits too', () => { + const onChange = vi.fn(); + const { container } = render(); + const input = textBox(container); + fireEvent.change(input, { target: { value: '2026-10-20' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onChange).toHaveBeenCalledWith('2026-10-20'); + }); + + it('text naming no date is not written, and says so', () => { + const onChange = vi.fn(); + const { container } = render(); + typeAndCommit(textBox(container), '02/30/2026'); + expect(onChange).not.toHaveBeenCalled(); + expect(textBox(container).getAttribute('aria-invalid')).toBe('true'); + expect(screen.getByTestId('date-picker-notice').textContent).toContain('MM/DD/YYYY'); + }); + + it('a calendar pick writes the ISO day', async () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('date-picker-trigger')); + const day = await waitFor(() => { + const el = document.body.querySelector(`button[data-day="${new Date(2026, 9, 20).toLocaleDateString()}"]`); + if (!el) throw new Error('calendar not open'); + return el as HTMLButtonElement; + }); + fireEvent.click(day); + expect(onChange).toHaveBeenCalledWith('2026-10-20'); + }); + + it('the today and tomorrow shortcuts write those days', async () => { + const onChange = vi.fn(); + render(); + const pad = (n: number) => String(n).padStart(2, '0'); + const dayIn = (offset: number) => { + const d = new Date(); + d.setDate(d.getDate() + offset); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + }; + fireEvent.click(screen.getByTestId('date-picker-trigger')); + fireEvent.click(await screen.findByRole('button', { name: 'Today' })); + expect(onChange).toHaveBeenLastCalledWith(dayIn(0)); + fireEvent.click(screen.getByTestId('date-picker-trigger')); + fireEvent.click(await screen.findByRole('button', { name: 'Tomorrow' })); + expect(onChange).toHaveBeenLastCalledWith(dayIn(1)); + }); + + it('clear writes the empty value the native input wrote', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('date-picker-clear')); + expect(onChange).toHaveBeenCalledWith(''); + }); + + it('carries the host id, aria-required and disabled onto the text box', () => { + const { container } = render( + {}} field={FIELD} id="f-1" aria-required disabled />, + ); + const input = textBox(container); + expect(input.id).toBe('f-1'); + expect(input.getAttribute('aria-required')).toBe('true'); + expect(input.disabled).toBe(true); + expect(screen.queryByTestId('date-picker-clear')).toBeNull(); + expect((screen.getByTestId('date-picker-trigger') as HTMLButtonElement).disabled).toBe(true); + }); + + it('readonly is the DateField readonly face, unchanged', () => { + const { container } = render( {}} field={FIELD} readonly />); + expect(container.querySelector('input')).toBeNull(); + expect(container.querySelector('[data-slot="date-picker"]')).toBeNull(); + }); +}); + +describe('DateTimePickerField — writes an ISO instant (objectui#12622)', () => { + it('shows the stored instant as the local wall clock in the locale format', () => { + const iso = new Date(2026, 9, 16, 14, 30).toISOString(); + const { container } = render( {}} field={DT_FIELD} />); + expect(container.querySelector('input[type="datetime-local"]')).toBeNull(); + expect(textBox(container).value).toBe('10/16/2026, 02:30 PM'); + }); + + it('typing a date and time writes the ISO instant of that local wall clock', () => { + const onChange = vi.fn(); + const { container } = render(); + typeAndCommit(textBox(container), '10/16/2026 14:30'); + const written = onChange.mock.calls[0][0] as string; + expect(written).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + expect(written).toBe(new Date(2026, 9, 16, 14, 30).toISOString()); + }); + + it('a calendar pick keeps the value time', async () => { + const onChange = vi.fn(); + const iso = new Date(2026, 9, 16, 9, 15).toISOString(); + render(); + fireEvent.click(screen.getByTestId('date-picker-trigger')); + const day = await waitFor(() => { + const el = document.body.querySelector(`button[data-day="${new Date(2026, 9, 20).toLocaleDateString()}"]`); + if (!el) throw new Error('calendar not open'); + return el as HTMLButtonElement; + }); + act(() => { + fireEvent.click(day); + }); + expect(onChange).toHaveBeenCalledWith(new Date(2026, 9, 20, 9, 15).toISOString()); + }); +}); diff --git a/packages/fields/src/widgets/DatePickerField.tsx b/packages/fields/src/widgets/DatePickerField.tsx new file mode 100644 index 0000000000..02d7bb63e7 --- /dev/null +++ b/packages/fields/src/widgets/DatePickerField.tsx @@ -0,0 +1,232 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { CalendarDays, X } from 'lucide-react'; +import { Button, Calendar, Input, Popover, PopoverContent, PopoverTrigger, cn } from '@object-ui/components'; +import { useDisplayLocale } from '@object-ui/i18n'; +import { FieldWidgetComponentProps } from './types.js'; +import { toDomProps } from './toDomProps.js'; +import { DateField } from './DateField.js'; +import { DateTimeField } from './DateTimeField.js'; +import { + toDateInputValue, + toDateTimeInputValue, + fromDateTimeInputValue, + isImpossibleStoredDay, +} from './nativeDateValue.js'; +import { + formatLocaleDateText, + localeDatePattern, + parseLocaleDateText, + toLocalDay, + fromLocalDay, +} from './localeDateText.js'; +import { useFieldTranslation } from './useFieldTranslation.js'; + +/** + * The record form's date and date-time controls (objectui#12622). + * + * The registry's `date` / `datetime` widgets — what a record form resolves + * `field:date` / `field:datetime` to — draw the platform picker instead of the + * browser's native input: a text box in the display locale's format (keyboard + * entry), a calendar popover (the same `Calendar` the calendar view's date + * popover opens, localized by the same resolver), a clear button, and + * today / tomorrow shortcuts. + * + * ## The value written is unchanged + * + * Both faces go through the native adapters `DateField` / `DateTimeField` use + * (`toDateInputValue`, `toDateTimeInputValue`, `fromDateTimeInputValue`), so a + * `date` field writes `YYYY-MM-DD` and a `datetime` field writes an ISO + * instant, exactly as the native inputs did; clearing writes `''` as the + * native inputs did. + * + * ## Where the native input stays + * + * - The read-only face is `DateField` / `DateTimeField`'s own, rendered as is. + * - The inline editors (`FieldEditWidget`: the grid / detail inline editors and + * the kanban required-fields dialog) import `DateField` / `DateTimeField` + * directly and keep the native input; this card's ruling is the record form. + * - `getLazyFieldWidget` (the action-param and bulk-action dialogs) loads the + * `date` / `datetime` widgets of `fieldWidgetMap`, which stay `DateField` / + * `DateTimeField`: only `registerField` takes this face. + * - A form whose `field:date` widget is not registered (no + * `@object-ui/fields` loaded) falls back to the form renderer's own + * ``, with the `inputType` `ObjectForm` maps. + */ +export function DatePickerField(props: FieldWidgetComponentProps) { + return ; +} + +/** The `datetime` face of {@link DatePickerField}. */ +export function DateTimePickerField(props: FieldWidgetComponentProps) { + return ; +} + +function TemporalPicker({ + value, + onChange, + field, + readonly, + error, + withTime, + ...props +}: FieldWidgetComponentProps & { withTime: boolean }) { + const locale = useDisplayLocale(); + const { t } = useFieldTranslation(); + const noticeId = React.useId(); + const [open, setOpen] = React.useState(false); + + // The value in the native control's shape: `YYYY-MM-DD`, or the local wall + // clock `YYYY-MM-DDTHH:mm`. `''` for an empty value and for a stored day that + // does not exist (named in the notice below, as `DateField` names it). + const impossible = isImpossibleStoredDay(value); + const nativeValue = impossible ? '' : withTime ? toDateTimeInputValue(value) : toDateInputValue(value); + const shown = formatLocaleDateText(nativeValue, locale, withTime); + + const [text, setText] = React.useState(shown); + const [badText, setBadText] = React.useState(null); + // A new value or locale from outside replaces the text; typing does not + // reach here until it is committed. + React.useEffect(() => { + setText(shown); + setBadText(null); + }, [shown]); + + if (readonly) { + const ReadonlyFace = withTime ? DateTimeField : DateField; + return ; + } + + const domProps = toDomProps(props); + const disabled = !!domProps.disabled; + + /** Writes a native-shaped value (or `''`) in the field's stored shape. */ + const write = (next: string) => { + const stored = withTime ? fromDateTimeInputValue(next) : next; + setBadText(null); + setText(formatLocaleDateText(next, locale, withTime)); + if (next !== nativeValue) onChange(stored); + }; + + const commitText = () => { + const parsed = parseLocaleDateText(text, locale, withTime); + if (parsed === null) { + setBadText(text); + return; + } + write(parsed); + }; + + /** A day picked in the calendar or by a shortcut keeps the value's time. */ + const pickDay = (day: Date | undefined) => { + if (!day) return; + const time = nativeValue.slice(11, 16) || '00:00'; + write(withTime ? `${toLocalDay(day)}T${time}` : toLocalDay(day)); + setOpen(false); + }; + + const shortcut = (offsetDays: number) => { + const day = new Date(); + day.setHours(0, 0, 0, 0); + day.setDate(day.getDate() + offsetDays); + pickDay(day); + }; + + const selected = nativeValue ? fromLocalDay(nativeValue) : undefined; + const pattern = localeDatePattern(locale, withTime); + const notice = badText !== null + ? t('fields.datePicker.invalid', { value: badText, format: pattern }) + : impossible + ? t(withTime ? 'fields.dateTime.impossibleDay' : 'fields.date.impossibleDay', { value: String(value) }) + : null; + const describedBy = notice + ? [domProps['aria-describedby'], noticeId].filter(Boolean).join(' ') + : domProps['aria-describedby']; + + return ( +
+
+ setText(e.target.value)} + onBlur={(e) => { + commitText(); + domProps.onBlur?.(e); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + // Commit, and keep a bare Enter from submitting the form with + // the text not yet read. + e.preventDefault(); + commitText(); + } + }} + disabled={disabled} + // After the DOM spread, the objectui#3222 idiom `DateField` follows. + aria-invalid={!!error || impossible || badText !== null} + aria-describedby={describedBy} + /> +
+ {!disabled && (text || nativeValue) ? ( + + ) : null} + + + + + + {/* Opens on the value's month, and on today's with no value. */} + +
+ + +
+
+
+
+
+ {notice ? ( +

+ {notice} +

+ ) : null} +
+ ); +} diff --git a/packages/fields/src/widgets/localeDateText.test.ts b/packages/fields/src/widgets/localeDateText.test.ts new file mode 100644 index 0000000000..5d32c923e1 --- /dev/null +++ b/packages/fields/src/widgets/localeDateText.test.ts @@ -0,0 +1,78 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12622 — the text the record form's date picker shows and reads. + * + * The format is the DISPLAY locale's (an `en` and a `zh` rendering pinned + * below), and every reading lands on the native control's value shape, so the + * value the form writes is unchanged. + */ + +import { describe, expect, it } from 'vitest'; +import { formatLocaleDateText, localeDatePattern, parseLocaleDateText } from './localeDateText'; + +describe('formatLocaleDateText — the display locale spells the day (objectui#12622)', () => { + it('en: month / day / year', () => { + expect(formatLocaleDateText('2026-10-16', 'en', false)).toBe('10/16/2026'); + expect(localeDatePattern('en', false)).toBe('MM/DD/YYYY'); + }); + + it('zh: year / month / day', () => { + expect(formatLocaleDateText('2026-10-16', 'zh', false)).toBe('2026/10/16'); + expect(localeDatePattern('zh', false)).toBe('YYYY/MM/DD'); + }); + + it('datetime: the wall clock as given, in the locale clock', () => { + expect(formatLocaleDateText('2026-10-16T14:30', 'en', true)).toBe('10/16/2026, 02:30 PM'); + expect(formatLocaleDateText('2026-10-16T14:30', 'zh', true)).toBe('2026/10/16 14:30'); + }); + + it('an empty or unreadable value is blank', () => { + expect(formatLocaleDateText('', 'en', false)).toBe(''); + expect(formatLocaleDateText('not a date', 'en', false)).toBe(''); + }); +}); + +describe('parseLocaleDateText — typed text lands on the native value shape (objectui#12622)', () => { + it('date: the locale order, round-tripping its own text', () => { + expect(parseLocaleDateText('10/16/2026', 'en', false)).toBe('2026-10-16'); + expect(parseLocaleDateText('2026/10/16', 'zh', false)).toBe('2026-10-16'); + expect(parseLocaleDateText('16.10.2026', 'de', false)).toBe('2026-10-16'); + expect(parseLocaleDateText('1/2/26', 'en', false)).toBe('2026-01-02'); + }); + + it('date: an ISO day is read in every locale', () => { + expect(parseLocaleDateText('2026-10-16', 'en', false)).toBe('2026-10-16'); + expect(parseLocaleDateText('2026-10-16', 'de', false)).toBe('2026-10-16'); + }); + + it('blank text clears; text naming no real day is refused', () => { + expect(parseLocaleDateText(' ', 'en', false)).toBe(''); + expect(parseLocaleDateText('02/30/2026', 'en', false)).toBeNull(); + expect(parseLocaleDateText('13/01/2026', 'en', false)).toBeNull(); + expect(parseLocaleDateText('tomorrow', 'en', false)).toBeNull(); + expect(parseLocaleDateText('10/16/2026 10:00', 'en', false)).toBeNull(); + }); + + it('datetime: 24-hour, 12-hour with a marker, and the date alone', () => { + expect(parseLocaleDateText('10/16/2026, 02:30 PM', 'en', true)).toBe('2026-10-16T14:30'); + expect(parseLocaleDateText('10/16/2026 12:05 am', 'en', true)).toBe('2026-10-16T00:05'); + expect(parseLocaleDateText('2026/10/16 14:30', 'zh', true)).toBe('2026-10-16T14:30'); + expect(parseLocaleDateText('2026/10/16 下午 2:30', 'zh', true)).toBe('2026-10-16T14:30'); + expect(parseLocaleDateText('10/16/2026', 'en', true)).toBe('2026-10-16T00:00'); + expect(parseLocaleDateText('10/16/2026 25:00', 'en', true)).toBeNull(); + }); + + it('every locale reads back the text it formats', () => { + for (const locale of ['en', 'zh', 'ja', 'ko', 'de', 'fr', 'es', 'pt', 'ru', 'ar']) { + expect(parseLocaleDateText(formatLocaleDateText('2026-10-16', locale, false), locale, false), locale).toBe('2026-10-16'); + expect(parseLocaleDateText(formatLocaleDateText('2026-10-16T14:30', locale, true), locale, true), locale).toBe('2026-10-16T14:30'); + } + }); +}); diff --git a/packages/fields/src/widgets/localeDateText.ts b/packages/fields/src/widgets/localeDateText.ts new file mode 100644 index 0000000000..8b36309bd3 --- /dev/null +++ b/packages/fields/src/widgets/localeDateText.ts @@ -0,0 +1,170 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The text a record form's date picker shows and reads (objectui#12622). + * + * The picker's text box is spelled in the DISPLAY locale (`useDisplayLocale()`, + * the tag every date renderer formats with), not the machine's. The native + * `` it replaces on the form path took its placeholder and + * its digit order from the browser, so an English console showed a Chinese + * machine's `年/月/日`. + * + * Both directions go through the native control's own value shapes, so the + * value the form writes is unchanged: + * + * - `date`: `YYYY-MM-DD` (what `toDateInputValue` hands in, and what + * `` emitted); + * - `datetime`: the local wall clock `YYYY-MM-DDTHH:mm` (what + * `toDateTimeInputValue` hands in), which the caller turns back into an + * ISO instant with `fromDateTimeInputValue`, exactly as `DateTimeField` + * does for `datetime-local`. + * + * Formatting reads `Intl` with Latin digits (`numberingSystem: 'latn'`), so a + * typed date is always read back by the digit groups it was shown with; the + * ORDER of year, month and day, and the separators, are the locale's. + */ + +import { isRealCalendarDate } from '@object-ui/core'; + +type DatePart = 'year' | 'month' | 'day'; + +/** A fixed day whose month, day and year are all distinct numbers. */ +const PROBE = Date.UTC(2026, 10, 23, 13, 5); + +const pad2 = (n: number) => String(n).padStart(2, '0'); + +function dateFormat(locale: string, withTime: boolean): Intl.DateTimeFormat { + const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + timeZone: 'UTC', + numberingSystem: 'latn', + ...(withTime ? { hour: '2-digit', minute: '2-digit' } : null), + }; + try { + return new Intl.DateTimeFormat(locale, options); + } catch { + // A malformed tag: `Intl` throws a RangeError for it. Read it as `en`, + // which is what `useDisplayLocale()` falls back to as well. + return new Intl.DateTimeFormat('en', options); + } +} + +/** The order the locale writes year, month and day in. */ +function dateOrder(locale: string): DatePart[] { + return dateFormat(locale, false) + .formatToParts(PROBE) + .map((p) => p.type) + .filter((t): t is DatePart => t === 'year' || t === 'month' || t === 'day'); +} + +function partsOf(text: string): { y: number; m: number; d: number; h: number; mi: number } | null { + const m = text.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?$/); + if (!m) return null; + return { y: +m[1], m: +m[2], d: +m[3], h: m[4] ? +m[4] : 0, mi: m[5] ? +m[5] : 0 }; +} + +/** + * The display text for a native-shaped value (`YYYY-MM-DD`, or + * `YYYY-MM-DDTHH:mm` when `withTime`). `''` for an empty or unreadable value. + */ +export function formatLocaleDateText(value: string, locale: string, withTime: boolean): string { + const p = partsOf(value); + if (!p) return ''; + // The wall clock is formatted as a UTC instant in the UTC zone, so the text + // is the value's own digits in every viewer's zone. + return dateFormat(locale, withTime).format(Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi)); +} + +/** + * The pattern the text box expects, for its placeholder: the locale's own + * order and separators with `YYYY` / `MM` / `DD` (and `HH` / `mm`) in place of + * the digits. + */ +export function localeDatePattern(locale: string, withTime: boolean): string { + const tokens: Record = { year: 'YYYY', month: 'MM', day: 'DD', hour: 'HH', minute: 'mm' }; + return dateFormat(locale, withTime) + .formatToParts(PROBE) + .map((part) => (part.type === 'dayPeriod' ? 'AM/PM' : tokens[part.type] ?? part.value)) + .join(''); +} + +/** The locale's own afternoon marker (`PM`, `下午`, …), lower-cased. */ +function pmMarker(locale: string): string | undefined { + const parts = new Intl.DateTimeFormat(dateFormat(locale, true).resolvedOptions().locale, { + hour: 'numeric', + hour12: true, + timeZone: 'UTC', + }).formatToParts(PROBE); + return parts.find((p) => p.type === 'dayPeriod')?.value.toLowerCase(); +} + +/** + * Reads what was typed into the text box. + * + * Returns `''` for blank text, the native-shaped value for a real day (and a + * real clock time when `withTime`), and `null` for text that names no real + * date. Accepted: + * + * - the locale's own digit order, with any separators (`10/16/2026` in `en`, + * `2026/10/16` in `zh`, `16.10.2026` in `de`); + * - an ISO date `2026-10-16`, in every locale (a four-digit first group is + * read as the year); + * - a two-digit year, read in this century; + * - with `withTime`: an hour and minute after the date, 24-hour, or 12-hour + * with `AM` / `PM` or the locale's own afternoon marker. With no time, the + * time is midnight. + */ +export function parseLocaleDateText(text: string, locale: string, withTime: boolean): string | null { + const trimmed = text.trim(); + if (!trimmed) return ''; + const groups = trimmed.match(/\d+/g) ?? []; + if (groups.length < 3) return null; + + const order: DatePart[] = groups[0].length === 4 ? ['year', 'month', 'day'] : dateOrder(locale); + if (order.length !== 3) return null; + const date: Record = { year: '', month: '', day: '' }; + order.forEach((part, i) => { + date[part] = groups[i]; + }); + if (date.year.length === 2) date.year = `20${date.year}`; + if (date.year.length !== 4 || date.month.length > 2 || date.day.length > 2) return null; + const day = `${date.year}-${pad2(Number(date.month))}-${pad2(Number(date.day))}`; + if (!isRealCalendarDate(day)) return null; + + const rest = groups.slice(3); + if (!withTime) return rest.length === 0 ? day : null; + + if (rest.length === 0) return `${day}T00:00`; + if (rest.length > 3 || rest[0].length > 2 || (rest[1] ?? '').length > 2) return null; + let hour = Number(rest[0]); + const minute = Number(rest[1] ?? '0'); + const lower = trimmed.toLowerCase(); + const pm = /\bp\.?\s?m\.?/.test(lower) || (!!pmMarker(locale) && lower.includes(pmMarker(locale)!)); + const am = /\ba\.?\s?m\.?/.test(lower); + if (pm || am) { + if (hour < 1 || hour > 12) return null; + if (pm && hour < 12) hour += 12; + if (am && hour === 12) hour = 0; + } + if (hour > 23 || minute > 59) return null; + return `${day}T${pad2(hour)}:${pad2(minute)}`; +} + +/** The local calendar day of `d` as `YYYY-MM-DD`. */ +export function toLocalDay(d: Date): string { + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} + +/** Local midnight of a `YYYY-MM-DD` day, for the calendar to select. */ +export function fromLocalDay(day: string): Date | undefined { + const p = partsOf(day.slice(0, 10)); + return p ? new Date(p.y, p.m - 1, p.d) : undefined; +} diff --git a/packages/fields/src/widgets/useFieldTranslation.ts b/packages/fields/src/widgets/useFieldTranslation.ts index 1db88788d4..985121f833 100644 --- a/packages/fields/src/widgets/useFieldTranslation.ts +++ b/packages/fields/src/widgets/useFieldTranslation.ts @@ -194,6 +194,14 @@ const FIELD_DEFAULTS: Record = { // only paint it blank. Same value as the `en` pack. 'fields.dateTime.impossibleDay': 'The stored value "{{value}}" is not a real date. Pick a date and time to replace it.', + // objectui#12622 — the record form's date picker (`DatePickerField`). Same + // values as the `en` pack. + 'fields.datePicker.today': 'Today', + 'fields.datePicker.tomorrow': 'Tomorrow', + 'fields.datePicker.clear': 'Clear date', + 'fields.datePicker.openCalendar': 'Open calendar', + 'fields.datePicker.invalid': + 'Not saved: "{{value}}" is not a date. Type it as {{format}}, or pick one from the calendar.', // objectui#3342 — the tags widget's input hint. Used only when the field // author declared no `placeholder` of their own (author declaration wins). 'fields.tags.placeholder': 'Type and press Enter to add…', diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 55f5ce8fbf..0aaa19eee1 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -400,6 +400,14 @@ const ar = { impossibleDay: "القيمة المحفوظة «{{value}}» ليست تاريخًا حقيقيًا. اختر تاريخًا ووقتًا لاستبدالها.", }, + datePicker: { + today: "اليوم", + tomorrow: "غدًا", + clear: "مسح التاريخ", + openCalendar: "فتح التقويم", + invalid: + "لم يتم الحفظ: «{{value}}» ليس تاريخًا. اكتبه بالصيغة {{format}} أو اختره من التقويم.", + }, number: { badInput: "لم يتم الحفظ: النص في هذا المربع ليس رقمًا. أدخل رقمًا عشريًا عاديًا (مثال: {{example}}).", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index f2bb7ba8dc..e5df1a8561 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -360,6 +360,14 @@ const de = { impossibleDay: "Der gespeicherte Wert „{{value}}“ ist kein gültiges Datum. Wählen Sie Datum und Uhrzeit, um ihn zu ersetzen.", }, + datePicker: { + today: "Heute", + tomorrow: "Morgen", + clear: "Datum löschen", + openCalendar: "Kalender öffnen", + invalid: + "Nicht gespeichert: „{{value}}“ ist kein Datum. Geben Sie es als {{format}} ein oder wählen Sie es im Kalender aus.", + }, number: { badInput: "Nicht gespeichert: Der Text in diesem Feld ist keine Zahl. Geben Sie eine einfache Dezimalzahl ein (Beispiel: {{example}}).", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 1482cfaf36..491dccaf55 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -542,6 +542,17 @@ const en = { impossibleDay: 'The stored value "{{value}}" is not a real date. Pick a date and time to replace it.', }, + // objectui#12622 — the record form's date picker: its two shortcuts, + // the clear / calendar buttons' accessible names, and the notice for + // typed text that names no date ({{format}} is the locale pattern). + datePicker: { + today: 'Today', + tomorrow: 'Tomorrow', + clear: 'Clear date', + openCalendar: 'Open calendar', + invalid: + 'Not saved: "{{value}}" is not a date. Type it as {{format}}, or pick one from the calendar.', + }, number: { badInput: 'Not saved: the text in this box is not a number. Enter a plain decimal (example: {{example}}).', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 1e841b6523..fc252a0a2e 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -371,6 +371,14 @@ const es = { impossibleDay: "El valor guardado «{{value}}» no es una fecha real. Elige una fecha y una hora para reemplazarlo.", }, + datePicker: { + today: "Hoy", + tomorrow: "Mañana", + clear: "Borrar fecha", + openCalendar: "Abrir calendario", + invalid: + "No guardado: «{{value}}» no es una fecha. Escríbela como {{format}} o elígela en el calendario.", + }, number: { badInput: "No guardado: el texto de este campo no es un número. Introduce un decimal simple (ejemplo: {{example}}).", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index ef44af9963..1c0a6bea2e 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -367,6 +367,14 @@ const fr = { impossibleDay: "La valeur enregistrée « {{value}} » n’est pas une date réelle. Choisissez une date et une heure pour la remplacer.", }, + datePicker: { + today: "Aujourd’hui", + tomorrow: "Demain", + clear: "Effacer la date", + openCalendar: "Ouvrir le calendrier", + invalid: + "Non enregistré : « {{value}} » n’est pas une date. Saisissez-la au format {{format}} ou choisissez-la dans le calendrier.", + }, number: { badInput: "Non enregistré : le texte de ce champ n'est pas un nombre. Saisissez une décimale simple (exemple : {{example}}).", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 69abf7954d..98c0627bfe 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -360,6 +360,14 @@ const ja = { impossibleDay: "保存されている値「{{value}}」は実在しない日付です。置き換えるには日付と時刻を選択してください。", }, + datePicker: { + today: "今日", + tomorrow: "明日", + clear: "日付をクリア", + openCalendar: "カレンダーを開く", + invalid: + "保存されていません:「{{value}}」は日付ではありません。{{format}} の形式で入力するか、カレンダーから選択してください。", + }, number: { badInput: "保存されていません: このボックスのテキストは数値ではありません。通常の小数で入力してください(例: {{example}})。", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index ffb3e07e6d..60fb3c3a96 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -360,6 +360,14 @@ const ko = { impossibleDay: "저장된 값 “{{value}}”은(는) 실제 날짜가 아닙니다. 바꾸려면 날짜와 시간을 선택하세요.", }, + datePicker: { + today: "오늘", + tomorrow: "내일", + clear: "날짜 지우기", + openCalendar: "달력 열기", + invalid: + "저장되지 않음: “{{value}}”은(는) 날짜가 아닙니다. {{format}} 형식으로 입력하거나 달력에서 선택하세요.", + }, number: { badInput: "저장되지 않았습니다: 이 입력란의 텍스트는 숫자가 아닙니다. 일반 소수로 입력하세요(예: {{example}}).", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 6bb16a01ce..8ba282d221 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -366,6 +366,14 @@ const pt = { impossibleDay: "O valor salvo “{{value}}” não é uma data real. Escolha uma data e uma hora para substituí-lo.", }, + datePicker: { + today: "Hoje", + tomorrow: "Amanhã", + clear: "Limpar data", + openCalendar: "Abrir calendário", + invalid: + "Não salvo: “{{value}}” não é uma data. Digite no formato {{format}} ou escolha no calendário.", + }, number: { badInput: "Não salvo: o texto deste campo não é um número. Informe um decimal simples (exemplo: {{example}}).", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index e41129f24d..c08f6a4197 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -382,6 +382,14 @@ const ru = { impossibleDay: "Сохранённое значение «{{value}}» — несуществующая дата. Выберите дату и время, чтобы заменить его.", }, + datePicker: { + today: "Сегодня", + tomorrow: "Завтра", + clear: "Очистить дату", + openCalendar: "Открыть календарь", + invalid: + "Не сохранено: «{{value}}» — не дата. Введите её в формате {{format}} или выберите в календаре.", + }, number: { badInput: "Не сохранено: текст в этом поле — не число. Введите обычную десятичную дробь (например: {{example}}).", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index d8e71db5e5..fd51a649d4 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -368,6 +368,14 @@ const zh = { impossibleDay: '存储的值“{{value}}”不是真实存在的日期。请选择日期和时间以替换它。', }, + datePicker: { + today: '今天', + tomorrow: '明天', + clear: '清除日期', + openCalendar: '打开日历', + invalid: + '未保存:“{{value}}”不是有效日期。请按 {{format}} 格式输入,或从日历中选择。', + }, number: { badInput: '未保存:此输入框中的文本不是数字。请输入普通小数(例如 {{example}})。', diff --git a/packages/plugin-form/src/__tests__/recordFormDatePicker-12622.test.tsx b/packages/plugin-form/src/__tests__/recordFormDatePicker-12622.test.tsx new file mode 100644 index 0000000000..4e100d909a --- /dev/null +++ b/packages/plugin-form/src/__tests__/recordFormDatePicker-12622.test.tsx @@ -0,0 +1,95 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12622 — a record form's `date` / `datetime` fields draw the + * platform date picker, not the browser's native input. + * + * Through the real `ObjectForm` and the real field registry + * (`registerAllFields`): the date rows carry the picker's text box and no + * `input[type=date]` / `input[type=datetime-local]`, typing into them submits + * the ISO shapes the native inputs submitted, and the form's other field types + * render the controls they rendered before (the CONTROL). + */ + +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'; +import { registerAllFields } from '@object-ui/fields'; +import { ObjectForm } from '../ObjectForm'; + +registerAllFields(); + +const OBJECT_SCHEMA = { + name: 'opportunity', + fields: { + name: { type: 'text', label: 'Name' }, + amount: { type: 'number', label: 'Amount' }, + close_date: { type: 'date', label: 'Close date' }, + remind_at: { type: 'datetime', label: 'Remind at' }, + }, +}; + +const makeDS = () => + ({ + getObjectSchema: vi.fn().mockResolvedValue(OBJECT_SCHEMA), + create: vi.fn().mockResolvedValue({ id: 'o1' }), + update: vi.fn().mockResolvedValue({ id: 'o1' }), + findOne: vi.fn().mockResolvedValue({ id: 'o1' }), + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + }) as any; + +const row = (name: string) => document.body.querySelector(`[data-field="${name}"]`); + +afterEach(() => cleanup()); + +describe('record form date fields draw the date picker (objectui#12622)', () => { + it('date and datetime rows carry the picker; the other rows are unchanged', async () => { + render(); + await waitFor(() => { + if (!row('close_date')?.querySelector('[data-slot="date-picker"]')) throw new Error('date picker not rendered'); + if (!row('remind_at')?.querySelector('[data-slot="date-picker"]')) throw new Error('datetime picker not rendered'); + }); + + expect(document.body.querySelector('input[type="date"]')).toBeNull(); + expect(document.body.querySelector('input[type="datetime-local"]')).toBeNull(); + // The label still names the text box: the host id reached it. + const dateInput = row('close_date')!.querySelector('[data-slot="date-picker"] input') as HTMLInputElement; + expect(dateInput.type).toBe('text'); + expect(document.body.querySelector(`label[for="${dateInput.id}"]`)?.textContent).toContain('Close date'); + + // CONTROL: the form's other field types render the controls they did. + await waitFor(() => { + if (!row('name')?.querySelector('input') || !row('amount')?.querySelector('input')) throw new Error('rows not rendered'); + }); + expect((row('name')!.querySelector('input') as HTMLInputElement).type).toBe('text'); + expect((row('amount')!.querySelector('input') as HTMLInputElement).type).toBe('number'); + expect(row('name')!.querySelector('[data-slot="date-picker"]')).toBeNull(); + expect(row('amount')!.querySelector('[data-slot="date-picker"]')).toBeNull(); + }); + + it('typed dates submit the ISO day and the ISO instant', async () => { + const ds = makeDS(); + render(); + const picker = (name: string) => row(name)?.querySelector('[data-slot="date-picker"] input') as HTMLInputElement | null; + await waitFor(() => { + if (!picker('close_date') || !picker('remind_at')) throw new Error('pickers not rendered'); + }); + + fireEvent.change(picker('close_date')!, { target: { value: '10/20/2026' } }); + fireEvent.blur(picker('close_date')!); + fireEvent.change(picker('remind_at')!, { target: { value: '10/20/2026 14:30' } }); + fireEvent.blur(picker('remind_at')!); + + fireEvent.submit(document.body.querySelector('form') as HTMLFormElement); + await waitFor(() => expect(ds.create).toHaveBeenCalled()); + const payload = ds.create.mock.calls[0][1]; + expect(payload.close_date).toBe('2026-10-20'); + expect(payload.remind_at).toBe(new Date(2026, 9, 20, 14, 30).toISOString()); + }); +}); From 9134febab6a5c6ca08f14a05bce33708c48511fc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 07:01:45 +0000 Subject: [PATCH 2/5] fix(fields): adjust the picker text during render, not in an effect Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- .../fields/src/widgets/DatePickerField-12622.test.tsx | 5 +++-- packages/fields/src/widgets/DatePickerField.tsx | 9 ++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/fields/src/widgets/DatePickerField-12622.test.tsx b/packages/fields/src/widgets/DatePickerField-12622.test.tsx index 55800eeabd..b5c05d6dfa 100644 --- a/packages/fields/src/widgets/DatePickerField-12622.test.tsx +++ b/packages/fields/src/widgets/DatePickerField-12622.test.tsx @@ -25,8 +25,9 @@ import { DatePickerField, DateTimePickerField } from './DatePickerField'; afterEach(() => cleanup()); -const FIELD = { name: 'close_date', type: 'date' } as any; -const DT_FIELD = { name: 'remind_at', type: 'datetime' } as any; +type Field = React.ComponentProps['field']; +const FIELD = { name: 'close_date', type: 'date' } as Field; +const DT_FIELD = { name: 'remind_at', type: 'datetime' } as Field; function inSession(language: string, node: React.ReactNode) { return ( diff --git a/packages/fields/src/widgets/DatePickerField.tsx b/packages/fields/src/widgets/DatePickerField.tsx index 02d7bb63e7..4e02590892 100644 --- a/packages/fields/src/widgets/DatePickerField.tsx +++ b/packages/fields/src/widgets/DatePickerField.tsx @@ -93,11 +93,14 @@ function TemporalPicker({ const [text, setText] = React.useState(shown); const [badText, setBadText] = React.useState(null); // A new value or locale from outside replaces the text; typing does not - // reach here until it is committed. - React.useEffect(() => { + // reach here until it is committed. Adjusted while rendering (React's + // "storing information from previous renders"), not in an effect. + const [lastShown, setLastShown] = React.useState(shown); + if (shown !== lastShown) { + setLastShown(shown); setText(shown); setBadText(null); - }, [shown]); + } if (readonly) { const ReadonlyFace = withTime ? DateTimeField : DateField; From 10527ec3edc49a0d2dc3df0bb848cd3cdc427b77 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 07:18:10 +0000 Subject: [PATCH 3/5] fix(fields): read the typed date groups under noUncheckedIndexedAccess Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- packages/fields/src/widgets/localeDateText.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/fields/src/widgets/localeDateText.ts b/packages/fields/src/widgets/localeDateText.ts index 8b36309bd3..0bef773963 100644 --- a/packages/fields/src/widgets/localeDateText.ts +++ b/packages/fields/src/widgets/localeDateText.ts @@ -128,11 +128,11 @@ export function parseLocaleDateText(text: string, locale: string, withTime: bool const groups = trimmed.match(/\d+/g) ?? []; if (groups.length < 3) return null; - const order: DatePart[] = groups[0].length === 4 ? ['year', 'month', 'day'] : dateOrder(locale); + const order: DatePart[] = (groups[0] ?? '').length === 4 ? ['year', 'month', 'day'] : dateOrder(locale); if (order.length !== 3) return null; const date: Record = { year: '', month: '', day: '' }; order.forEach((part, i) => { - date[part] = groups[i]; + date[part] = groups[i] ?? ''; }); if (date.year.length === 2) date.year = `20${date.year}`; if (date.year.length !== 4 || date.month.length > 2 || date.day.length > 2) return null; @@ -143,11 +143,13 @@ export function parseLocaleDateText(text: string, locale: string, withTime: bool if (!withTime) return rest.length === 0 ? day : null; if (rest.length === 0) return `${day}T00:00`; - if (rest.length > 3 || rest[0].length > 2 || (rest[1] ?? '').length > 2) return null; - let hour = Number(rest[0]); - const minute = Number(rest[1] ?? '0'); + const [hourText = '', minuteText = '0'] = rest; + if (rest.length > 3 || hourText.length > 2 || minuteText.length > 2) return null; + let hour = Number(hourText); + const minute = Number(minuteText); const lower = trimmed.toLowerCase(); - const pm = /\bp\.?\s?m\.?/.test(lower) || (!!pmMarker(locale) && lower.includes(pmMarker(locale)!)); + const marker = pmMarker(locale); + const pm = /\bp\.?\s?m\.?/.test(lower) || (!!marker && lower.includes(marker)); const am = /\ba\.?\s?m\.?/.test(lower); if (pm || am) { if (hour < 1 || hour > 12) return null; From 8da1f195c60af324f8b85b4eae5284d1be6b9cb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:36:10 +0000 Subject: [PATCH 4/5] fix(fields): the date picker formats in the display locale only; de quote census counts the new span The picker's Intl formatter no longer falls back to a hard-coded tag (objectui#9909 census); a malformed tag is answered as formatDate answers it. The de quote-pairing census moves 86 to 87 for fields.datePicker.invalid's one matched span (objectui#12622). Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- packages/fields/src/widgets/localeDateText.ts | 11 ++++------- .../i18n/src/__tests__/de-quote-pairing-3876.test.ts | 10 +++++++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/fields/src/widgets/localeDateText.ts b/packages/fields/src/widgets/localeDateText.ts index 0bef773963..ae99f16192 100644 --- a/packages/fields/src/widgets/localeDateText.ts +++ b/packages/fields/src/widgets/localeDateText.ts @@ -48,13 +48,10 @@ function dateFormat(locale: string, withTime: boolean): Intl.DateTimeFormat { numberingSystem: 'latn', ...(withTime ? { hour: '2-digit', minute: '2-digit' } : null), }; - try { - return new Intl.DateTimeFormat(locale, options); - } catch { - // A malformed tag: `Intl` throws a RangeError for it. Read it as `en`, - // which is what `useDisplayLocale()` falls back to as well. - return new Intl.DateTimeFormat('en', options); - } + // The display locale and nothing else (objectui#9909: no hard-coded tag on + // a display path). A malformed tag is answered exactly as the read-only face + // beside it answers it: `formatDate` hands `Intl` the same tag unguarded. + return new Intl.DateTimeFormat(locale, options); } /** The order the locale writes year, month and day in. */ diff --git a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index ad00c88775..5863d758a6 100644 --- a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -334,7 +334,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // 86 once objectui#12504 retired `console.objectView.viewConfigPermissionDenied` // (its producer, the toolbar's view write, retired with the adapter's gate), // taking its one literal span („Metadaten verwalten“) with it. - expect(okSpans, 'correctly paired spans').toBe(86); + // 87 once objectui#12622 added `fields.datePicker.invalid`, the record + // form date picker's notice for typed text that names no date, which + // quotes that text („{{value}}“) — one interpolated span, runtime data. + expect(okSpans, 'correctly paired spans').toBe(87); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -385,8 +388,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // objectui#12288 added `marketplace.install.unmanagedSuccess` and // `.unmanagedIgnored`, one matched pair each. 86 / 86 / 0 after // objectui#12504 retired `console.objectView.viewConfigPermissionDenied`, - // one matched pair fewer. - expect({ open, close, rdq }).toEqual({ open: 86, close: 86, rdq: 0 }); + // one matched pair fewer. 87 / 87 / 0 after objectui#12622 added + // `fields.datePicker.invalid`, one more matched pair. + expect({ open, close, rdq }).toEqual({ open: 87, close: 87, rdq: 0 }); // The durable shape: every „ closed by a “, every surplus “ an English // opener answered by a ”. Survived translating the two English values. expect(close).toBe(open + rdq); From 58c7f685c02be978251edd0ad93f89bd4093bd8e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 09:57:34 +0000 Subject: [PATCH 5/5] perf(console): the record-form date picker loads with its form, not on every page The `ui-components` group claimed all of `packages/fields`, so the picker's `import()` in `FORM_FACE_LOADERS` did not affect chunking and the picker was eager. It now lives in `widgets/date-picker/`, a `fields-date-picker` group (priority 82, `includeDependenciesRecursively: false`) takes that directory only, and `PER_CHUNK_MEMBERSHIP_CARVE_OUTS` declares the split. No ceiling, baseline or headroom constant moved. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- apps/console/vite.config.ts | 27 +++++++++++++++++++ packages/fields/src/index.tsx | 6 ++--- .../DatePickerField-12622.test.tsx | 0 .../{ => date-picker}/DatePickerField.tsx | 12 ++++----- .../{ => date-picker}/localeDateText.test.ts | 0 .../{ => date-picker}/localeDateText.ts | 0 .../check-eager-closure-budget.test.ts | 5 ++++ scripts/check-eager-closure-budget.mjs | 8 ++++++ 8 files changed, 49 insertions(+), 9 deletions(-) rename packages/fields/src/widgets/{ => date-picker}/DatePickerField-12622.test.tsx (100%) rename packages/fields/src/widgets/{ => date-picker}/DatePickerField.tsx (96%) rename packages/fields/src/widgets/{ => date-picker}/localeDateText.test.ts (100%) rename packages/fields/src/widgets/{ => date-picker}/localeDateText.ts (100%) diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts index 4c4d942fab..04fd83e4f3 100644 --- a/apps/console/vite.config.ts +++ b/apps/console/vite.config.ts @@ -1407,6 +1407,33 @@ export default defineConfig({ includeDependenciesRecursively: false, }, { name: 'framework', test: /[\\/]packages[\\/](core|react|types)[\\/]/, priority: 80 }, + // + // ## The record-form date picker leaves the eager line (objectui#12622) + // + // `ui-components` claims every `packages/fields/` module wherever it + // is reached from, so the picker's `import()` in `FORM_FACE_LOADERS` + // had no effect on chunking: the picker would have been fetched on + // every page load, including pages with no record form. This group + // takes the `widgets/date-picker/` directory only. The picker's + // imports from the rest of `packages/fields` (`DateField`, + // `nativeDateValue`, `useFieldTranslation`, ...) stay in + // `ui-components`. The new chunk is reached only through that + // `import()`, so the closure walk never enters it. + // `PER_CHUNK_MEMBERSHIP_CARVE_OUTS` in + // `scripts/check-eager-closure-budget.mjs` declares this split and + // checks it module by module. That check errors if this chunk ever + // joins the eager closure. + // + // `includeDependenciesRecursively: false` for the reason `types-zod` + // below gives: with the default left on, the group would pull the + // shared `widgets/` neighbours along an import, and `ui-components` + // would then import this chunk statically. + { + name: 'fields-date-picker', + test: /[\\/]packages[\\/]fields[\\/]src[\\/]widgets[\\/]date-picker[\\/]/, + priority: 82, + includeDependenciesRecursively: false, + }, { name: 'ui-components', test: /[\\/]packages[\\/](components|fields)[\\/]/, priority: 80 }, { name: 'ui-layout', test: /[\\/]packages[\\/]layout[\\/]/, priority: 80 }, { name: 'infrastructure', test: /[\\/]packages[\\/](auth|permissions|tenant)[\\/]/, priority: 80 }, diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 9bce63878f..acef3f63e2 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -4730,11 +4730,11 @@ export const FIELD_WIDGET_LABELLING: Record< * imports `DateField` / `DateTimeField` directly) keep the native input. * * The value written is the native input's: `YYYY-MM-DD` for `date`, an ISO - * instant for `datetime` (see `widgets/DatePickerField.tsx`). + * instant for `datetime` (see `widgets/date-picker/DatePickerField.tsx`). */ const FORM_FACE_LOADERS: Partial> = { - date: () => import('./widgets/DatePickerField.js').then(m => ({ default: m.DatePickerField })), - datetime: () => import('./widgets/DatePickerField.js').then(m => ({ default: m.DateTimePickerField })), + date: () => import('./widgets/date-picker/DatePickerField.js').then(m => ({ default: m.DatePickerField })), + datetime: () => import('./widgets/date-picker/DatePickerField.js').then(m => ({ default: m.DateTimePickerField })), }; export function registerField(fieldType: string): void { diff --git a/packages/fields/src/widgets/DatePickerField-12622.test.tsx b/packages/fields/src/widgets/date-picker/DatePickerField-12622.test.tsx similarity index 100% rename from packages/fields/src/widgets/DatePickerField-12622.test.tsx rename to packages/fields/src/widgets/date-picker/DatePickerField-12622.test.tsx diff --git a/packages/fields/src/widgets/DatePickerField.tsx b/packages/fields/src/widgets/date-picker/DatePickerField.tsx similarity index 96% rename from packages/fields/src/widgets/DatePickerField.tsx rename to packages/fields/src/widgets/date-picker/DatePickerField.tsx index 4e02590892..f8ea3e8fcb 100644 --- a/packages/fields/src/widgets/DatePickerField.tsx +++ b/packages/fields/src/widgets/date-picker/DatePickerField.tsx @@ -10,16 +10,16 @@ import React from 'react'; import { CalendarDays, X } from 'lucide-react'; import { Button, Calendar, Input, Popover, PopoverContent, PopoverTrigger, cn } from '@object-ui/components'; import { useDisplayLocale } from '@object-ui/i18n'; -import { FieldWidgetComponentProps } from './types.js'; -import { toDomProps } from './toDomProps.js'; -import { DateField } from './DateField.js'; -import { DateTimeField } from './DateTimeField.js'; +import { FieldWidgetComponentProps } from '../types.js'; +import { toDomProps } from '../toDomProps.js'; +import { DateField } from '../DateField.js'; +import { DateTimeField } from '../DateTimeField.js'; import { toDateInputValue, toDateTimeInputValue, fromDateTimeInputValue, isImpossibleStoredDay, -} from './nativeDateValue.js'; +} from '../nativeDateValue.js'; import { formatLocaleDateText, localeDatePattern, @@ -27,7 +27,7 @@ import { toLocalDay, fromLocalDay, } from './localeDateText.js'; -import { useFieldTranslation } from './useFieldTranslation.js'; +import { useFieldTranslation } from '../useFieldTranslation.js'; /** * The record form's date and date-time controls (objectui#12622). diff --git a/packages/fields/src/widgets/localeDateText.test.ts b/packages/fields/src/widgets/date-picker/localeDateText.test.ts similarity index 100% rename from packages/fields/src/widgets/localeDateText.test.ts rename to packages/fields/src/widgets/date-picker/localeDateText.test.ts diff --git a/packages/fields/src/widgets/localeDateText.ts b/packages/fields/src/widgets/date-picker/localeDateText.ts similarity index 100% rename from packages/fields/src/widgets/localeDateText.ts rename to packages/fields/src/widgets/date-picker/localeDateText.ts diff --git a/scripts/__tests__/check-eager-closure-budget.test.ts b/scripts/__tests__/check-eager-closure-budget.test.ts index 8621c26172..713de734f3 100644 --- a/scripts/__tests__/check-eager-closure-budget.test.ts +++ b/scripts/__tests__/check-eager-closure-budget.test.ts @@ -503,6 +503,9 @@ describe('chunk attribution (objectui#7399)', () => { // `plugin-grid` chunk reads its runtime values. const ZOD_MODULE = moduleId('packages/types/src/zod/objectql.zod.ts'); const TYPES_SHARED_MODULE = moduleId('packages/types/src/data-display.ts'); + // objectui#12622's carve-out: the record-form date picker, which only an + // `import()` reaches, leaves `ui-components`. + const DATE_PICKER_MODULE = moduleId('packages/fields/src/widgets/date-picker/DatePickerField.tsx'); /** The groups whose test matches this id, highest priority first. */ function claimants(id: string): Group[] { @@ -558,6 +561,7 @@ describe('chunk attribution (objectui#7399)', () => { ['the i18n runtime', I18N_RUNTIME_MODULE, 'i18n-runtime'], ['the ObjectStack data adapter', DATA_MODULE, 'data-adapter'], ['the zod validators', ZOD_MODULE, 'types-zod'], + ['the record-form date picker', DATE_PICKER_MODULE, 'fields-date-picker'], ])('routes %s to `%s` at a priority `framework` cannot tie', (_what, id, expected) => { const framework = groups.find((g) => g.name === 'framework'); expect(framework).toBeDefined(); @@ -1248,6 +1252,7 @@ describe('chunk membership (objectui#9345)', () => { expect(PER_CHUNK_MEMBERSHIP.framework).toContain('types'); expect(PER_CHUNK_MEMBERSHIP_CARVE_OUTS).toEqual({ types: [{ subtree: 'src/zod', chunk: 'types-zod' }], + fields: [{ subtree: 'src/widgets/date-picker', chunk: 'fields-date-picker' }], }); }); diff --git a/scripts/check-eager-closure-budget.mjs b/scripts/check-eager-closure-budget.mjs index dfd8efe59b..3e5afd94c2 100644 --- a/scripts/check-eager-closure-budget.mjs +++ b/scripts/check-eager-closure-budget.mjs @@ -2315,6 +2315,14 @@ export const PER_CHUNK_MEMBERSHIP = Object.freeze({ */ export const PER_CHUNK_MEMBERSHIP_CARVE_OUTS = Object.freeze({ types: Object.freeze([Object.freeze({ subtree: 'src/zod', chunk: 'types-zod' })]), + // objectui#12622, seat ruling 6212914664 (option A): the record-form date + // picker, which only `FORM_FACE_LOADERS`' `import()` reaches. Without this row + // `ui-components` claimed it with the rest of `packages/fields` and put it on + // every page load. Its chunk carries no ceiling, so the rule above holds it + // to being ABSENT from the eager closure. + fields: Object.freeze([ + Object.freeze({ subtree: 'src/widgets/date-picker', chunk: 'fields-date-picker' }), + ]), }); /**