@object-ui/fields 17.2.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 (212) hide show
  1. package/CHANGELOG.md +1533 -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 +5 -5
  7. package/dist/index.css +633 -0
  8. package/dist/index.d.ts +52 -12
  9. package/dist/index.js +5167 -4759
  10. package/dist/widgets/AddressField.d.ts +4 -12
  11. package/dist/widgets/AvatarField.d.ts +1 -1
  12. package/dist/widgets/CharacterCount.d.ts +56 -0
  13. package/dist/widgets/CheckboxesField.d.ts +1 -1
  14. package/dist/widgets/CodeField.d.ts +1 -1
  15. package/dist/widgets/ColorField.d.ts +1 -1
  16. package/dist/widgets/CurrencyField.d.ts +1 -1
  17. package/dist/widgets/EmailField.d.ts +1 -1
  18. package/dist/widgets/FileField.d.ts +1 -1
  19. package/dist/widgets/FilterConditionField.d.ts +1 -1
  20. package/dist/widgets/FullscreenFieldEditor.d.ts +45 -0
  21. package/dist/widgets/GeolocationField.d.ts +1 -1
  22. package/dist/widgets/GridField.d.ts +12 -2
  23. package/dist/widgets/ImageField.d.ts +1 -1
  24. package/dist/widgets/LocationField.d.ts +1 -1
  25. package/dist/widgets/LookupField.d.ts +1 -1
  26. package/dist/widgets/MultiSelectField.d.ts +1 -1
  27. package/dist/widgets/ObjectField.d.ts +1 -1
  28. package/dist/widgets/ObjectRefField.d.ts +1 -1
  29. package/dist/widgets/OptionsEmptyState.d.ts +59 -0
  30. package/dist/widgets/PercentField.d.ts +1 -1
  31. package/dist/widgets/PhoneField.d.ts +1 -1
  32. package/dist/widgets/QRCodeField.d.ts +1 -1
  33. package/dist/widgets/RadioField.d.ts +1 -1
  34. package/dist/widgets/RatingField.d.ts +1 -1
  35. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  36. package/dist/widgets/RecordPickerDialog.d.ts +49 -7
  37. package/dist/widgets/RichTextField.d.ts +19 -1
  38. package/dist/widgets/SelectField.d.ts +25 -5
  39. package/dist/widgets/TagsField.d.ts +1 -1
  40. package/dist/widgets/TextAreaField.d.ts +44 -5
  41. package/dist/widgets/UrlField.d.ts +1 -1
  42. package/dist/widgets/address-format.d.ts +68 -0
  43. package/dist/widgets/file-size-guard.d.ts +41 -0
  44. package/dist/widgets/nativeDateValue.d.ts +37 -0
  45. package/dist/widgets/toDomProps.d.ts +111 -0
  46. package/dist/widgets/toHostGroupProps.d.ts +91 -0
  47. package/dist/widgets/types.d.ts +128 -33
  48. package/dist/widgets/useRecordQuery.d.ts +7 -1
  49. package/dist/withFieldCarrier.d.ts +56 -0
  50. package/package.json +20 -16
  51. package/src/FieldEditWidget.tsx +17 -1
  52. package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
  53. package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
  54. package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
  55. package/src/__tests__/DateCellRenderer.test.tsx +9 -0
  56. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
  57. package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
  58. package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
  59. package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
  60. package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
  61. package/src/__tests__/cell-truncation.test.tsx +96 -0
  62. package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
  63. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
  64. package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
  65. package/src/__tests__/date-locale-channel.test.tsx +305 -0
  66. package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
  67. package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
  68. package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
  69. package/src/__tests__/group-labelling-declaration.test.ts +148 -0
  70. package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
  71. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
  72. package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
  73. package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
  74. package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
  75. package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
  76. package/src/__tests__/validation-feedback.test.tsx +22 -13
  77. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
  78. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
  79. package/src/__tests__/widget-dom-leak-e2e.test.tsx +357 -0
  80. package/src/__tests__/widget-props-contract.test.tsx +22 -9
  81. package/src/complex-widgets.test.tsx +22 -6
  82. package/src/currency.ts +130 -0
  83. package/src/datetime-widgets.test.tsx +94 -2
  84. package/src/field-type-alias.multiple.test.ts +81 -0
  85. package/src/field-type-alias.ts +58 -2
  86. package/src/index.css +44 -0
  87. package/src/index.tsx +558 -268
  88. package/src/standard-widgets.test.tsx +8 -1
  89. package/src/widgets/AddressField.postalCode.test.tsx +257 -0
  90. package/src/widgets/AddressField.tsx +107 -38
  91. package/src/widgets/AddressField.uniqueIds.test.tsx +93 -0
  92. package/src/widgets/AvatarField.tsx +9 -2
  93. package/src/widgets/BooleanField.tsx +44 -10
  94. package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
  95. package/src/widgets/CharacterCount.tsx +263 -0
  96. package/src/widgets/CheckboxesField.tsx +66 -16
  97. package/src/widgets/CodeField.tsx +9 -2
  98. package/src/widgets/ColorField.tsx +11 -2
  99. package/src/widgets/CurrencyField.tsx +60 -19
  100. package/src/widgets/DateField.tsx +13 -4
  101. package/src/widgets/DateTimeField.tsx +14 -5
  102. package/src/widgets/EmailField.tsx +5 -5
  103. package/src/widgets/FileField.tsx +39 -11
  104. package/src/widgets/FilterConditionField.tsx +11 -0
  105. package/src/widgets/FormulaField.tsx +7 -2
  106. package/src/widgets/FullscreenFieldEditor.tsx +60 -0
  107. package/src/widgets/GeolocationField.tsx +49 -8
  108. package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
  109. package/src/widgets/GridField.test.tsx +226 -0
  110. package/src/widgets/GridField.tsx +115 -11
  111. package/src/widgets/ImageField.maxSize.test.tsx +154 -0
  112. package/src/widgets/ImageField.tsx +63 -6
  113. package/src/widgets/LocationField.tsx +9 -2
  114. package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
  115. package/src/widgets/LookupField.hydration.test.tsx +1 -1
  116. package/src/widgets/LookupField.tsx +49 -10
  117. package/src/widgets/MasterDetailField.tsx +1 -1
  118. package/src/widgets/MultiSelectField.tsx +86 -16
  119. package/src/widgets/NumberField.tsx +3 -3
  120. package/src/widgets/ObjectField.tsx +17 -8
  121. package/src/widgets/ObjectRefField.tsx +10 -0
  122. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
  123. package/src/widgets/OptionsEmptyState.test.tsx +169 -0
  124. package/src/widgets/OptionsEmptyState.tsx +99 -0
  125. package/src/widgets/PasswordField.tsx +3 -3
  126. package/src/widgets/PeoplePicker.tsx +7 -1
  127. package/src/widgets/PercentField.tsx +5 -4
  128. package/src/widgets/PhoneField.tsx +5 -5
  129. package/src/widgets/QRCodeField.tsx +9 -2
  130. package/src/widgets/RadioField.tsx +58 -16
  131. package/src/widgets/RatingField.tsx +32 -4
  132. package/src/widgets/RecipientPickerField.tsx +18 -0
  133. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
  134. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
  135. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
  136. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
  137. package/src/widgets/RecordPickerDialog.tsx +235 -21
  138. package/src/widgets/RichTextField.tsx +151 -24
  139. package/src/widgets/SelectField.tsx +84 -22
  140. package/src/widgets/SliderField.tsx +1 -1
  141. package/src/widgets/SummaryField.tsx +1 -1
  142. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
  143. package/src/widgets/TagsField.placeholder.test.tsx +95 -0
  144. package/src/widgets/TagsField.tsx +23 -2
  145. package/src/widgets/TextAreaField.tsx +171 -93
  146. package/src/widgets/TextField.tsx +3 -3
  147. package/src/widgets/TimeField.tsx +2 -2
  148. package/src/widgets/UrlField.tsx +5 -5
  149. package/src/widgets/UserField.tsx +4 -3
  150. package/src/widgets/VectorField.tsx +1 -1
  151. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
  152. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
  153. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
  154. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
  155. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
  156. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
  157. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
  158. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
  159. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
  160. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
  161. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
  162. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
  163. package/src/widgets/address-format.ts +120 -0
  164. package/src/widgets/file-size-guard.ts +55 -0
  165. package/src/widgets/nativeDateValue.test.ts +115 -0
  166. package/src/widgets/nativeDateValue.ts +112 -0
  167. package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
  168. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
  169. package/src/widgets/toDomProps.ts +204 -0
  170. package/src/widgets/toHostGroupProps.ts +102 -0
  171. package/src/widgets/types.ts +135 -35
  172. package/src/widgets/useFieldTranslation.ts +46 -4
  173. package/src/widgets/useRecordQuery.ts +30 -3
  174. package/src/withFieldCarrier.tsx +75 -0
  175. package/dist/FieldEditWidget.test.d.ts +0 -8
  176. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  177. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  178. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  179. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  180. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  181. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  182. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  183. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  184. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  185. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  186. package/dist/coerce-safe-value.test.d.ts +0 -1
  187. package/dist/complex-widgets.test.d.ts +0 -1
  188. package/dist/datetime-widgets.test.d.ts +0 -1
  189. package/dist/field-type-coverage.test.d.ts +0 -1
  190. package/dist/standard-widgets.test.d.ts +0 -0
  191. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  192. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  193. package/dist/widgets/GridField.test.d.ts +0 -1
  194. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  195. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  196. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  197. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  198. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  199. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  200. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  201. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  202. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  203. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  204. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  205. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  206. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  207. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  208. package/dist/widgets/file-value.test.d.ts +0 -1
  209. package/dist/widgets/personDisplay.test.d.ts +0 -8
  210. package/dist/widgets/recentLookups.test.d.ts +0 -1
  211. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  212. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
@@ -0,0 +1,88 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * Unique sub-input ids for the composite `geolocation` widget
11
+ * (objectui#3343).
12
+ *
13
+ * The widget used to hardcode literal ids ("latitude" / "longitude") on its
14
+ * sub-inputs. Two geolocation fields in one form — e.g. origin + destination
15
+ * — then produced duplicate DOM ids, and every `Label htmlFor` resolved to
16
+ * the FIRST match in the document: each sub-label of the second field
17
+ * clicked/announced the first field's input.
18
+ *
19
+ * The fix follows the `groupId` paradigm of RadioField / CheckboxesField:
20
+ * a `useId()` prefix + the sub-field name. These tests pin it by rendering
21
+ * TWO instances in one form:
22
+ *
23
+ * 1. every `[id]` in the document is globally unique (red again if anyone
24
+ * reverts to hardcoded literals — both instances would emit
25
+ * id="latitude");
26
+ * 2. each sub-label of the SECOND instance resolves — via the exact
27
+ * mechanism browsers use for label activation, `for` → getElementById —
28
+ * to an input inside its OWN instance, and focusing it lands there.
29
+ */
30
+ import { describe, it, expect, vi } from 'vitest';
31
+ import React from 'react';
32
+ import { render, within } from '@testing-library/react';
33
+ import '@testing-library/jest-dom';
34
+ import { GeolocationField } from './GeolocationField';
35
+
36
+ const field = { name: 'location', type: 'geolocation' } as any;
37
+
38
+ const SUB_LABELS = ['Latitude', 'Longitude'];
39
+
40
+ function renderTwoInOneForm() {
41
+ return render(
42
+ <form>
43
+ <div data-testid="geo-a">
44
+ <GeolocationField value={{}} onChange={vi.fn()} field={field} />
45
+ </div>
46
+ <div data-testid="geo-b">
47
+ <GeolocationField value={{}} onChange={vi.fn()} field={field} />
48
+ </div>
49
+ </form>,
50
+ );
51
+ }
52
+
53
+ describe('GeolocationField — unique sub-input ids (objectui#3343)', () => {
54
+ it('two geolocation fields in one form produce globally unique DOM ids', () => {
55
+ const { baseElement } = renderTwoInOneForm();
56
+ const ids = Array.from(baseElement.querySelectorAll('[id]')).map((el) => el.id);
57
+ // 2 sub-inputs per instance — both instances must actually render ids.
58
+ expect(ids.length).toBeGreaterThanOrEqual(4);
59
+ const duplicates = ids.filter((id, i) => ids.indexOf(id) !== i);
60
+ expect(duplicates).toEqual([]);
61
+ });
62
+
63
+ it("each sub-label of the SECOND field focuses that field's own input", () => {
64
+ const { getByTestId } = renderTwoInOneForm();
65
+ const first = getByTestId('geo-a');
66
+ const second = getByTestId('geo-b');
67
+
68
+ for (const text of SUB_LABELS) {
69
+ const label = within(second).getByText(text);
70
+ const forId = label.getAttribute('for');
71
+ expect(forId, `label "${text}" must carry htmlFor`).toBeTruthy();
72
+
73
+ // Exactly what the browser does on label click / SR announcement:
74
+ // resolve `for` against the document.
75
+ const control = document.getElementById(forId!);
76
+ expect(control, `htmlFor of "${text}" must resolve`).not.toBeNull();
77
+ expect(control!.tagName).toBe('INPUT');
78
+ expect(
79
+ second.contains(control),
80
+ `"${text}" must resolve into its OWN field, not the first one`,
81
+ ).toBe(true);
82
+ expect(first.contains(control)).toBe(false);
83
+
84
+ (control as HTMLInputElement).focus();
85
+ expect(control).toHaveFocus();
86
+ }
87
+ });
88
+ });
@@ -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
 
@@ -324,10 +390,14 @@ export function GridField({
324
390
  * the header (`parent.status == 'paid'`). Supplied by MasterDetailForm. */
325
391
  contextRecord?: Record<string, unknown>;
326
392
  }) {
327
- const cfg = (field || (props as any).schema || {}) as any;
393
+ const cfg = (field || {}) as any;
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
  />