diff --git a/.changeset/7443-datetime-compact-style.md b/.changeset/7443-datetime-compact-style.md
new file mode 100644
index 0000000000..c7190f6f26
--- /dev/null
+++ b/.changeset/7443-datetime-compact-style.md
@@ -0,0 +1,27 @@
+---
+'@object-ui/core': minor
+'@object-ui/fields': minor
+'@object-ui/components': patch
+---
+
+One home for the `datetime` display convention (objectui#7443).
+
+`formatDateTime` gains a named `'compact'` style, selected through
+`options.style` — the dense grid face, `7/4/2024 7:00 am` in `en-US` — which
+`DateTimeCellRenderer` used to build from its own inlined `Intl` option bags.
+The cell now reads `field.format` (it destructured `value` only, so a
+`datetime` field could not reach the style vocabulary a `date` field has) and
+renders through the shared function, and `data-table`'s `formatCellValue`
+calls `formatDateTime` instead of a third, independently authored option bag.
+Every existing cell renders byte-identically; `'compact'` is today's face
+named and rehoused, not a new one.
+
+Additive, no signature change: `formatDateTime(value, options?)` is unchanged
+and `formatDateTime(v, { locale })` keeps meaning what it meant.
+`DateDisplayOptions` gains an optional `style` key (read by `formatDateTime`
+only; `formatDate` still takes its style positionally), and
+`formatDateTimeCompactParts` is a new export of `@object-ui/core`, re-exported
+by `@object-ui/fields`, returning the compact face as the two halves a grid
+cell paints separately. `@object-ui/components` changes no rendered output —
+the table's datetime cell is measured identical before and after in `en-US`,
+`zh` and `de-DE`.
diff --git a/packages/components/src/__tests__/data-table-datetime-convention-7443.test.tsx b/packages/components/src/__tests__/data-table-datetime-convention-7443.test.tsx
new file mode 100644
index 0000000000..0185336ffb
--- /dev/null
+++ b/packages/components/src/__tests__/data-table-datetime-convention-7443.test.tsx
@@ -0,0 +1,128 @@
+/**
+ * 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#7443 — the THIRD spelling of the datetime convention converges.
+ *
+ * `formatCellValue` sniffed ISO strings and built its own
+ * `Intl.DateTimeFormat` with `{ year:'numeric', month:'short', day:'numeric',
+ * hour:'2-digit', minute:'2-digit' }` — close to `formatDateTime` but
+ * independently authored, so nothing kept the two in step. It now calls
+ * `formatDateTime` (default style).
+ *
+ * ── The stop-condition this file answers ─────────────────────────────────
+ * The ruling required `data-table`'s output measured BEFORE and AFTER, and a
+ * separate line in the PR if a pixel changed. It did not: the bag it used to
+ * build is the same bag `formatDateTime`'s default branch builds. These pins
+ * are the measurement — `FORMER_DATETIME_BAG` below is the bag copied verbatim
+ * from `origin/main`, and the rendered cell is asserted equal to it, so the
+ * two can never silently diverge again either.
+ *
+ * ── The date-only half is deliberately NOT converged ─────────────────────
+ * `formatDateTime` always carries a time and `formatDate`'s default drops the
+ * year inside the current year, so routing the date-only branch through either
+ * WOULD change what renders. #7443's subject is the datetime convention; the
+ * date-only bag keeps its own spelling here and is pinned unchanged.
+ */
+import { describe, it, expect, afterEach } from 'vitest';
+import React from 'react';
+import { render, cleanup } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { ComponentRegistry } from '@object-ui/core';
+import { I18nProvider, useObjectTranslation } from '@object-ui/i18n';
+// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
+// cold transform is billed to `hookTimeout` (objectui#3010/#3021).
+import '../renderers';
+
+const INSTANT = '2024-07-04T07:00:00.000Z';
+const DATE_ONLY = '2024-07-04';
+
+/** The bags `formatCellValue` inlined before this change, copied verbatim. */
+const FORMER_DATETIME_BAG: Intl.DateTimeFormatOptions = {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+};
+const FORMER_DATE_BAG: Intl.DateTimeFormatOptions = {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+};
+
+const former = (iso: string, locale: string, bag: Intl.DateTimeFormatOptions) =>
+ new Intl.DateTimeFormat(locale, bag).format(new Date(Date.parse(iso)));
+
+/**
+ * Reports the tag the table itself resolves. `formatCellValue` localizes from
+ * `useTableTranslation().language`, so the expectation is built from THE SAME
+ * tag the component read rather than from the one this file asked for — the
+ * property under test is "identical to the former bag", and hard-coding a tag
+ * the harness may not actually resolve would measure the harness instead.
+ */
+function LanguageProbe({ report }: { report: (language: string) => void }) {
+ report(useObjectTranslation().language);
+ return null;
+}
+
+function renderTable(language: string, value: string) {
+ const Component = ComponentRegistry.get('data-table')!;
+ const schema = {
+ type: 'data-table',
+ columns: [{ header: 'When', accessorKey: 'when' }],
+ data: [{ id: 'r1', when: value }],
+ } as any;
+ let resolved = '';
+ const result = render(
+
+ { resolved = l; }} />
+
+ ,
+ );
+ return { ...result, language: () => resolved };
+}
+
+const cellText = (container: HTMLElement) =>
+ container.querySelector('tbody tr td')?.textContent ?? '';
+
+afterEach(() => cleanup());
+
+describe('the datetime cell is byte-identical before and after the convergence', () => {
+ it.each(['en', 'de'])('%s — the rendered cell equals the former bag', (language) => {
+ const { container, language: resolved } = renderTable(language, INSTANT);
+ expect(cellText(container)).toBe(former(INSTANT, resolved(), FORMER_DATETIME_BAG));
+ });
+
+ it('en renders the exact string the card recorded for this path', () => {
+ const { container } = renderTable('en', INSTANT);
+ expect(cellText(container)).toBe('Jul 4, 2024, 07:00 AM');
+ });
+
+ it('the shared function and the former bag agree — that is why nothing moved', () => {
+ for (const language of ['en', 'de', 'zh']) {
+ const shared = new Date(INSTANT).toLocaleDateString(language, FORMER_DATETIME_BAG);
+ expect(shared).toBe(former(INSTANT, language, FORMER_DATETIME_BAG));
+ }
+ });
+});
+
+describe('the date-only cell is untouched', () => {
+ it.each(['en', 'de'])('%s — still the date-only bag, with no time appended', (language) => {
+ const { container, language: resolved } = renderTable(language, DATE_ONLY);
+ expect(cellText(container)).toBe(former(DATE_ONLY, resolved(), FORMER_DATE_BAG));
+ expect(cellText(container)).not.toMatch(/\d\d:\d\d/);
+ });
+});
+
+describe('non-date values are still returned untouched', () => {
+ it('a plain string is not sniffed into a date', () => {
+ const { container } = renderTable('en', 'not-a-date-at-all');
+ expect(cellText(container)).toBe('not-a-date-at-all');
+ });
+});
diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx
index 8df517bcf5..0af09e4480 100644
--- a/packages/components/src/renderers/complex/data-table.tsx
+++ b/packages/components/src/renderers/complex/data-table.tsx
@@ -12,7 +12,7 @@ import { cn } from '../../lib/utils';
import { resolveIcon } from '../action/resolve-icon';
import { useGridFieldAuthoring } from '../../context/gridFieldAuthoring';
import { describeIgnoredBind, describeNonArrayData } from './dataTableBindDiagnostic';
-import { ComponentRegistry, compareSortValues, evalRowPredicate, getSortValue } from '@object-ui/core';
+import { ComponentRegistry, compareSortValues, evalRowPredicate, formatDateTime, getSortValue } from '@object-ui/core';
import type { DataTableSchema, TableSortItem, TableColumnType } from '@object-ui/types';
import { SchemaRenderer, useRowPredicate, usePredicateScope } from '@object-ui/react';
import { createSafeTranslation } from '@object-ui/i18n';
@@ -766,10 +766,22 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
if (Number.isNaN(ts)) return value;
const hasTime = value.includes('T');
try {
- const fmt = new Intl.DateTimeFormat(language, hasTime
- ? { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }
- : { year: 'numeric', month: 'short', day: 'numeric' });
- return fmt.format(new Date(ts));
+ // The datetime half is `formatDateTime`'s DEFAULT style — the one home
+ // for this convention (objectui#7443). It used to be a third,
+ // independently authored `Intl.DateTimeFormat` bag here, close to but
+ // not derived from the shared function. Byte-identical in en-US, zh and
+ // de-DE, so no table cell changes.
+ if (hasTime) return formatDateTime(new Date(ts), { locale: language });
+ // The DATE-only half keeps its own bag on purpose: `formatDateTime`
+ // always carries a time, and `formatDate`'s default drops the year in
+ // the current year — routing this branch through either WOULD change
+ // what renders. #7443's subject is the datetime convention; the
+ // date-only divergence is recorded separately.
+ return new Intl.DateTimeFormat(language, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ }).format(new Date(ts));
} catch {
return value;
}
diff --git a/packages/core/src/utils/date-display.ts b/packages/core/src/utils/date-display.ts
index b924b02847..8d5716224c 100644
--- a/packages/core/src/utils/date-display.ts
+++ b/packages/core/src/utils/date-display.ts
@@ -29,9 +29,16 @@
* upper package re-exports it, so there is one home and nothing to drift.
*
* `@object-ui/fields` re-exports every symbol below under its original name,
- * so `formatDate` / `formatDateTime` / `formatRelativeDate` /
- * `DateDisplayOptions` keep working unchanged for `ObjectGrid`, `ObjectGantt`,
- * `plugin-dashboard`'s `recordFields` and the `date` cell renderer.
+ * so `formatDate` / `formatDateTime` / `formatDateTimeCompactParts` /
+ * `formatRelativeDate` / `DateDisplayOptions` keep working unchanged for
+ * `ObjectGrid`, `ObjectGantt`, `plugin-dashboard`'s `recordFields` and the
+ * `date` cell renderer.
+ *
+ * The `datetime` CELL face joined this file in objectui#7443. It used to be a
+ * second convention inlined in `DateTimeCellRenderer`: two `Intl` option bags
+ * for one field type, kept in step by nothing, while `date` had exactly one.
+ * It is `formatDateTime`'s `'compact'` style now, byte-identical to what the
+ * cell rendered before.
*
* Pure by construction (no React, no i18n): the only ambient inputs are `Intl`
* and the clock, and the one phrase `Intl` cannot produce ("Overdue Nd") comes
@@ -39,13 +46,33 @@
* in `dataset-format.ts` takes `fieldLabel`.
*/
-/** Options shared by {@link formatDate} / {@link formatRelativeDate}. */
+/**
+ * Options shared by {@link formatDate} / {@link formatRelativeDate} /
+ * {@link formatDateTime}. One bag, and each function reads the keys it needs:
+ * `dueLike` and `t` only matter on the relative path, `style` is read by
+ * `formatDateTime` only (see below).
+ */
export interface DateDisplayOptions {
dueLike?: boolean;
/** BCP-47 display locale (ADR-0053 tenant default); falls back to the runtime locale. */
locale?: string;
/** i18n translate fn for phrases `Intl` can't produce (the "Overdue Nd" wording). */
t?: (key: string, params?: Record) => string;
+ /**
+ * Named face, read by {@link formatDateTime}: `'compact'` is the dense grid
+ * cell face (objectui#7443); anything else, or absent, is the default face.
+ *
+ * It rides here rather than in a second positional parameter because
+ * `formatDateTime(value, options?)` is a PUBLISHED signature with `options`
+ * in position two (objectui#4272). A positional `style` would have displaced
+ * it: TypeScript would reject the old call, but a JavaScript caller would
+ * silently hand its options bag to the style slot and lose its locale —
+ * the #4272 defect again. {@link formatDate} still takes its style
+ * positionally and does NOT read this key; the symmetric long-run shape
+ * (both functions reading `options.style`) is additive on `formatDate` and
+ * deliberately not part of #7443.
+ */
+ style?: string;
}
/**
@@ -136,22 +163,81 @@ export function formatDate(value: string | Date | number, style?: string, option
});
}
+/**
+ * The `'compact'` datetime face as the two halves a grid cell paints
+ * separately — `7/4/2024` and `7:00 am` for `2024-07-04T07:00:00Z` in `en-US`.
+ *
+ * `formatDateTime(value, { style: 'compact', ...options })` is exactly
+ * `date + ' ' + time` of what this returns, so a caller that wants the face
+ * as ONE string and a caller that wants to style the halves differently
+ * cannot drift apart. That drift is what objectui#7443 recorded:
+ * `DateTimeCellRenderer` inlined these two option bags and never called this
+ * module, so `datetime` had two display conventions while `date` had one —
+ * the same shape as objectui#4576, which this repo has already paid for once.
+ *
+ * `null` for a value this module renders as `'—'`; the cell renders its own
+ * empty state for those, so it never sees the dash.
+ */
+export function formatDateTimeCompactParts(
+ value: string | Date | number,
+ options?: DateDisplayOptions,
+): { date: string; time: string } | null {
+ if (value === null || value === undefined || value === '') return null;
+ const date = value instanceof Date ? value : new Date(value as any);
+ if (!(date instanceof Date) || isNaN(date.getTime())) return null;
+
+ return {
+ date: date.toLocaleDateString(options?.locale, {
+ month: 'numeric',
+ day: 'numeric',
+ year: 'numeric',
+ }),
+ // `hour12` stays declared: this is the compact Airtable-style cell, and
+ // the 12-hour face is its design, not a locale artefact. Locales that
+ // write no am/pm marker simply ignore it.
+ time: date.toLocaleTimeString(options?.locale, {
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ }).toLowerCase(),
+ };
+}
+
/**
* Format datetime value.
*
- * `options` mirrors {@link formatDate}'s and is optional, so an existing
- * caller that passes nothing keeps the exact runtime-default behavior it had.
- * Before objectui#4272 the parameter did not exist at all, which meant no
- * caller could localize this function however hard it tried — it always handed
- * `Intl` an `undefined` tag, i.e. the MACHINE's locale, which is neither of
- * the repo's two locale channels. Callers should pass the tag from
- * `useDisplayLocale()`.
+ * `options.style` selects a named face:
+ *
+ * - `'compact'` — the dense grid face, `7/4/2024 7:00 am` in `en-US`. It is
+ * what every `datetime` CELL renders, and what `DateTimeCellRenderer`
+ * used to build from its own inlined `Intl` bags (objectui#7443).
+ * - anything else, including absent — the verbose default,
+ * `Jul 4, 2024, 07:00 AM` in `en-US`. Unchanged, and still what a
+ * non-cell caller (dataset measure, gantt tooltip, data-table) gets.
+ *
+ * The signature is `(value, options?)`, unchanged: `style` is a key of
+ * `options`, not a positional parameter, so every existing call —
+ * `formatDateTime(v, { locale })` included — keeps meaning exactly what it
+ * meant (see the note on `DateDisplayOptions.style` for why the positional
+ * shape `formatDate` uses was refused here).
+ *
+ * `options` is optional, so a caller that passes nothing keeps the exact
+ * runtime-default behavior it had. Before objectui#4272 the parameter did not
+ * exist at all, which meant no caller could localize this function however
+ * hard it tried — it always handed `Intl` an `undefined` tag, i.e. the
+ * MACHINE's locale, which is neither of the repo's two locale channels.
+ * Callers should pass the tag from `useDisplayLocale()`.
*/
export function formatDateTime(value: string | Date | number, options?: DateDisplayOptions): string {
if (value === null || value === undefined || value === '') return '—';
const date = value instanceof Date ? value : new Date(value as any);
if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
+ if (options?.style === 'compact') {
+ const parts = formatDateTimeCompactParts(date, options);
+ return parts ? `${parts.date} ${parts.time}` : '—';
+ }
+
return date.toLocaleDateString(options?.locale, {
year: 'numeric',
month: 'short',
diff --git a/packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx b/packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx
new file mode 100644
index 0000000000..4094ff94fe
--- /dev/null
+++ b/packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx
@@ -0,0 +1,296 @@
+/**
+ * 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#7443 — ONE home for the `datetime` display convention.
+ *
+ * ── What was measured ────────────────────────────────────────────────────
+ * `date` cells routed through the shared `formatDate` and read `field.format`
+ * as a display style. `datetime` cells did neither: `DateTimeCellRenderer`
+ * destructured `value` only and built its OWN pair of `Intl` option bags, so
+ * one field type had two display conventions kept in step by nothing, and the
+ * style vocabulary `date` had was unreachable for `datetime`.
+ *
+ * | path | rendered |
+ * | --------------------------------- | ----------------------- |
+ * | `DateTimeCellRenderer` (the cell) | `7/4/2024 7:00 am` |
+ * | `formatDateTime` (the module's) | `Jul 4, 2024, 07:00 AM` |
+ *
+ * That is the shape of objectui#4576, which this repo has already paid for
+ * once (`1.234,5 %` beside `1.234,5%` in a German session).
+ *
+ * ── The property these pins exist to prove ───────────────────────────────
+ * The compact face was NOT redesigned. It was named `'compact'`, moved into
+ * `formatDateTime`, and every existing cell still renders it — BYTE-identical,
+ * not merely "still a string". Each expectation below is therefore computed
+ * from the EXACT option bags the renderer used to inline (`FORMER_*` at the
+ * top of the file), so an ICU or Node upgrade moves both sides together and
+ * the pin keeps measuring drift from the former face rather than the age of
+ * the runner. The `en-US` literal from the card is asserted alongside them, on
+ * the same instant, so a silent redesign cannot pass by moving both sides.
+ *
+ * ── The call shape (maintainer ruling B, objectui#7443) ──────────────────
+ * `'compact'` rides INSIDE `options` — `formatDateTime(v, { style: 'compact',
+ * locale })` — and the published signature stays `(value, options?)`. A
+ * positional `style` parameter would have displaced the `options` objectui#4272
+ * put in position two, and a JavaScript caller that did not move its argument
+ * would silently lose its locale. The arity pin below is the mechanical guard:
+ * `formatDateTime.length` is 2 and turns 3 the moment a positional slot is
+ * inserted again.
+ *
+ * ── Directions ───────────────────────────────────────────────────────────
+ * Reverting `formatDateTime` to a single default face turns every `'compact'`
+ * case RED. Reverting the renderer to its inlined bags leaves the `'compact'`
+ * cases GREEN (they measure the function) and turns the `field.format` cases
+ * RED (the renderer would read no field at all). Re-inserting a positional
+ * `style` turns the arity pin RED and, because `{ style, locale }` would then
+ * land in the style slot, every localized case with it.
+ */
+import { describe, it, expect, afterEach } from 'vitest';
+import React from 'react';
+import { render, cleanup } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { I18nProvider, LocalizationProvider } from '@object-ui/i18n';
+import {
+ DateCellRenderer,
+ DateTimeCellRenderer,
+ formatDateTime,
+ formatDateTimeCompactParts,
+} from '../index';
+
+/** The card's instant, in the card's locale, with the card's expected face. */
+const INSTANT = '2024-07-04T07:00:00.000Z';
+
+/**
+ * The two option bags `DateTimeCellRenderer` inlined before this change,
+ * copied verbatim from `origin/main`. Every "byte-identical" claim below is
+ * measured against THESE, not against a literal typed by hand.
+ */
+const FORMER_DATE_BAG: Intl.DateTimeFormatOptions = {
+ month: 'numeric',
+ day: 'numeric',
+ year: 'numeric',
+};
+const FORMER_TIME_BAG: Intl.DateTimeFormatOptions = {
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+};
+
+/** Exactly what the cell used to compute, for a locale. */
+function formerCellFace(iso: string, locale: string): { date: string; time: string } {
+ const d = new Date(iso);
+ return {
+ date: d.toLocaleDateString(locale, FORMER_DATE_BAG),
+ time: d.toLocaleTimeString(locale, FORMER_TIME_BAG).toLowerCase(),
+ };
+}
+
+/** `en-US` plus one NON-US locale, as the ruling requires. */
+const LOCALES = ['en-US', 'zh', 'de-DE'];
+
+/**
+ * `useDisplayLocale()` resolves tenant locale -> active UI language -> `'en'`,
+ * so the tag is set as the TENANT locale: it is the channel objectui#4468
+ * made every date branch read, and it pins the exact tag rather than whatever
+ * a language code happens to widen to. `persistLanguage={false}` keeps each
+ * case on its own language instead of inheriting the previous one's.
+ */
+function renderSession(locale: string, node: React.ReactElement) {
+ return render(
+
+ {node}
+ ,
+ );
+}
+
+afterEach(() => cleanup());
+
+describe("formatDateTime's 'compact' style is the former cell face, byte-identical", () => {
+ it.each(LOCALES)('%s — the joined face', (locale) => {
+ const former = formerCellFace(INSTANT, locale);
+ expect(formatDateTime(INSTANT, { style: 'compact', locale })).toBe(
+ `${former.date} ${former.time}`,
+ );
+ });
+
+ it.each(LOCALES)('%s — the halves the cell paints separately', (locale) => {
+ const former = formerCellFace(INSTANT, locale);
+ expect(formatDateTimeCompactParts(INSTANT, { locale })).toEqual(former);
+ });
+
+ it('the en-US face is the one the card recorded', () => {
+ expect(formatDateTime(INSTANT, { style: 'compact', locale: 'en-US' })).toBe('7/4/2024 7:00 am');
+ });
+
+ it('the joined face is exactly the two halves and one space', () => {
+ const parts = formatDateTimeCompactParts(INSTANT, { locale: 'en-US' })!;
+ expect(`${parts.date} ${parts.time}`).toBe(
+ formatDateTime(INSTANT, { style: 'compact', locale: 'en-US' }),
+ );
+ });
+
+ it('empty and invalid values answer the module dash, not a broken face', () => {
+ expect(formatDateTime('', { style: 'compact', locale: 'en-US' })).toBe('—');
+ expect(formatDateTime('not-a-date', { style: 'compact', locale: 'en-US' })).toBe('—');
+ expect(formatDateTimeCompactParts('', { locale: 'en-US' })).toBeNull();
+ expect(formatDateTimeCompactParts('not-a-date', { locale: 'en-US' })).toBeNull();
+ });
+});
+
+describe('the DEFAULT style is untouched — the non-cell convention still renders', () => {
+ it.each(LOCALES)('%s — unchanged verbose face', (locale) => {
+ const verbose = new Date(INSTANT).toLocaleDateString(locale, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ expect(formatDateTime(INSTANT, { locale })).toBe(verbose);
+ });
+
+ it('en-US default is the second row of the card table, not the compact face', () => {
+ expect(formatDateTime(INSTANT, { locale: 'en-US' })).toBe('Jul 4, 2024, 07:00 AM');
+ expect(formatDateTime(INSTANT, { locale: 'en-US' })).not.toBe(
+ formatDateTime(INSTANT, { style: 'compact', locale: 'en-US' }),
+ );
+ });
+
+ it('an unrecognised style falls to the default face, as it does on formatDate', () => {
+ expect(formatDateTime(INSTANT, { style: 'no-such-style', locale: 'en-US' })).toBe(
+ formatDateTime(INSTANT, { locale: 'en-US' }),
+ );
+ });
+});
+
+describe("the signature stays (value, options?) — 'compact' rides in options (ruling B)", () => {
+ it('formatDateTime declares exactly two parameters: no positional style slot', () => {
+ // `Function.length` counts declared parameters. `(value, options?)` is 2;
+ // the refused `(value, style?, options?)` shape is 3.
+ expect(formatDateTime.length).toBe(2);
+ });
+
+ it('the objectui#4272 call shape — options in position two — still localizes', () => {
+ const zh = formatDateTime(INSTANT, { locale: 'zh' });
+ const en = formatDateTime(INSTANT, { locale: 'en-US' });
+ expect(zh).toBe(
+ new Date(INSTANT).toLocaleDateString('zh', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ }),
+ );
+ expect(zh).not.toBe(en);
+ });
+
+ it('style is read from options, so the compact face is reachable with the locale beside it', () => {
+ const former = formerCellFace(INSTANT, 'de-DE');
+ expect(formatDateTime(INSTANT, { style: 'compact', locale: 'de-DE' })).toBe(
+ `${former.date} ${former.time}`,
+ );
+ });
+});
+
+describe('every existing datetime cell renders unchanged', () => {
+ it.each(LOCALES)('%s — a field with no format still paints the compact halves', (locale) => {
+ const former = formerCellFace(INSTANT, locale);
+ const { container } = renderSession(
+ locale,
+ ,
+ );
+ const spans = container.querySelectorAll('span > span');
+ expect(spans).toHaveLength(2);
+ expect(spans[0].textContent).toBe(former.date);
+ expect(spans[1].textContent).toBe(former.time);
+ // The time half stays muted and offset — the two-tone face is the visual
+ // half of "renders unchanged", and collapsing it to one string would be a
+ // visible change even with identical text.
+ expect(spans[1].className).toMatch(/text-muted-foreground/);
+ expect(spans[1].className).toMatch(/ml-2/);
+ });
+
+ it('an authored empty format is still the compact face, not the verbose one', () => {
+ const former = formerCellFace(INSTANT, 'en-US');
+ const { container } = renderSession(
+ 'en-US',
+ ,
+ );
+ expect(container.textContent).toBe(`${former.date}${former.time}`);
+ });
+});
+
+describe('field.format now works for datetime, the way it already did for date', () => {
+ it('an explicit compact format renders the same face as no format at all', () => {
+ const { container: implicit } = renderSession(
+ 'en-US',
+ ,
+ );
+ const implicitText = implicit.textContent;
+ cleanup();
+ const { container: explicit } = renderSession(
+ 'en-US',
+ ,
+ );
+ expect(explicit.textContent).toBe(implicitText);
+ });
+
+ it('an authored non-compact format reaches formatDateTime — the vocabulary is live', () => {
+ const { container } = renderSession(
+ 'en-US',
+ ,
+ );
+ expect(container.textContent).toBe('Jul 4, 2024, 07:00 AM');
+ // Before this change the renderer never saw `field` at all, so this string
+ // was unreachable from metadata however it was authored.
+ expect(container.textContent).not.toBe('7/4/2024 7:00 am');
+ });
+
+ it('an absent field is tolerated — the compact face is the default', () => {
+ const { container } = renderSession(
+ 'en-US',
+ ,
+ );
+ expect(container.textContent).toBe('7/4/20247:00 am');
+ });
+});
+
+describe('the date cell path is untouched', () => {
+ it('a date field still renders through formatDate, not the datetime convention', () => {
+ const { container } = renderSession(
+ 'en-US',
+ ,
+ );
+ expect(container.textContent).toBe("Jul 4, '24");
+ });
+
+ it("a date field's default style is still relative, not compact", () => {
+ const { container } = renderSession(
+ 'en-US',
+ ,
+ );
+ expect(container.textContent).not.toContain('7:00 am');
+ });
+});
diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx
index 2bdf38de8a..bb09749989 100644
--- a/packages/fields/src/index.tsx
+++ b/packages/fields/src/index.tsx
@@ -8,7 +8,7 @@
import React from 'react';
import type { FieldMetadata, SelectOptionMetadata } from '@object-ui/types';
-import { ComponentRegistry, percentDisplayValue, getRecordDisplayName, humanizeLabel, isMissingForRequired, formatDate, formatDateTime, formatRelativeDate, type ComponentMeta, type DateDisplayOptions } from '@object-ui/core';
+import { ComponentRegistry, percentDisplayValue, getRecordDisplayName, humanizeLabel, isMissingForRequired, formatDate, formatDateTime, formatDateTimeCompactParts, formatRelativeDate, type ComponentMeta, type DateDisplayOptions } from '@object-ui/core';
// The platform's own value-shape contract, asked rather than restated
// (objectui#6744). See `locationStoredValueSchemaFor` below for why this is a
// runtime import in the barrel and not a hand-written coordinate range.
@@ -589,7 +589,7 @@ export { humanizeLabel };
* moving was a second date convention in `dataset-format.ts`, which is the
* drift #4576 already paid for once with percent.
*/
-export { formatDate, formatDateTime, formatRelativeDate };
+export { formatDate, formatDateTime, formatDateTimeCompactParts, formatRelativeDate };
export type { DateDisplayOptions };
/**
@@ -863,36 +863,50 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
/**
* DateTime field cell renderer (Airtable-style with date and time visually separated)
*/
-export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactElement {
+export function DateTimeCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
// Hook before every early return — a value flipping between null and set
// must not change the hook count between renders (same rule as the number /
// currency renderers above). This is the site objectui#4468 caught rendering
// `8/11/2026 12:00 am` inside an otherwise Chinese grid: both calls passed
// `undefined`, i.e. the machine's locale, on every session.
const locale = useDisplayLocale();
+ const t = useFieldTranslate();
if (!value) return ;
const safe = coerceToSafeValue(value);
const date = safe != null ? new Date(safe as string | number) : null;
if (date === null || isNaN(date.getTime())) return ;
- const datePart = date.toLocaleDateString(locale, {
- month: 'numeric',
- day: 'numeric',
- year: 'numeric',
- });
- // `hour12` stays declared: this is the compact Airtable-style cell, and the
- // 12-hour face is its design, not a locale artefact. Locales that write no
- // am/pm marker simply ignore it.
- const timePart = date.toLocaleTimeString(locale, {
- hour: 'numeric',
- minute: '2-digit',
- hour12: true,
- }).toLowerCase();
+ // `field.format` is read as a display style here for the same reason
+ // `DateCellRenderer` reads it one function up: `datetime` had no style
+ // vocabulary at all because this renderer destructured `value` only
+ // (objectui#7443). `||`, not `??`, matches the `date` cell and keeps an
+ // authored empty string on the compact face rather than dropping it into
+ // the verbose default.
+ const style = (field as any)?.format || 'compact';
+
+ // The compact face is painted in two halves — the time is muted and offset
+ // — so this branch asks the shared module for the halves rather than the
+ // joined string. Both come out of `formatDateTimeCompactParts`, which is
+ // also what `formatDateTime(value, { style: 'compact' })` joins, so the
+ // cell and every string caller of the compact face render the same instant
+ // identically.
+ // `null` is unreachable: the invalid/empty values it answers for already
+ // returned `` above.
+ if (style === 'compact') {
+ const parts = formatDateTimeCompactParts(date, { locale });
+ if (parts) {
+ return (
+
+ {parts.date}
+ {parts.time}
+
+ );
+ }
+ }
return (
- {datePart}
- {timePart}
+ {formatDateTime(date, { style, locale, t })}
);
}