diff --git a/.changeset/12622-form-date-picker.md b/.changeset/12622-form-date-picker.md
index 83587ef9e1..14f6eb2c59 100644
--- a/.changeset/12622-form-date-picker.md
+++ b/.changeset/12622-form-date-picker.md
@@ -8,5 +8,5 @@ Record forms draw `date` and `datetime` fields with the platform date picker ins
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.
+- **Where it applies.** The form registration of `field:date` / `field:datetime` (what `registerField` / `registerAllFields` register) takes the picker, and so does `getLazyFieldWidget` (the action-param and bulk-action dialogs, objectui#12642). The inline editors (`FieldEditWidget`, the detail inline editor, the kanban required-fields dialog) 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/.changeset/12642-dialog-date-picker.md b/.changeset/12642-dialog-date-picker.md
new file mode 100644
index 0000000000..eb42051064
--- /dev/null
+++ b/.changeset/12642-dialog-date-picker.md
@@ -0,0 +1,13 @@
+---
+'@object-ui/fields': patch
+'@object-ui/app-shell': patch
+---
+
+Action-param and bulk-action dialogs draw `date` and `datetime` params with the platform date picker, the same face record forms draw since objectui#12622 (objectui#12642).
+
+The native input took its placeholder and digit order from the machine's locale, not the console's. Now one field type has one face in forms and dialogs.
+
+- **Where it applies.** Everything that resolves a field widget through `getLazyFieldWidget` or `registerField` / `registerAllFields`: the record form, `ActionParamDialog`, the grid's `BulkActionDialog`, and the console's form page. The inline editors (`FieldEditWidget`, the detail inline editor) and the list filters keep the native input.
+- **The value written is unchanged.** A `date` param writes `YYYY-MM-DD` and a `datetime` param writes an ISO instant, as the native inputs did.
+- **The Action designer's preview** draws a date-picker shape for `date` / `datetime` params, matching the dialog.
+- **No public surface changes.** No type, prop, export or language-pack key is added or removed (Clause-②: no). `getLazyFieldWidget('date' | 'datetime')` returns another component with the same props.
diff --git a/apps/console/src/components/FormPage.sharedWidgets-10179.test.tsx b/apps/console/src/components/FormPage.sharedWidgets-10179.test.tsx
index 2a916d13f3..e6efefc299 100644
--- a/apps/console/src/components/FormPage.sharedWidgets-10179.test.tsx
+++ b/apps/console/src/components/FormPage.sharedWidgets-10179.test.tsx
@@ -189,7 +189,10 @@ describe('value shapes — what the shared widgets emit is what the page submits
it('submits a date string, a number, a multiselect array and a lookup id', async () => {
await renderForm();
+ // `date` is the platform picker (objectui#12642): its text box commits on
+ // blur, and an ISO day reads in every locale.
fireEvent.change(screen.getByLabelText('Due'), { target: { value: '2026-03-04' } });
+ fireEvent.blur(screen.getByLabelText('Due'));
fireEvent.change(screen.getByLabelText('Quantity'), { target: { value: '7' } });
const tags = screen.getByRole('group', { name: 'Tags' });
diff --git a/packages/app-shell/src/views/ActionParamDialog.test.tsx b/packages/app-shell/src/views/ActionParamDialog.test.tsx
index 62c01c3e88..419f0f5634 100644
--- a/packages/app-shell/src/views/ActionParamDialog.test.tsx
+++ b/packages/app-shell/src/views/ActionParamDialog.test.tsx
@@ -454,11 +454,18 @@ describe('ActionParamDialog — shared field-widget rendering (ADR-0059)', () =>
expect(await screen.findByRole('combobox')).toBeTruthy();
});
- it('renders a date param through the shared DateField (native date input)', async () => {
+ // objectui#12642: the dialog draws the platform date picker the record form
+ // draws (one face per field type), not the browser's native input, whose
+ // placeholder and digit order are the machine's. The value is unchanged.
+ it('renders a date param through the platform date picker and resolves the YYYY-MM-DD day', async () => {
const resolve = openDialog([def({ name: 'due', type: 'date' })]);
- const input = await screen.findByLabelText('Param One');
- expect(input.getAttribute('type')).toBe('date');
- fireEvent.change(input, { target: { value: '2026-07-19' } });
+ const input = (await screen.findByLabelText('Param One')) as HTMLInputElement;
+ expect(input.closest('[data-slot="date-picker"]'), 'date picker not rendered').not.toBeNull();
+ expect(input.type).toBe('text');
+ expect(input.placeholder).toBe('MM/DD/YYYY');
+ expect(document.querySelector('input[type="date"]')).toBeNull();
+ fireEvent.change(input, { target: { value: '07/19/2026' } });
+ fireEvent.blur(input);
confirm();
await waitFor(() => expect(resolve).toHaveBeenCalledWith({ due: '2026-07-19' }));
});
@@ -611,9 +618,12 @@ describe('emitted value-shape parity (contract-tied render proofs, #2714)', () =
it('datetime param emits a zoned ISO instant the platform validator accepts', async () => {
const param = def({ name: 'when', type: 'datetime' });
const resolve = openDialog([param]);
- const input = await screen.findByLabelText('Param One');
- expect(input.getAttribute('type')).toBe('datetime-local');
- fireEvent.change(input, { target: { value: '2026-07-20T14:30' } });
+ const input = (await screen.findByLabelText('Param One')) as HTMLInputElement;
+ // objectui#12642: the platform picker, typed in the display locale's format.
+ expect(input.closest('[data-slot="date-picker"]'), 'date picker not rendered').not.toBeNull();
+ expect(document.querySelector('input[type="datetime-local"]')).toBeNull();
+ fireEvent.change(input, { target: { value: '07/20/2026 14:30' } });
+ fireEvent.blur(input);
confirm();
await waitFor(() => expect(resolve).toHaveBeenCalled());
const emitted = resolve.mock.calls[0][0].when;
diff --git a/packages/app-shell/src/views/metadata-admin/ActionDesigner.paramVocabulary.test.tsx b/packages/app-shell/src/views/metadata-admin/ActionDesigner.paramVocabulary.test.tsx
index 2d549539ce..da7a077506 100644
--- a/packages/app-shell/src/views/metadata-admin/ActionDesigner.paramVocabulary.test.tsx
+++ b/packages/app-shell/src/views/metadata-admin/ActionDesigner.paramVocabulary.test.tsx
@@ -22,7 +22,8 @@
* ## Why the expectations are hand-declared AND cross-checked
*
* `EXPECTED` below is read out of the runtime by a human: `datetime` renders
- * `DateTimeField`, so its mock must be a datetime control, not a text box. If
+ * the platform date picker (`DateTimePickerField`, objectui#12642), so its mock
+ * must be a date picker, not a plain text box. If
* the test DERIVED each expectation from the same resolver the preview calls,
* it could never catch the preview calling the resolver wrongly — the pin would
* assert the implementation against itself. So the per-case expectation is
@@ -85,13 +86,14 @@ const OFFERED: readonly string[] = PARAM_TYPE_OPTS.map((o) => o.value);
* `input:*` is the DOM `type` of the rendered ``; `record-picker` is the
* disabled combobox the preview mocks a `LookupField` with, the one that pops
* a dialog; `select` is the shared `Select`'s trigger, the combobox that pops
- * none, which `SelectField` draws too (objectui#11865).
+ * none, which `SelectField` draws too (objectui#11865); `date-picker` is the
+ * text box inside a `data-slot="date-picker"` wrapper, the shape the platform
+ * date picker draws for `date` and `datetime` (objectui#12642).
*/
type MockControl =
| 'input:text'
| 'input:number'
- | 'input:date'
- | 'input:datetime-local'
+ | 'date-picker'
| 'input:checkbox'
| 'textarea'
| 'select'
@@ -109,8 +111,8 @@ const MOCK_FOR_RUNTIME_WIDGET: Readonly> = {
number: 'input:number',
boolean: 'input:checkbox',
select: 'select',
- date: 'input:date',
- datetime: 'input:datetime-local',
+ date: 'date-picker',
+ datetime: 'date-picker',
lookup: 'record-picker',
};
@@ -146,8 +148,8 @@ const CASES: readonly Case[] = [
expected: 'select',
why: 'SelectField renders an EMPTY picker with a placeholder — not a text box',
},
- { spelling: 'date', expected: 'input:date', why: 'DateField' },
- { spelling: 'datetime', expected: 'input:datetime-local', why: 'DateTimeField' },
+ { spelling: 'date', expected: 'date-picker', why: 'DatePickerField, the platform date picker' },
+ { spelling: 'datetime', expected: 'date-picker', why: 'DateTimePickerField, the platform date picker' },
{
spelling: 'lookup',
extra: { reference: 'account' },
@@ -212,6 +214,7 @@ function drawnControl(container: HTMLElement): MockControl {
if (tag === 'select') return 'select';
if (tag === 'textarea') return 'textarea';
if (tag === 'button') return el.getAttribute('aria-haspopup') === 'dialog' ? 'record-picker' : 'select';
+ if (el.closest('[data-slot="date-picker"]')) return 'date-picker';
return `input:${(el as HTMLInputElement).type}` as MockControl;
}
diff --git a/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx
index 984fec6a70..9914907e98 100644
--- a/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx
+++ b/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx
@@ -28,6 +28,7 @@ import * as React from 'react';
import {
AlertTriangle,
Bot,
+ CalendarDays,
Code2,
Eye,
Globe,
@@ -661,9 +662,15 @@ function renderFieldMock(p: ActionParam, fieldLabel: string, locale?: string): R
case 'number':
return inputMock('number');
case 'date':
- return inputMock('date');
case 'datetime':
- return inputMock('datetime-local');
+ // The dialog draws the platform date picker for both (objectui#12642):
+ // a text box with a calendar button, not the browser's native input.
+ return (
+
+
+
+
+ );
case 'lookup':
// `LookupField` is a combobox that opens a record picker — mocked with
// the same disabled-combobox shape the designer's other pickers use, so
diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx
index acef3f63e2..10b136640b 100644
--- a/packages/fields/src/index.tsx
+++ b/packages/fields/src/index.tsx
@@ -4315,8 +4315,15 @@ const fieldWidgetMap = {
'number': () => import('./widgets/NumberField.js').then(m => ({ default: m.NumberField })),
'boolean': () => import('./widgets/BooleanField.js').then(m => ({ default: m.BooleanField })),
'select': () => import('./widgets/SelectField.js').then(m => ({ default: m.SelectField })),
- 'date': () => import('./widgets/DateField.js').then(m => ({ default: m.DateField })),
- 'datetime': () => import('./widgets/DateTimeField.js').then(m => ({ default: m.DateTimeField })),
+ // The platform date picker (objectui#12622, objectui#12642): a text box in
+ // the display locale's format with a calendar popover, for every consumer of
+ // this map — the record form (`registerField`) and the action-param and
+ // bulk-action dialogs (`getLazyFieldWidget`) alike, so a field type has one
+ // face. It writes what the native input wrote: `YYYY-MM-DD` for `date`, an
+ // ISO instant for `datetime`. Its read-only face is `DateField` /
+ // `DateTimeField`'s own.
+ 'date': () => import('./widgets/date-picker/DatePickerField.js').then(m => ({ default: m.DatePickerField })),
+ 'datetime': () => import('./widgets/date-picker/DatePickerField.js').then(m => ({ default: m.DateTimePickerField })),
'time': () => import('./widgets/TimeField.js').then(m => ({ default: m.TimeField })),
// Contact fields
@@ -4483,9 +4490,8 @@ const lazyFieldWidgets = new Map>();
/**
* Lazily-loaded form field widget for a field type. Shares the exact loaders
- * 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
+ * of {@link fieldWidgetMap} (the same components `registerField` registers for
+ * forms), 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}.
@@ -4717,26 +4723,6 @@ 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/date-picker/DatePickerField.tsx`).
- */
-const FORM_FACE_LOADERS: Partial> = {
- 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 {
const loader = fieldWidgetLoaderByKey[fieldType];
if (!loader) {
@@ -4745,9 +4731,7 @@ export function registerField(fieldType: string): void {
}
// Create lazy component
- const LazyFieldWidget = React.lazy(
- FORM_FACE_LOADERS[fieldType as RegisteredFieldWidgetType] ?? loader,
- );
+ const LazyFieldWidget = React.lazy(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/date-picker/DatePickerField.tsx b/packages/fields/src/widgets/date-picker/DatePickerField.tsx
index f8ea3e8fcb..49525643c3 100644
--- a/packages/fields/src/widgets/date-picker/DatePickerField.tsx
+++ b/packages/fields/src/widgets/date-picker/DatePickerField.tsx
@@ -30,11 +30,13 @@ import {
import { useFieldTranslation } from '../useFieldTranslation.js';
/**
- * The record form's date and date-time controls (objectui#12622).
+ * The `date` and date-time controls of the form field widgets
+ * (objectui#12622, objectui#12642).
*
- * 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
+ * `fieldWidgetMap`'s `date` / `datetime` widgets — what a record form resolves
+ * `field:date` / `field:datetime` to, and what `getLazyFieldWidget` hands the
+ * action-param and bulk-action dialogs — 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.
@@ -51,11 +53,10 @@ import { useFieldTranslation } from '../useFieldTranslation.js';
*
* - 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.
+ * the kanban required-fields dialog; plugin-detail's `InlineFieldInput`)
+ * import `DateField` / `DateTimeField` directly and keep the native input.
+ * A popover inside a grid cell or a filter row is a different interaction,
+ * measured on its own (objectui#12642).
* - 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.
diff --git a/packages/plugin-detail/src/__tests__/InlineFieldInput.dateOnlyWriteZone-12206.test.tsx b/packages/plugin-detail/src/__tests__/InlineFieldInput.dateOnlyWriteZone-12206.test.tsx
index 29ebcf7bdf..14042eabd1 100644
--- a/packages/plugin-detail/src/__tests__/InlineFieldInput.dateOnlyWriteZone-12206.test.tsx
+++ b/packages/plugin-detail/src/__tests__/InlineFieldInput.dateOnlyWriteZone-12206.test.tsx
@@ -121,8 +121,12 @@ async function formPick(day: string): Promise {
,
);
- await waitFor(() => expect(container.querySelector('input[type="date"]')).not.toBeNull());
- fireEvent.change(container.querySelector('input[type="date"]')!, { target: { value: day } });
+ // The form's `date` widget is the platform date picker (objectui#12642): a
+ // text box that commits on blur, and an ISO day reads in every locale.
+ const box = '[data-slot="date-picker"] input';
+ await waitFor(() => expect(container.querySelector(box)).not.toBeNull());
+ fireEvent.change(container.querySelector(box)!, { target: { value: day } });
+ fireEvent.blur(container.querySelector(box)!);
expect(onChange).toHaveBeenCalledTimes(1);
cleanup();
return onChange.mock.calls[0][0];
diff --git a/packages/plugin-grid/src/__tests__/bulkActionDialogParams.test.tsx b/packages/plugin-grid/src/__tests__/bulkActionDialogParams.test.tsx
index b434ddfc1b..8b52fe2960 100644
--- a/packages/plugin-grid/src/__tests__/bulkActionDialogParams.test.tsx
+++ b/packages/plugin-grid/src/__tests__/bulkActionDialogParams.test.tsx
@@ -13,7 +13,7 @@
* many → array patch → label preview) and the date widget. Since the ADR-0059
* migration (#3064) params render through the SHARED form field widgets
* (`@object-ui/fields` via `bulkParamToField`), so these tests now assert the
- * form-widget DOM (multiselect toggle chips, native date input) — and the new
+ * form-widget DOM (multiselect toggle chips, the platform date picker) — and the new
* lookup suite pins the #3064 contract itself: no eager candidate prefetch,
* fetch errors surfaced with a Retry affordance, and no failure caching
* (reopening the picker refetches).
@@ -120,15 +120,15 @@ describe('BulkActionDialog — multi-select param produces an array patch', () =
});
});
-describe('BulkActionDialog — date param renders a date picker', () => {
- it('renders a native date input rather than a free-text box', async () => {
+// objectui#12642: a `date` / `datetime` param draws the platform date picker
+// the record form draws (one face per field type), not the browser's native
+// input, whose placeholder and digit order are the machine's. The patch each
+// row receives is unchanged: `YYYY-MM-DD` for `date`, an ISO instant for
+// `datetime`.
+describe('BulkActionDialog — date params render the platform date picker', () => {
+ async function runWith(param: Record, typed: string) {
const ds = makeDataSource();
- const def: any = {
- name: 'set_due',
- label: 'Set due date',
- operation: 'update',
- params: [{ name: 'due', label: 'Due', type: 'date' }],
- };
+ const def: any = { name: 'set_it', label: 'Set it', operation: 'update', params: [param] };
render(
{
/>,
);
// Radix Dialog portals to document.body, and the widget loads lazily.
- await waitFor(() => {
- expect(document.querySelector('input[type="date"]')).toBeInTheDocument();
+ const input = await waitFor(() => {
+ const el = document.querySelector('[data-slot="date-picker"] input');
+ if (!el) throw new Error('date picker not rendered');
+ return el as HTMLInputElement;
});
+ expect(input.type).toBe('text');
+ expect(input.placeholder).toMatch(/^MM\/DD\/YYYY/);
+ expect(document.querySelector('input[type="date"], input[type="datetime-local"]')).toBeNull();
+ fireEvent.change(input, { target: { value: typed } });
+ fireEvent.blur(input);
+ const next = screen.getByRole('button', { name: 'Next' });
+ await waitFor(() => expect(next).toBeEnabled());
+ fireEvent.click(next);
+ fireEvent.click(await screen.findByRole('button', { name: 'Run' }));
+ await waitFor(() => expect(ds.update).toHaveBeenCalledTimes(1));
+ return ds.update.mock.calls[0];
+ }
+
+ it('a `date` param writes the YYYY-MM-DD day', async () => {
+ const call = await runWith({ name: 'due', label: 'Due', type: 'date', required: true }, '10/20/2026');
+ expect(call).toEqual(['thing', 'r1', { due: '2026-10-20' }]);
+ });
+
+ it('a `datetime` param writes the ISO instant of the typed local wall clock', async () => {
+ const call = await runWith({ name: 'at', label: 'At', type: 'datetime', required: true }, '10/20/2026 14:30');
+ expect(call).toEqual(['thing', 'r1', { at: new Date(2026, 9, 20, 14, 30).toISOString() }]);
});
});
diff --git a/scripts/vite-ineffective-dynamic-imports.ts b/scripts/vite-ineffective-dynamic-imports.ts
index f7f252e0c7..2cd07dcebe 100644
--- a/scripts/vite-ineffective-dynamic-imports.ts
+++ b/scripts/vite-ineffective-dynamic-imports.ts
@@ -183,8 +183,6 @@ export const DEFEATED_LAZY_FIELD_WIDGETS: readonly string[] = Object.freeze([
'packages/fields/src/widgets/CodeField.tsx',
'packages/fields/src/widgets/ColorField.tsx',
'packages/fields/src/widgets/CurrencyField.tsx',
- 'packages/fields/src/widgets/DateField.tsx',
- 'packages/fields/src/widgets/DateTimeField.tsx',
'packages/fields/src/widgets/EmailField.tsx',
'packages/fields/src/widgets/FileField.tsx',
'packages/fields/src/widgets/FilterConditionField.tsx',