Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/10567-date-only-impossible-day.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@object-ui/fields': patch
'@object-ui/i18n': patch
---

fix(fields): the editable date-only faces no longer blank a stored impossible day silently

`toDateInputValue` keeps a stored `2026-02-30` as written and never rolls it,
but an `<input type="date">` sanitises a day that does not exist to an empty
value (measured in Chromium). So `DateField` and the sub-grid's editable `date`
cell showed an empty control with no marker for a value that was stored. That
is a silent blank, which the direction of objectui#10026 rules out, and it is
the date-only half of objectui#10474.

Both faces now judge the day as written with `isImpossibleStoredDay` (the check
objectui#10474 added). For such a day they hand the control an empty value,
mark it `aria-invalid`, and name the stored string beside it with the new
date-only sentence `fields.date.impossibleDay`, in all ten packs. The control's
`aria-describedby` points at that sentence. Nothing is written until the user
picks a new day, and picking a real day clears the marker. Real days and empty
values render as before.
203 changes: 203 additions & 0 deletions packages/fields/src/widgets/DateField.impossibleDay-10567.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* 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#10567 — the EDITABLE date-only faces never blank a stored day that
* does not exist silently. The date-only half of objectui#10474.
*
* ── The defect ───────────────────────────────────────────────────────────
* `toDateInputValue('2026-02-30')` keeps the day as written (it never rolls),
* but an `<input type="date">` sanitises a nonexistent day to `""`: measured
* in Chromium 141, and the HTML value sanitisation algorithm for `date` says
* the same. So `DateField` and the sub-grid's editable `date` cell painted an
* empty control with no marker for a stored value: objectui#3127's shape,
* which objectui#10026's direction A rules out ("never blank silently").
*
* ── The face (objectui#10474's, reused) ────────────────────────────────────
* `isImpossibleStoredDay` judges the day as written. The control is handed
* `""` (the only thing it can paint), is marked `aria-invalid`, and a notice
* NAMING the stored string sits beside it and describes it. Nothing is
* written until the user picks a new value; picking a real day clears the
* marker.
*
* ── Directions, predicted before the run ───────────────────────────────────
* pre-card widgets RED — no `aria-invalid`, no notice
* detection removed (ablation) RED on every impossible-day row,
* including the edit-clears row (it asserts
* the marker before the edit)
* controls (real day, empty) green throughout
*/

import React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { DateField } from './DateField';
import { GridField } from './GridField';

afterEach(() => cleanup());

/** Nonexistent days, date-only and in the ISO shape an API may hand back. */
const IMPOSSIBLE = ['2026-02-30', '2024-02-31', '2025-02-29', '2026-02-30T00:00:00.000Z'];

/** The real days the engine rolls those onto — none may appear anywhere. */
const ROLLED = ['2026-03-02', '2024-03-02', '2025-03-01'];

/** Real days, a leap day among them — no marker, no notice. */
const REAL = ['2026-02-28', '2024-02-29'];

/** `DateField` reads nothing off `field` on the editable path. */
const NO_FIELD = {} as any;

type Row = Record<string, unknown>;

function StatefulDateField({ initial, onChange }: { initial: string; onChange: (v: string) => void }) {
const [value, setValue] = React.useState(initial);
return (
<DateField
value={value}
onChange={(v: string) => {
onChange(v);
setValue(v);
}}
field={NO_FIELD}
/>
);
}

describe('objectui#10567 — DateField, editable', () => {
it.each(IMPOSSIBLE)('a stored %s is not blanked silently: the control is marked and the stored string is named', (v) => {
const onChange = vi.fn();
const { container } = render(<DateField value={v} onChange={onChange} field={NO_FIELD} />);
const input = container.querySelector('input[type="date"]') as HTMLInputElement;
expect(input.value).toBe('');
expect(input.getAttribute('aria-invalid')).toBe('true');
const notice = screen.getByTestId('date-impossible-day');
expect(notice.textContent).toContain(`"${v}"`);
expect(input.getAttribute('aria-describedby')?.split(' ')).toContain(notice.id);
for (const d of ROLLED) expect(container.textContent + input.value).not.toContain(d);
// Untouched: nothing is written.
fireEvent.blur(input);
expect(onChange).not.toHaveBeenCalled();
});

it('keeps a host-supplied aria-describedby beside the notice', () => {
const { container } = render(
<DateField value="2026-02-30" onChange={() => {}} field={NO_FIELD} aria-describedby="host-msg" />,
);
const input = container.querySelector('input') as HTMLInputElement;
expect(input.getAttribute('aria-describedby')?.split(' ')).toEqual(['host-msg', screen.getByTestId('date-impossible-day').id]);
});

it('picking a real day writes it and clears the marker and the notice', () => {
const onChange = vi.fn();
const { container } = render(<StatefulDateField initial="2026-02-30" onChange={onChange} />);
const input = container.querySelector('input[type="date"]') as HTMLInputElement;
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(screen.getByTestId('date-impossible-day')).toBeTruthy();

fireEvent.change(input, { target: { value: '2026-02-28' } });
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('2026-02-28');
const after = container.querySelector('input[type="date"]') as HTMLInputElement;
expect(after.value).toBe('2026-02-28');
expect(after.getAttribute('aria-invalid')).toBe('false');
expect(after.getAttribute('aria-describedby')).toBeNull();
expect(screen.queryByTestId('date-impossible-day')).toBeNull();
});

it.each(REAL)('control: a real day %s shows as written, with no marker and no notice', (v) => {
const { container } = render(<DateField value={v} onChange={() => {}} field={NO_FIELD} />);
const input = container.querySelector('input[type="date"]') as HTMLInputElement;
expect(input.value).toBe(v);
expect(input.getAttribute('aria-invalid')).toBe('false');
expect(input.getAttribute('aria-describedby')).toBeNull();
expect(screen.queryByTestId('date-impossible-day')).toBeNull();
});

it.each<[string | undefined]>([[''], [undefined]])('control: an empty value (%j) gives no marker and no notice', (v) => {
const { container } = render(<DateField value={v as string} onChange={() => {}} field={NO_FIELD} />);
const input = container.querySelector('input[type="date"]') as HTMLInputElement;
expect(input.value).toBe('');
expect(input.getAttribute('aria-invalid')).toBe('false');
expect(input.getAttribute('aria-describedby')).toBeNull();
expect(screen.queryByTestId('date-impossible-day')).toBeNull();
});
});

describe('objectui#10567 — the sub-grid\'s editable `date` cell', () => {
const field = {
columns: [
{ name: 'merchant', label: 'Merchant', type: 'text' as const },
{ name: 'due_on', label: 'Due On', type: 'date' as const },
],
} as any;

function StatefulGrid({ initial, onChange }: { initial: Row[]; onChange: (rows: Row[]) => void }) {
const [rows, setRows] = React.useState(initial);
return (
<GridField
value={rows}
onChange={(next: Row[]) => {
onChange(next);
setRows(next);
}}
field={field}
/>
);
}

it.each(IMPOSSIBLE)('a stored %s is not blanked silently: the cell is marked and the stored string is named', (v) => {
const onChange = vi.fn();
const { container } = render(<GridField value={[{ merchant: 'X', due_on: v }]} onChange={onChange} field={field} />);
const input = screen.getAllByLabelText('Due On')[0] as HTMLInputElement;
expect(input.getAttribute('type')).toBe('date');
expect(input.value).toBe('');
expect(input.getAttribute('aria-invalid')).toBe('true');
const notice = screen.getByTestId('line-items-impossible-day-0-due_on');
expect(notice.textContent).toContain(`"${v}"`);
expect(input.getAttribute('aria-describedby')).toBe(notice.id);
for (const d of ROLLED) expect(container.textContent + input.value).not.toContain(d);
fireEvent.blur(input);
expect(onChange).not.toHaveBeenCalled();
});

it('picking a real day writes it and clears the marker and the notice', () => {
const onChange = vi.fn();
render(<StatefulGrid initial={[{ merchant: 'X', due_on: '2026-02-30' }]} onChange={onChange} />);
let input = screen.getAllByLabelText('Due On')[0] as HTMLInputElement;
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(screen.getByTestId('line-items-impossible-day-0-due_on')).toBeTruthy();

fireEvent.change(input, { target: { value: '2026-02-28' } });
expect(onChange).toHaveBeenCalled();
expect(onChange.mock.calls.at(-1)![0][0].due_on).toBe('2026-02-28');
input = screen.getAllByLabelText('Due On')[0] as HTMLInputElement;
expect(input.value).toBe('2026-02-28');
expect(input.getAttribute('aria-invalid')).toBeNull();
expect(input.getAttribute('aria-describedby')).toBeNull();
expect(screen.queryByTestId('line-items-impossible-day-0-due_on')).toBeNull();
});

it.each(REAL)('control: a real day %s shows as written, with no marker and no notice', (v) => {
render(<GridField value={[{ merchant: 'X', due_on: v }]} onChange={() => {}} field={field} />);
const input = screen.getAllByLabelText('Due On')[0] as HTMLInputElement;
expect(input.value).toBe(v);
expect(input.getAttribute('aria-invalid')).toBeNull();
expect(input.getAttribute('aria-describedby')).toBeNull();
expect(screen.queryByTestId('line-items-impossible-day-0-due_on')).toBeNull();
});

it('control: an empty cell gives no marker and no notice', () => {
render(<GridField value={[{ merchant: 'X', due_on: '' }]} onChange={() => {}} field={field} />);
const input = screen.getAllByLabelText('Due On')[0] as HTMLInputElement;
expect(input.value).toBe('');
expect(input.getAttribute('aria-invalid')).toBeNull();
expect(input.getAttribute('aria-describedby')).toBeNull();
expect(screen.queryByTestId('line-items-impossible-day-0-due_on')).toBeNull();
});
});
36 changes: 32 additions & 4 deletions packages/fields/src/widgets/DateField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { formatDate, toDisplayDate } from '@object-ui/core';
import { FieldWidgetComponentProps } from './types.js';
import { toDomProps } from './toDomProps.js';
import { openNativePicker } from './openNativePicker.js';
import { toDateInputValue } from './nativeDateValue.js';
import { toDateInputValue, isImpossibleStoredDay } from './nativeDateValue.js';
import { useFieldTranslation } from './useFieldTranslation.js';

/**
* DateField - Date picker input widget
Expand All @@ -16,6 +17,8 @@ export function DateField({ value, onChange, field, readonly, error, ...props }:
// (objectui#4468). A bare `toLocaleDateString()` reads the MACHINE's locale,
// which is how a Chinese form ended up with an `8/11/2026` value in it.
const locale = useDisplayLocale();
const { t } = useFieldTranslation();
const noticeId = React.useId();
if (readonly) {
// The readonly face is `formatDate`'s DEFAULT style — the one home for the
// `date` display convention (objectui#8194, following the maintainer's
Expand Down Expand Up @@ -67,6 +70,21 @@ export function DateField({ value, onChange, field, readonly, error, ...props }:
}

const domProps = toDomProps(props);
/**
* A stored value written on a day that does not exist (objectui#10567, the
* date-only half of objectui#10474). `toDateInputValue` keeps such a day as
* written, but an `<input type="date">` sanitises it to `""` (measured in
* Chromium; the HTML value sanitisation algorithm for `date` says the same),
* so the control alone would be objectui#3127's silent blank. The control is
* handed `""` — the only thing it can paint — and marked invalid, and the
* stored string is NAMED beside it: `DateTimeField`'s face for the same
* value. Nothing is written until the user picks a new day: the control
* emits only on a user edit.
*/
const impossible = isImpossibleStoredDay(value);
const describedBy = impossible
? [domProps['aria-describedby'], noticeId].filter(Boolean).join(' ')
: domProps['aria-describedby'];

/**
* `aria-invalid` after the DOM spread below, the objectui#3222 idiom shared
Expand All @@ -91,21 +109,31 @@ export function DateField({ value, onChange, field, readonly, error, ...props }:
* objectui#7008) and nothing read it. MARKING only: the message TEXT stays
* with the host.
*/
return (
const control = (
<Input
{...domProps}
type="date"
// An API that hands back `2026-06-17T00:00:00.000Z` for a `date` field
// would leave this control empty too (objectui#3127). The written-back
// shape is unchanged: the control's own plain `YYYY-MM-DD`.
value={toDateInputValue(value)}
value={impossible ? '' : toDateInputValue(value)}
onChange={(e) => onChange(e.target.value)}
onClick={(e) => {
openNativePicker(e.currentTarget);
domProps.onClick?.(e);
}}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
aria-invalid={!!error || impossible}
aria-describedby={describedBy}
/>
);
if (!impossible) return control;
return (
<div className="space-y-1">
{control}
<p id={noticeId} className="text-xs text-destructive" data-testid="date-impossible-day">
{t('fields.date.impossibleDay', { value: String(value) })}
</p>
</div>
);
}
23 changes: 13 additions & 10 deletions packages/fields/src/widgets/GridField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -643,8 +643,9 @@ export function GridField({
// regional default → active UI language → 'en' (objectui#4468). Read here
// and passed down, since `displayText` is a pure helper.
const displayLocale = useDisplayLocale();
// The editable `datetime` cell's notice for a stored nonexistent day
// (objectui#10474) — the one sentence `DateTimeField` shows for it too.
// The editable `date` / `datetime` cell's notice for a stored nonexistent
// day (objectui#10474, objectui#10567) — the sentences `DateField` and
// `DateTimeField` show for it too.
const { t } = useFieldTranslation();
const cellIdBase = React.useId();
// The tenant default currency (ADR-0053) — the resolver's last step, and in
Expand Down Expand Up @@ -1130,12 +1131,14 @@ export function GridField({
// The editable currency cell shows the SAME symbol its display face does
// (objectui#10355) — `currencyAdornment`, never a default `¥`.
const adornment = c.type === 'currency' ? currencyAdornment(c, currency, displayLocale) : '';
// A `datetime` cell holding a value written on a day that does not exist
// (objectui#10474): its control can paint that only blank, so the stored
// string is named under it and the control is marked invalid. Nothing is
// written until the user picks a new value — the same face `DateTimeField`
// gives it.
const impossibleDay = c.type === 'datetime' && isImpossibleStoredDay(val);
// A `date` or `datetime` cell holding a value written on a day that does
// not exist (objectui#10474, objectui#10567): its control can paint that
// only blank — the `date` control sanitises the verbatim day
// `toDateInputValue` keeps, so it is handed `""` below, as the `datetime`
// adapter already does — so the stored string is named under it and the
// control is marked invalid. Nothing is written until the user picks a new
// value — the same face `DateField` / `DateTimeField` give it.
const impossibleDay = (c.type === 'date' || c.type === 'datetime') && isImpossibleStoredDay(val);
const noticeId = impossibleDay ? `${cellIdBase}-impossible-${rowIdx}-${colIdx}` : undefined;
return (
<div className="relative">
Expand Down Expand Up @@ -1178,7 +1181,7 @@ export function GridField({
// time → `HH:mm[:ss]` is already the stored shape, both ways.
value={
c.type === 'date'
? toDateInputValue(val)
? (impossibleDay ? '' : toDateInputValue(val))
: c.type === 'datetime'
? toDateTimeInputValue(val)
: val != null
Expand All @@ -1190,7 +1193,7 @@ export function GridField({
/>
{impossibleDay && (
<p id={noticeId} className="px-2 pb-1 text-xs text-destructive" data-testid={`line-items-impossible-day-${rowIdx}-${c.name}`}>
{t('fields.dateTime.impossibleDay', { value: String(val) })}
{t(c.type === 'date' ? 'fields.date.impossibleDay' : 'fields.dateTime.impossibleDay', { value: String(val) })}
</p>
)}
</div>
Expand Down
5 changes: 5 additions & 0 deletions packages/fields/src/widgets/useFieldTranslation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ const FIELD_DEFAULTS: Record<string, string> = {
// rendering are unchanged.
'fields.number.badInput':
'Not saved: the text in this box is not a number. Enter a plain decimal (example: {{example}}).',
// objectui#10567 — `DateField` and the sub-grid's `date` cell name a stored
// value written on a nonexistent day beside their control, which can only
// paint it blank. Same value as the `en` pack.
'fields.date.impossibleDay':
'The stored value "{{value}}" is not a real date. Pick a date to replace it.',
// objectui#10474 — `DateTimeField` and the sub-grid's `datetime` cell name a
// stored value written on a nonexistent day beside their control, which can
// only paint it blank. Same value as the `en` pack.
Expand Down
9 changes: 6 additions & 3 deletions packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight
// one interpolated span, runtime data.
// 64 once objectui#10474 added `fields.dateTime.impossibleDay`, which quotes
// the stored value („{{value}}“) — one interpolated span, runtime data.
expect(okSpans, 'correctly paired spans').toBe(64);
// 65 once objectui#10567 added its date-only sibling `fields.date.impossibleDay`,
// which quotes the same stored value — one more interpolated span.
expect(okSpans, 'correctly paired spans').toBe(65);
});

it('keeps the count identity that replaces the card’s count(„) === count(“)', () => {
Expand Down Expand Up @@ -320,8 +322,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight
// three more matched pairs across its two arity siblings, and `rdq` still 0.
// 63 / 63 / 0 after objectui#4191 added `actions.notAvailableHere`, one
// more matched pair. 64 / 64 / 0 after objectui#10474 added
// `fields.dateTime.impossibleDay`, one more matched pair.
expect({ open, close, rdq }).toEqual({ open: 64, close: 64, rdq: 0 });
// `fields.dateTime.impossibleDay`, one more matched pair. 65 / 65 / 0 after
// objectui#10567 added `fields.date.impossibleDay`, one more matched pair.
expect({ open, close, rdq }).toEqual({ open: 65, close: 65, 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);
Expand Down
Loading
Loading