@object-ui/fields 17.3.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +941 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +7 -1
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +19 -1
  6. package/dist/index.cjs +4 -4
  7. package/dist/index.css +633 -0
  8. package/dist/index.d.ts +48 -12
  9. package/dist/index.js +5026 -4933
  10. package/dist/widgets/AddressField.d.ts +3 -11
  11. package/dist/widgets/FullscreenFieldEditor.d.ts +42 -152
  12. package/dist/widgets/GridField.d.ts +12 -2
  13. package/dist/widgets/OptionsEmptyState.d.ts +15 -1
  14. package/dist/widgets/RecordPickerDialog.d.ts +32 -6
  15. package/dist/widgets/SelectField.d.ts +25 -5
  16. package/dist/widgets/address-format.d.ts +68 -0
  17. package/dist/widgets/file-size-guard.d.ts +41 -0
  18. package/dist/widgets/nativeDateValue.d.ts +37 -0
  19. package/dist/widgets/toDomProps.d.ts +30 -10
  20. package/dist/widgets/toHostGroupProps.d.ts +91 -0
  21. package/dist/widgets/useRecordQuery.d.ts +7 -1
  22. package/package.json +17 -13
  23. package/src/FieldEditWidget.tsx +17 -1
  24. package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
  25. package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
  26. package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
  27. package/src/__tests__/DateCellRenderer.test.tsx +9 -0
  28. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
  29. package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
  30. package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
  31. package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
  32. package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
  33. package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
  34. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
  35. package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
  36. package/src/__tests__/date-locale-channel.test.tsx +305 -0
  37. package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
  38. package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
  39. package/src/__tests__/group-labelling-declaration.test.ts +148 -0
  40. package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
  41. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
  42. package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
  43. package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
  44. package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
  45. package/src/__tests__/widget-dom-leak-e2e.test.tsx +29 -270
  46. package/src/complex-widgets.test.tsx +22 -6
  47. package/src/currency.ts +130 -0
  48. package/src/datetime-widgets.test.tsx +94 -2
  49. package/src/field-type-alias.multiple.test.ts +81 -0
  50. package/src/field-type-alias.ts +58 -2
  51. package/src/index.css +44 -0
  52. package/src/index.tsx +489 -247
  53. package/src/standard-widgets.test.tsx +8 -1
  54. package/src/widgets/AddressField.postalCode.test.tsx +257 -0
  55. package/src/widgets/AddressField.tsx +79 -29
  56. package/src/widgets/AddressField.uniqueIds.test.tsx +2 -1
  57. package/src/widgets/BooleanField.tsx +41 -7
  58. package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
  59. package/src/widgets/CheckboxesField.tsx +35 -2
  60. package/src/widgets/CurrencyField.tsx +55 -15
  61. package/src/widgets/DateField.tsx +11 -2
  62. package/src/widgets/DateTimeField.tsx +12 -3
  63. package/src/widgets/FileField.tsx +27 -9
  64. package/src/widgets/FormulaField.tsx +6 -1
  65. package/src/widgets/FullscreenFieldEditor.tsx +47 -258
  66. package/src/widgets/GeolocationField.tsx +29 -3
  67. package/src/widgets/GridField.test.tsx +226 -0
  68. package/src/widgets/GridField.tsx +114 -10
  69. package/src/widgets/ImageField.maxSize.test.tsx +154 -0
  70. package/src/widgets/ImageField.tsx +54 -4
  71. package/src/widgets/LookupField.hydration.test.tsx +1 -1
  72. package/src/widgets/LookupField.tsx +8 -4
  73. package/src/widgets/MultiSelectField.tsx +56 -2
  74. package/src/widgets/OptionsEmptyState.tsx +16 -0
  75. package/src/widgets/RadioField.tsx +35 -3
  76. package/src/widgets/RatingField.tsx +15 -2
  77. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
  78. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
  79. package/src/widgets/RecordPickerDialog.tsx +64 -15
  80. package/src/widgets/RichTextField.tsx +1 -1
  81. package/src/widgets/SelectField.tsx +36 -5
  82. package/src/widgets/UserField.tsx +3 -2
  83. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
  84. package/src/widgets/address-format.ts +120 -0
  85. package/src/widgets/file-size-guard.ts +55 -0
  86. package/src/widgets/nativeDateValue.test.ts +115 -0
  87. package/src/widgets/nativeDateValue.ts +112 -0
  88. package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
  89. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
  90. package/src/widgets/toDomProps.ts +48 -19
  91. package/src/widgets/toHostGroupProps.ts +102 -0
  92. package/src/widgets/useFieldTranslation.ts +15 -33
  93. package/src/widgets/useRecordQuery.ts +30 -3
  94. package/dist/FieldEditWidget.test.d.ts +0 -8
  95. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  96. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  97. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  98. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  99. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  100. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  101. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  102. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  103. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  104. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  105. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  106. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  107. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  108. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  109. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  110. package/dist/coerce-safe-value.test.d.ts +0 -1
  111. package/dist/complex-widgets.test.d.ts +0 -1
  112. package/dist/datetime-widgets.test.d.ts +0 -1
  113. package/dist/field-type-coverage.test.d.ts +0 -1
  114. package/dist/standard-widgets.test.d.ts +0 -0
  115. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  116. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  117. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  118. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  119. package/dist/widgets/GridField.test.d.ts +0 -1
  120. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  121. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  122. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
  123. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  124. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  125. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  126. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  127. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  128. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  129. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  130. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  131. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  132. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
  133. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
  134. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  135. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  136. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  137. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  138. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  139. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  140. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  141. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  142. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  143. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  144. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  145. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  146. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  147. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  148. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  149. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  150. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  151. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  152. package/dist/widgets/file-value.test.d.ts +0 -1
  153. package/dist/widgets/personDisplay.test.d.ts +0 -8
  154. package/dist/widgets/recentLookups.test.d.ts +0 -1
  155. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  156. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
@@ -3,6 +3,7 @@ import { Input, Button, Label, EmptyValue } from '@object-ui/components';
3
3
  import { MapPin, Crosshair } from 'lucide-react';
4
4
  import { FieldWidgetComponentProps } from './types';
5
5
  import { toDomProps } from './toDomProps';
6
+ import { toHostGroupProps } from './toHostGroupProps';
6
7
 
7
8
  /**
8
9
  * Geolocation data structure
@@ -23,7 +24,24 @@ export function GeolocationField({ value, onChange, field, readonly, error, ...p
23
24
  // DOM pass-through (objectui#3318): the whitelist spread goes onto the FIRST
24
25
  // sub-input (latitude); the composite's validation state goes onto BOTH
25
26
  // focusable sub-inputs via `aria-invalid={!!error}`.
26
- const domProps = toDomProps(props);
27
+ //
28
+ // `id` and `aria-labelledby` are held back and land on the group CONTAINER
29
+ // instead (objectui#3961) — they address the whole field, not the latitude box.
30
+ // The host `id` never reached the DOM before: it was overwritten one line later
31
+ // by `id={subId('latitude')}` (objectui#3343), leaving the form's group label
32
+ // pointing at nothing. And `aria-labelledby` on the latitude input would
33
+ // OVERRIDE its own "Latitude" label with the field name. `aria-describedby` and
34
+ // the rest stay on the first sub-input, where focus can reach them.
35
+ const {
36
+ id: _hostId,
37
+ 'aria-labelledby': _hostLabelledBy,
38
+ ...domProps
39
+ } = toDomProps(props);
40
+ // The pair held back above, in the one spelling every group-labelled widget
41
+ // uses for it — and computed HERE, before the readonly early return below,
42
+ // because that return used to drop both keys on the floor: a published label
43
+ // id with no consumer in the document (objectui#3990). See `toHostGroupProps`.
44
+ const hostGroupProps = toHostGroupProps(props);
27
45
  // Sub-input ids (objectui#3343): `useId()` prefix + sub-field name — the
28
46
  // `groupId` paradigm of RadioField / CheckboxesField. Hardcoded literals
29
47
  // ("latitude" / "longitude") collide as soon as a form renders two
@@ -79,9 +97,13 @@ export function GeolocationField({ value, onChange, field, readonly, error, ...p
79
97
  };
80
98
 
81
99
  if (readonly) {
100
+ // Readonly the composite collapses to the formatted coordinates (plus the
101
+ // "View on map" link), so THAT row is the surface the host label names
102
+ // (objectui#3990). The `EmptyValue` placeholder sits inside it, so it needs
103
+ // nothing of its own.
82
104
  const formatted = formatLocation(location);
83
105
  return (
84
- <div className="flex items-center gap-2">
106
+ <div {...hostGroupProps} className="flex items-center gap-2">
85
107
  <MapPin className="w-4 h-4 text-muted-foreground" />
86
108
  {formatted ? <span className="text-sm">{formatted}</span> : <EmptyValue />}
87
109
  {location.latitude && location.longitude && (
@@ -100,7 +122,11 @@ export function GeolocationField({ value, onChange, field, readonly, error, ...p
100
122
  }
101
123
 
102
124
  return (
103
- <div className="space-y-3">
125
+ // `role="group"` only when a host actually named this container
126
+ // (objectui#3961); standalone rendering stays exactly as it was. That
127
+ // condition now lives in `toHostGroupProps` so this container and the
128
+ // readonly row above cannot answer differently (objectui#3990).
129
+ <div className="space-y-3" {...hostGroupProps}>
104
130
  <div className="flex items-center gap-2">
105
131
  <Button
106
132
  type="button"
@@ -113,6 +113,232 @@ describe('GridField / LineItemsField — editable line items', () => {
113
113
  expect(onChange).toHaveBeenCalledWith([{ description: 'Taxi', amount: 42.5 }]);
114
114
  });
115
115
 
116
+ /**
117
+ * objectui#3566 — the sub-grid face of #3127. `<input type="date">` accepts
118
+ * exactly `YYYY-MM-DD` and rejects every other shape SILENTLY: the attribute
119
+ * still lands in the DOM, so the value looks present in the markup, while
120
+ * `.value` reads back `''` and the cell paints its empty placeholder. Since
121
+ * the API hands back `2026-06-17T00:00:00.000Z` for a `date` field, every
122
+ * date cell in an inline child grid rendered blank on a row that has a value.
123
+ * Asserting `.value` (never `getAttribute('value')`) is the only way to see
124
+ * this bug at all.
125
+ */
126
+ describe('date columns echo the stored value (#3566)', () => {
127
+ const dateField = {
128
+ columns: [
129
+ { field: 'description', label: 'Description', type: 'text' as const },
130
+ { field: 'incurred_on', label: 'Incurred On', type: 'date' as const },
131
+ ],
132
+ } as any;
133
+
134
+ // Both spellings the API may hand back for a `date` field. The bare form
135
+ // must survive VERBATIM — re-parsing it as UTC midnight and reading local
136
+ // calendar components back out would move it to the 16th anywhere west of
137
+ // Greenwich, which is the off-by-one this fix must not introduce.
138
+ it.each(['2026-06-17', '2026-06-17T00:00:00.000Z'])(
139
+ 'shows the stored calendar day for %s instead of an empty cell',
140
+ (stored) => {
141
+ render(
142
+ <GridField value={[{ description: 'Taxi', incurred_on: stored }]} onChange={() => {}} field={dateField} />,
143
+ );
144
+ const cell = screen.getAllByLabelText('Incurred On')[0] as HTMLInputElement;
145
+ expect(cell.type).toBe('date');
146
+ expect(cell.value).toBe('2026-06-17');
147
+ },
148
+ );
149
+
150
+ it('leaves an empty date cell empty', () => {
151
+ render(<GridField value={[{ description: 'Taxi', incurred_on: null }]} onChange={() => {}} field={dateField} />);
152
+ expect((screen.getAllByLabelText('Incurred On')[0] as HTMLInputElement).value).toBe('');
153
+ });
154
+
155
+ it('writes back the control\'s own YYYY-MM-DD, unchanged by the read fix', () => {
156
+ // No paired conversion on the write side: `onChange` hands over exactly
157
+ // what `<input type="date">` produces, matching DateField's contract. A
158
+ // conversion here would put read and write on different bases.
159
+ const onChange = vi.fn();
160
+ render(
161
+ <GridField
162
+ value={[{ description: 'Taxi', incurred_on: '2026-06-17T00:00:00.000Z' }]}
163
+ onChange={onChange}
164
+ field={dateField}
165
+ />,
166
+ );
167
+ fireEvent.change(screen.getAllByLabelText('Incurred On')[0], { target: { value: '2026-06-18' } });
168
+ expect(onChange).toHaveBeenCalledWith([{ description: 'Taxi', incurred_on: '2026-06-18' }]);
169
+ });
170
+ });
171
+
172
+ /**
173
+ * objectui#3569 — the type collapse behind #3566's symptom. `datetime` and
174
+ * `time` used to be mapped onto the ONE `date` column type, so a datetime
175
+ * cell rendered `<input type="date">`. That is not merely an under-render:
176
+ * the control emits a bare `YYYY-MM-DD`, so a user who only re-picked the
177
+ * DAY silently wrote the record's time component out of existence. The
178
+ * falsifiable claim of these tests is therefore the TIME COMPONENT, not the
179
+ * control's markup.
180
+ *
181
+ * Local-wall-clock expectations are computed here from `Date` rather than
182
+ * hard-coded, so the suite asserts the same thing in every timezone (and the
183
+ * day-shift arithmetic is done in LOCAL parts, so it is DST-safe too).
184
+ */
185
+ describe('datetime / time columns get their own control (#3569)', () => {
186
+ const pad2 = (n: number) => String(n).padStart(2, '0');
187
+ /** The local wall clock an `<input type="datetime-local">` must show for `iso`. */
188
+ const localInputValue = (iso: string | Date) => {
189
+ const d = iso instanceof Date ? iso : new Date(iso);
190
+ return (
191
+ `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` +
192
+ `T${pad2(d.getHours())}:${pad2(d.getMinutes())}`
193
+ );
194
+ };
195
+
196
+ const temporalField = {
197
+ columns: [
198
+ { field: 'merchant', label: 'Merchant', type: 'text' as const },
199
+ { field: 'incurred_on', label: 'Incurred On', type: 'date' as const },
200
+ { field: 'incurred_at', label: 'Incurred At', type: 'datetime' as const },
201
+ { field: 'started_at', label: 'Started At', type: 'time' as const },
202
+ ],
203
+ } as any;
204
+
205
+ const STORED_ISO = '2026-06-17T14:30:00.000Z';
206
+
207
+ it('renders a datetime column as datetime-local, echoing the stored instant', () => {
208
+ render(
209
+ <GridField
210
+ value={[{ merchant: 'Chipotle', incurred_at: STORED_ISO }]}
211
+ onChange={() => {}}
212
+ field={temporalField}
213
+ />,
214
+ );
215
+ const cell = screen.getAllByLabelText('Incurred At')[0] as HTMLInputElement;
216
+ expect(cell.type).toBe('datetime-local');
217
+ expect(cell.value).toBe(localInputValue(STORED_ISO));
218
+ });
219
+
220
+ it('does NOT collapse the datetime column onto the date control', () => {
221
+ render(
222
+ <GridField
223
+ value={[{ merchant: 'Chipotle', incurred_on: '2026-06-17', incurred_at: STORED_ISO }]}
224
+ onChange={() => {}}
225
+ field={temporalField}
226
+ />,
227
+ );
228
+ // The sibling `date` column keeps its own control — the two are distinct
229
+ // now, which is exactly what was lost before.
230
+ expect((screen.getAllByLabelText('Incurred On')[0] as HTMLInputElement).type).toBe('date');
231
+ expect((screen.getAllByLabelText('Incurred At')[0] as HTMLInputElement).type).toBe('datetime-local');
232
+ });
233
+
234
+ /** THE regression: editing only the day must not destroy the time. */
235
+ it('keeps the time component when only the day is edited', () => {
236
+ const onChange = vi.fn();
237
+ render(
238
+ <GridField
239
+ value={[{ merchant: 'Chipotle', incurred_at: STORED_ISO }]}
240
+ onChange={onChange}
241
+ field={temporalField}
242
+ />,
243
+ );
244
+ const cell = screen.getAllByLabelText('Incurred At')[0] as HTMLInputElement;
245
+
246
+ // What a user does to correct a date: move the day forward one, leave
247
+ // the wall clock alone.
248
+ const before = new Date(STORED_ISO);
249
+ const nextDay = new Date(before);
250
+ nextDay.setDate(nextDay.getDate() + 1);
251
+ fireEvent.change(cell, { target: { value: localInputValue(nextDay) } });
252
+
253
+ const emitted = onChange.mock.calls[0][0][0].incurred_at;
254
+ const after = new Date(emitted);
255
+ expect(after.getHours()).toBe(before.getHours());
256
+ expect(after.getMinutes()).toBe(before.getMinutes());
257
+ // Round-tripped as ISO-8601 (`fromDateTimeInputValue`), NOT the control's
258
+ // naive zone-less string — read and write share one basis.
259
+ expect(emitted).toBe(nextDay.toISOString());
260
+ });
261
+
262
+ it('leaves an empty datetime cell empty', () => {
263
+ render(
264
+ <GridField value={[{ merchant: 'Chipotle', incurred_at: null }]} onChange={() => {}} field={temporalField} />,
265
+ );
266
+ expect((screen.getAllByLabelText('Incurred At')[0] as HTMLInputElement).value).toBe('');
267
+ });
268
+
269
+ it('renders a time column as a time control and round-trips HH:mm verbatim', () => {
270
+ const onChange = vi.fn();
271
+ render(
272
+ <GridField
273
+ value={[{ merchant: 'Chipotle', started_at: '14:30' }]}
274
+ onChange={onChange}
275
+ field={temporalField}
276
+ />,
277
+ );
278
+ const cell = screen.getAllByLabelText('Started At')[0] as HTMLInputElement;
279
+ expect(cell.type).toBe('time');
280
+ expect(cell.value).toBe('14:30');
281
+ // A `time` value is a zone-less wall clock — stored exactly as typed, the
282
+ // same contract `TimeField` follows. No conversion in either direction.
283
+ fireEvent.change(cell, { target: { value: '09:15' } });
284
+ expect(onChange).toHaveBeenCalledWith([{ merchant: 'Chipotle', started_at: '09:15' }]);
285
+ });
286
+
287
+ /**
288
+ * The read-only faces the issue calls out: `displayText()` and the
289
+ * read-only table both fell through to `String(value)`, printing the raw
290
+ * `2026-06-17T00:00:00.000Z` on screen. Neither could be fixed before the
291
+ * collapse was undone — with one column type the renderer had no way to
292
+ * know whether to show a day or a day plus a time.
293
+ */
294
+ describe('read-only display formats each temporal type as itself', () => {
295
+ const row = { merchant: 'Chipotle', incurred_on: '2026-06-17T00:00:00.000Z', incurred_at: STORED_ISO, started_at: '14:30' };
296
+ // Spelled `'en'` rather than bare `toLocaleDateString()` (objectui#4468):
297
+ // the bare call reads the MACHINE's locale, so these pins agreed with the
298
+ // widget only by the accident of a CI runner set to en-US — and they
299
+ // would have kept agreeing with it after the widget started following the
300
+ // session locale, which is the bug this suite has to be able to see.
301
+ // `'en'` is what `useDisplayLocale()` resolves to with no provider.
302
+ const expectedDay = new Date(2026, 5, 17).toLocaleDateString('en');
303
+ const dt = new Date(STORED_ISO);
304
+ const expectedInstant = `${dt.toLocaleDateString('en')} ${dt.toLocaleTimeString('en')}`;
305
+
306
+ it('formats date / datetime / time in the read-only table', () => {
307
+ render(<GridField value={[row]} onChange={() => {}} field={temporalField} readonly />);
308
+ const table = screen.getByTestId('line-items-readonly');
309
+ expect(table.textContent).toContain(expectedDay);
310
+ expect(table.textContent).toContain(expectedInstant);
311
+ expect(table.textContent).toContain('14:30');
312
+ // The raw stored ISO must never reach the screen.
313
+ expect(table.textContent).not.toContain('2026-06-17T00:00:00.000Z');
314
+ expect(table.textContent).not.toContain(STORED_ISO);
315
+ });
316
+
317
+ it('formats them the same way in list display mode', () => {
318
+ const { container } = render(
319
+ <GridField value={[row]} onChange={() => {}} field={temporalField} displayMode="list" onRowExpand={() => {}} />,
320
+ );
321
+ expect(container.textContent).toContain(expectedDay);
322
+ expect(container.textContent).toContain(expectedInstant);
323
+ expect(container.textContent).not.toContain(STORED_ISO);
324
+ });
325
+
326
+ it('shows a value it cannot parse rather than "Invalid Date"', () => {
327
+ render(
328
+ <GridField value={[{ merchant: 'X', incurred_at: 'not-a-date' }]} onChange={() => {}} field={temporalField} readonly />,
329
+ );
330
+ expect(screen.getByTestId('line-items-readonly').textContent).toContain('not-a-date');
331
+ });
332
+
333
+ // The `zh` half of this — the sub-grid following the SESSION locale
334
+ // (objectui#4468) — lives in `__tests__/date-locale-channel.test.tsx`,
335
+ // not here. Mounting an `I18nProvider` anywhere in THIS file changes what
336
+ // the provider-less renders further down resolve, and the file-columns
337
+ // chip test reads a translated `aria-label`; keeping the provider in its
338
+ // own file keeps that coupling out of the way.
339
+ });
340
+ });
341
+
116
342
  describe('trailing ghost row (start-with-one + auto-append)', () => {
117
343
  it('renders a trailing empty row so an empty grid still has one input line', () => {
118
344
  render(<GridField value={[]} onChange={() => {}} field={field} />);
@@ -17,8 +17,10 @@ import {
17
17
  } from '@object-ui/components';
18
18
  import { Plus, Trash2, SlidersHorizontal, Maximize2, Copy, GripVertical } from 'lucide-react';
19
19
  import { resolveFieldRuleState } from '@object-ui/core';
20
+ import { useDisplayLocale } from '@object-ui/i18n';
20
21
  import { LookupField } from './LookupField';
21
22
  import { FileCell } from './FileField';
23
+ import { toDateInputValue, toDateTimeInputValue, fromDateTimeInputValue } from './nativeDateValue';
22
24
 
23
25
  /**
24
26
  * GridField / LineItemsField — editable child-grid ("line items") widget.
@@ -31,7 +33,8 @@ import { FileCell } from './FileField';
31
33
  *
32
34
  * Column config (a subset of `GridColumnDefinition`):
33
35
  * { field, label?, type?, options?, width?, required?, prefix?, step? }
34
- * type ∈ 'text' | 'number' | 'currency' | 'date' | 'select' | 'lookup' | 'file'
36
+ * type ∈ 'text' | 'number' | 'currency' | 'date' | 'datetime' | 'time'
37
+ * | 'select' | 'lookup' | 'file'
35
38
  *
36
39
  * Field-level config (from `GridFieldMetadata`):
37
40
  * columns, min_rows, max_rows, allow_add, allow_delete, total_field
@@ -40,7 +43,16 @@ import { FileCell } from './FileField';
40
43
  export interface GridColumn {
41
44
  field: string;
42
45
  label?: string;
43
- type?: 'text' | 'number' | 'currency' | 'date' | 'select' | 'lookup' | 'file';
46
+ /**
47
+ * Cell control + read/write adapter for the column.
48
+ *
49
+ * `date` / `datetime` / `time` are three DISTINCT controls, not one
50
+ * (objectui#3569). Collapsing `datetime` onto the `date` control did not
51
+ * merely under-render it — `<input type="date">` hands back a bare
52
+ * `YYYY-MM-DD` on change, so touching the day of a `datetime` cell silently
53
+ * DELETED its time component from the record.
54
+ */
55
+ type?: 'text' | 'number' | 'currency' | 'date' | 'datetime' | 'time' | 'select' | 'lookup' | 'file';
44
56
  options?: Array<{ label: string; value: string }>;
45
57
  width?: number;
46
58
  required?: boolean;
@@ -110,6 +122,10 @@ const MIN_WIDTH_BY_TYPE: Record<string, number> = {
110
122
  number: 104,
111
123
  currency: 116,
112
124
  date: 150,
125
+ // `<input type="datetime-local">` renders a day AND a wall clock, so it needs
126
+ // materially more room than a date cell before the browser starts eliding.
127
+ datetime: 200,
128
+ time: 116,
113
129
  file: 168,
114
130
  };
115
131
  const minWidthFor = (c: GridColumn): number => c.width ?? MIN_WIDTH_BY_TYPE[c.type ?? 'text'] ?? 132;
@@ -254,10 +270,52 @@ export function computeRow(columns: GridColumn[], row: Row): Row {
254
270
  return next;
255
271
  }
256
272
 
273
+ /** True for the three temporal column types, which all need a formatter on the
274
+ * read-only surfaces rather than the raw stored string (objectui#3569). */
275
+ const isTemporal = (t?: string) => t === 'date' || t === 'datetime' || t === 'time';
276
+
277
+ /**
278
+ * Read-only display text for a temporal cell (objectui#3569).
279
+ *
280
+ * The stored shapes differ per type and so must the rendering — which is only
281
+ * decidable now that `datetime`/`time` are no longer collapsed onto `date`:
282
+ *
283
+ * - `date` — a calendar day. Formatted from its VERBATIM `YYYY-MM-DD` parts via
284
+ * a local `Date`, never by parsing the stored string: `new Date('2026-06-17')`
285
+ * is UTC midnight, so reading local calendar components back out of it moves
286
+ * the day to the 16th everywhere west of Greenwich.
287
+ * - `datetime` — an instant. Rendered as local day + local time, the same basis
288
+ * `toDateTimeInputValue` uses for the editor, so the two never disagree (and
289
+ * matching `DateTimeField`'s own read-only rendering).
290
+ * - `time` — a zone-less wall clock (`HH:mm[:ss]`); it is already display-ready.
291
+ *
292
+ * An unparseable value falls through to its raw string rather than rendering
293
+ * "Invalid Date" — showing the user what is actually stored beats hiding it.
294
+ *
295
+ * `locale` is threaded rather than left to `Intl`'s default (objectui#4468):
296
+ * the default is the MACHINE's locale, which has nothing to do with the
297
+ * session, so a Chinese form rendered `6/17/2026` in its line items.
298
+ */
299
+ function temporalText(type: string | undefined, value: any, locale: string): string {
300
+ const raw = String(value);
301
+ if (type === 'time') return raw;
302
+ if (type === 'date') {
303
+ const ymd = toDateInputValue(value);
304
+ if (!ymd) return raw;
305
+ const [y, m, d] = ymd.split('-').map(Number);
306
+ return new Date(y, m - 1, d).toLocaleDateString(locale);
307
+ }
308
+ const dt = value instanceof Date ? value : new Date(raw);
309
+ if (Number.isNaN(dt.getTime())) return raw;
310
+ return `${dt.toLocaleDateString(locale)} ${dt.toLocaleTimeString(locale)}`;
311
+ }
312
+
257
313
  /** Read-only display text for a cell in list mode (select → option label,
258
- * currency/number → formatted, empty → em dash). Lookups render separately. */
259
- function displayText(c: GridColumn, value: any): string {
314
+ * currency/number → formatted, date/datetime/time → localized, empty → em
315
+ * dash). Lookups render separately. */
316
+ function displayText(c: GridColumn, value: any, locale: string): string {
260
317
  if (value === null || value === undefined || value === '') return '—';
318
+ if (isTemporal(c.type)) return temporalText(c.type, value, locale);
261
319
  if (c.type === 'file') {
262
320
  const files = Array.isArray(value) ? value : [value];
263
321
  if (files.length === 0) return '—';
@@ -283,6 +341,14 @@ function coerce(type: string | undefined, raw: string): any {
283
341
  const n = Number(raw);
284
342
  return Number.isNaN(n) ? raw : n;
285
343
  }
344
+ // `<input type="datetime-local">` hands back a ZONE-LESS local wall clock;
345
+ // storing that verbatim would put the write on a different basis from the
346
+ // read (`toDateTimeInputValue`), so a user in UTC+8 who picked 08:33 would
347
+ // store 08:33Z and see 16:33 back. The pair must be used as a pair — this is
348
+ // the same contract `DateTimeField` follows (objectui#3127 / #3569).
349
+ // `date` and `time` need no conversion: their controls already emit exactly
350
+ // the stored shape (`YYYY-MM-DD`, `HH:mm`).
351
+ if (type === 'datetime') return fromDateTimeInputValue(raw);
286
352
  return raw;
287
353
  }
288
354
 
@@ -328,6 +394,10 @@ export function GridField({
328
394
  const allColumns: GridColumn[] = cfg.columns || [];
329
395
  const rows: Row[] = Array.isArray(value) ? value : [];
330
396
  const contextRecord = props.contextRecord;
397
+ // The one locale channel every field renderer resolves through: tenant
398
+ // regional default → active UI language → 'en' (objectui#4468). Read here
399
+ // and passed down, since `displayText` is a pure helper.
400
+ const displayLocale = useDisplayLocale();
331
401
 
332
402
  // Per-cell CEL rule state (B2 in grids). A column with no readonlyWhen/
333
403
  // requiredWhen resolves to its static flags (cheap fast-path — no engine
@@ -626,8 +696,13 @@ export function GridField({
626
696
  readonly
627
697
  field={{ reference: c.reference, display_field: c.displayField, id_field: c.idField } as any}
628
698
  />
629
- ) : c.type === 'file' ? (
630
- displayText(c, row[c.field])
699
+ ) : c.type === 'file' || isTemporal(c.type) ? (
700
+ // A temporal column printed with `String(value)` puts
701
+ // the raw stored ISO on screen — `2026-06-17T00:00:00.000Z`
702
+ // for a date, and for a datetime it would ALSO have been
703
+ // wrong to render as a bare day (objectui#3569). Now that
704
+ // the three types are distinct, each formats as itself.
705
+ displayText(c, row[c.field], displayLocale)
631
706
  ) : row[c.field] != null && row[c.field] !== '' ? (
632
707
  String(row[c.field])
633
708
  ) : (
@@ -687,7 +762,7 @@ export function GridField({
687
762
  }
688
763
  return (
689
764
  <span className={cn('px-2 text-sm text-foreground', isNumeric(c.type) && 'tabular-nums', (val == null || val === '') && 'text-muted-foreground')}>
690
- {displayText(c, val)}
765
+ {displayText(c, val, displayLocale)}
691
766
  </span>
692
767
  );
693
768
  }
@@ -699,7 +774,7 @@ export function GridField({
699
774
  title="Computed"
700
775
  data-computed={c.field}
701
776
  >
702
- {displayText(c, val)}
777
+ {displayText(c, val, displayLocale)}
703
778
  </span>
704
779
  );
705
780
  }
@@ -757,10 +832,39 @@ export function GridField({
757
832
  c.type === 'currency' && 'pl-6',
758
833
  isNumeric(c.type) && 'text-right tabular-nums',
759
834
  )}
760
- type={c.type === 'date' ? 'date' : isNumeric(c.type) ? 'number' : 'text'}
835
+ type={
836
+ c.type === 'date'
837
+ ? 'date'
838
+ : c.type === 'datetime'
839
+ ? 'datetime-local'
840
+ : c.type === 'time'
841
+ ? 'time'
842
+ : isNumeric(c.type)
843
+ ? 'number'
844
+ : 'text'
845
+ }
761
846
  step={isNumeric(c.type) ? c.step ?? 'any' : undefined}
762
847
  aria-label={c.label || c.field}
763
- value={val != null ? String(val) : ''}
848
+ // A temporal cell holding the API's ISO shape (`2026-06-17T14:30:00.000Z`)
849
+ // is SILENTLY rejected by the native control — the attribute lands in
850
+ // the DOM but `input.value` reads back `''` and the cell paints empty
851
+ // (objectui#3566, the sub-grid face of #3127). So each type reads
852
+ // through its own adapter:
853
+ // date → `YYYY-MM-DD`; the control writes that shape back
854
+ // verbatim, so no paired conversion on `onChange`.
855
+ // datetime → `YYYY-MM-DDTHH:mm` local; PAIRED with
856
+ // `fromDateTimeInputValue` in `coerce` so read and write
857
+ // share one basis (objectui#3569).
858
+ // time → `HH:mm[:ss]` is already the stored shape, both ways.
859
+ value={
860
+ c.type === 'date'
861
+ ? toDateInputValue(val)
862
+ : c.type === 'datetime'
863
+ ? toDateTimeInputValue(val)
864
+ : val != null
865
+ ? String(val)
866
+ : ''
867
+ }
764
868
  onChange={(e) => setCell(rowIdx, c, e.target.value)}
765
869
  disabled={locked}
766
870
  />
@@ -0,0 +1,154 @@
1
+ /**
2
+ * ImageField — a declared `maxSize` is enforced BEFORE any upload starts
3
+ * (objectui#4141).
4
+ *
5
+ * The defect this pins: `paramToField` copies `maxSize` onto the field config
6
+ * for every param type, so the image widget RECEIVES the constraint — it just
7
+ * never read it. An oversized pick therefore fired the whole
8
+ * `presigned → PUT → complete` chain and rendered a preview, while the sibling
9
+ * file param rejected the same pick with zero uploads.
10
+ *
11
+ * So the assertion that matters is **zero upload attempts**, not "no preview":
12
+ * a widget that uploads the bytes and then hides the thumbnail has still
13
+ * stored an over-limit file. The spy sits on the `UploadProvider` adapter —
14
+ * the layer that performs the presigned request — so a call there IS the
15
+ * network attempt.
16
+ *
17
+ * Two upload entry points exist on this widget and both are covered here:
18
+ * 1. the native picker (`handleFileChange`, single and `multiple`);
19
+ * 2. the crop dialog's confirm (`handleCropConfirm`), which uploads a
20
+ * freshly re-encoded PNG blob — canvas re-encoding can push a crop of an
21
+ * in-limit JPEG *over* the limit, so it is a real second door.
22
+ */
23
+ import { describe, it, expect, vi } from 'vitest';
24
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
25
+ import { UploadProvider, type UploadAdapter } from '@object-ui/providers';
26
+ import { ImageField } from './ImageField';
27
+ // Imported at module scope (not awaited in a hook) so the widget's
28
+ // `React.lazy(() => import('./ImageCropperDialog'))` resolves from the ESM
29
+ // cache instead of racing RTL's 1000ms `findBy` budget — AGENTS.md §测试纪律.
30
+ import './ImageCropperDialog';
31
+
32
+ const MB = 1024 * 1024;
33
+
34
+ /** A File whose reported `size` is the declared one (constructor bytes are not). */
35
+ function imageOfSize(name: string, bytes: number): File {
36
+ const file = new File(['x'], name, { type: 'image/png' });
37
+ Object.defineProperty(file, 'size', { value: bytes });
38
+ return file;
39
+ }
40
+
41
+ function blobOfSize(bytes: number): Blob {
42
+ const blob = new Blob(['x'], { type: 'image/png' });
43
+ Object.defineProperty(blob, 'size', { value: bytes });
44
+ return blob;
45
+ }
46
+
47
+ function renderField(field: Record<string, unknown>, value: unknown = undefined) {
48
+ const upload = vi.fn(async (f: File | Blob) => ({
49
+ url: 'https://cdn.example/uploaded.png',
50
+ name: (f as File).name ?? 'cropped.png',
51
+ size: f.size,
52
+ mimeType: f.type || 'image/png',
53
+ }));
54
+ const adapter: UploadAdapter = { name: 'spy', upload };
55
+ const onChange = vi.fn();
56
+ render(
57
+ <UploadProvider adapter={adapter}>
58
+ <ImageField field={field as any} value={value} onChange={onChange} />
59
+ </UploadProvider>,
60
+ );
61
+ return { upload, onChange, input: document.querySelector('input[type="file"]') as HTMLInputElement };
62
+ }
63
+
64
+ describe('ImageField — client-side maxSize guard (#4141)', () => {
65
+ it('never reaches the upload layer for a pick over the declared maxSize', async () => {
66
+ const { upload, onChange, input } = renderField({ name: 'p_cover', maxSize: 5 * MB });
67
+
68
+ fireEvent.change(input, { target: { files: [imageOfSize('huge.png', 6.3 * MB)] } });
69
+
70
+ // The message is the proof the guard ran; the spy is the proof nothing was sent.
71
+ await screen.findByText(/"huge\.png" exceeds max size \(5\.0 MB\)/);
72
+ expect(upload).not.toHaveBeenCalled();
73
+ expect(onChange).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it('still uploads a pick inside the declared maxSize', async () => {
77
+ const { upload, onChange, input } = renderField({ name: 'p_cover', maxSize: 5 * MB });
78
+
79
+ fireEvent.change(input, { target: { files: [imageOfSize('ok.png', 1 * MB)] } });
80
+
81
+ await waitFor(() => expect(onChange).toHaveBeenCalled());
82
+ expect(upload).toHaveBeenCalledTimes(1);
83
+ expect(screen.queryByText(/exceeds max size/)).toBeNull();
84
+ });
85
+
86
+ it('uploads unrestricted when the param declares no maxSize', async () => {
87
+ const { upload, onChange, input } = renderField({ name: 'p_cover' });
88
+
89
+ fireEvent.change(input, { target: { files: [imageOfSize('enormous.png', 500 * MB)] } });
90
+
91
+ await waitFor(() => expect(onChange).toHaveBeenCalled());
92
+ expect(upload).toHaveBeenCalledTimes(1);
93
+ expect(screen.queryByText(/exceeds max size/)).toBeNull();
94
+ });
95
+
96
+ it('rejects only the oversized picks of a multi-select and uploads the rest', async () => {
97
+ const { upload, onChange, input } = renderField({ name: 'p_gallery', multiple: true, maxSize: 5 * MB });
98
+
99
+ fireEvent.change(input, {
100
+ target: { files: [imageOfSize('small.png', 1 * MB), imageOfSize('huge.png', 6.3 * MB)] },
101
+ });
102
+
103
+ await waitFor(() => expect(onChange).toHaveBeenCalled());
104
+ expect(upload).toHaveBeenCalledTimes(1);
105
+ expect((upload.mock.calls[0][0] as File).name).toBe('small.png');
106
+ expect(screen.getByText(/"huge\.png" exceeds max size \(5\.0 MB\)/)).toBeTruthy();
107
+ });
108
+
109
+ it('guards the crop dialog too — an oversized re-encode is never uploaded', async () => {
110
+ const existing = { name: 'cover.png', url: 'https://cdn.example/cover.png', mime_type: 'image/png' };
111
+ const { upload, onChange } = renderField({ name: 'p_cover', maxSize: 5 * MB, crop: true }, existing);
112
+
113
+ fireEvent.click(screen.getByTestId('image-field-crop-0'));
114
+ const confirm = await screen.findByTestId('fake-crop-confirm');
115
+ fireEvent.click(confirm);
116
+
117
+ await screen.findByText(/exceeds max size \(5\.0 MB\)/);
118
+ expect(upload).not.toHaveBeenCalled();
119
+ expect(onChange).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it('still uploads a crop that fits inside the declared maxSize', async () => {
123
+ const existing = { name: 'cover.png', url: 'https://cdn.example/cover.png', mime_type: 'image/png' };
124
+ const { upload, onChange } = renderField({ name: 'p_cover', maxSize: 5 * MB, crop: true }, existing);
125
+
126
+ fireEvent.click(screen.getByTestId('image-field-crop-0'));
127
+ fireEvent.click(await screen.findByTestId('fake-crop-confirm-small'));
128
+
129
+ await waitFor(() => expect(onChange).toHaveBeenCalled());
130
+ expect(upload).toHaveBeenCalledTimes(1);
131
+ });
132
+ });
133
+
134
+ /**
135
+ * The real cropper is a canvas + `toBlob` pipeline that happy-dom cannot run;
136
+ * this stand-in drives the ONE contract ImageField has with it — `onConfirm`
137
+ * hands back a blob and a name — with a blob size on either side of the limit.
138
+ */
139
+ vi.mock('./ImageCropperDialog', () => ({
140
+ ImageCropperDialog: ({ onConfirm }: { onConfirm: (blob: Blob, name: string) => void }) => (
141
+ <div>
142
+ <button type="button" data-testid="fake-crop-confirm" onClick={() => onConfirm(blobOfSize(6.3 * MB), 'cover.png')}>
143
+ confirm big
144
+ </button>
145
+ <button
146
+ type="button"
147
+ data-testid="fake-crop-confirm-small"
148
+ onClick={() => onConfirm(blobOfSize(0.5 * MB), 'cover.png')}
149
+ >
150
+ confirm small
151
+ </button>
152
+ </div>
153
+ ),
154
+ }));