@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
@@ -1,3 +1,4 @@
1
+ import React from 'react';
1
2
  import { render, screen, fireEvent } from '@testing-library/react';
2
3
  import { describe, it, expect, vi } from 'vitest';
3
4
  import { DateField } from './widgets/DateField';
@@ -54,10 +55,15 @@ describe('Date/Time Widgets', () => {
54
55
  });
55
56
 
56
57
  it('calls onChange when value changes', () => {
57
- const { container } = render(<DateTimeField {...baseProps} />);
58
+ const onChange = vi.fn();
59
+ const { container } = render(<DateTimeField {...baseProps} onChange={onChange} />);
58
60
  const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
59
61
  fireEvent.change(input, { target: { value: '2023-01-01T12:00' } });
60
- expect(baseProps.onChange).toHaveBeenCalledWith('2023-01-01T12:00');
62
+ // The control speaks zone-less local wall clock; form state holds
63
+ // ISO, the shape the API hands back and `toDateTimeInputValue`
64
+ // reads (objectui#3127). Storing the naive string instead would put
65
+ // read and write on different timezone bases.
66
+ expect(onChange).toHaveBeenCalledWith(new Date('2023-01-01T12:00').toISOString());
61
67
  });
62
68
 
63
69
  it('renders formatted datetime in readonly mode', () => {
@@ -71,6 +77,92 @@ describe('Date/Time Widgets', () => {
71
77
  });
72
78
  });
73
79
 
80
+ /**
81
+ * objectui#3127 — the record's stored ISO value reached the native control
82
+ * verbatim, and `<input type="date">` / `<input type="datetime-local">`
83
+ * reject anything but their one exact shape SILENTLY: the attribute lands
84
+ * in the DOM, `.value` reads back `''`, and the user sees an empty box on a
85
+ * field that has a value. `.value` (not `getAttribute('value')`) is
86
+ * therefore the only assertion that can see this bug at all.
87
+ */
88
+ describe('existing values round-trip through the native controls (#3127)', () => {
89
+ // The seeded showcase value. Whole minutes and no sub-second part, so
90
+ // the control (which has minute resolution) can represent it exactly
91
+ // and the round trip below is lossless in every timezone.
92
+ const storedIso = '2026-06-17T14:30:00.000Z';
93
+
94
+ it('DateTimeField shows a stored ISO instant instead of an empty box', () => {
95
+ const { container } = render(<DateTimeField {...baseProps} value={storedIso} />);
96
+ const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
97
+
98
+ expect(input.value).not.toBe('');
99
+ expect(input.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
100
+ // The shown wall clock denotes the SAME instant as the record —
101
+ // asserted this way so the test holds in every timezone, which a
102
+ // hardcoded "2026-06-17T22:30" would not.
103
+ expect(new Date(input.value).getTime()).toBe(new Date(storedIso).getTime());
104
+ });
105
+
106
+ it('DateTimeField writes back on the same basis it reads', () => {
107
+ // Read and write must share one timezone basis. Displaying local
108
+ // while storing the naive local string is the worse bug: the value
109
+ // would silently drift by the viewer's offset on every edit.
110
+ const onChange = vi.fn();
111
+ const { container } = render(
112
+ <DateTimeField {...baseProps} onChange={onChange} value={storedIso} />
113
+ );
114
+ const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
115
+
116
+ fireEvent.change(input, { target: { value: '2026-06-18T09:15' } });
117
+
118
+ const written = onChange.mock.calls[0][0];
119
+ // Zone-QUALIFIED, which the control's own output is not. Storing
120
+ // the raw `2026-06-18T09:15` is what puts the write on a different
121
+ // basis from the read: the server has no way to know whose 09:15
122
+ // it is, and resolves it to a different instant than the one the
123
+ // user picked.
124
+ expect(written).toMatch(/(Z|[+-]\d{2}:\d{2})$/);
125
+ expect(new Date(written).getTime()).toBe(new Date('2026-06-18T09:15').getTime());
126
+ });
127
+
128
+ it('DateTimeField returning to the shown minute restores the exact stored value', () => {
129
+ // Guards the drift this fix could otherwise introduce: if the two
130
+ // conversions disagreed by the viewer's UTC offset, navigating away
131
+ // from a value and back would land somewhere else, and every open →
132
+ // fiddle → undo would quietly move the record.
133
+ // Held in real state, because a `vi.fn()` onChange would leave the
134
+ // control pinned to its initial prop and React would swallow the
135
+ // second edit as a no-op — the test would pass without exercising
136
+ // anything.
137
+ const seen: string[] = [];
138
+ const Host = () => {
139
+ const [v, setV] = React.useState<string>(storedIso);
140
+ seen.push(v);
141
+ return <DateTimeField {...baseProps} value={v} onChange={setV as any} />;
142
+ };
143
+ const { container } = render(<Host />);
144
+ const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
145
+ const shown = input.value;
146
+
147
+ fireEvent.change(input, { target: { value: '2026-06-18T09:15' } });
148
+ fireEvent.change(input, { target: { value: shown } });
149
+
150
+ expect(seen[seen.length - 1]).toBe(storedIso);
151
+ });
152
+
153
+ it('DateField shows the stored calendar day, ISO-shaped or not', () => {
154
+ // Both spellings the API may hand back for a `date` field. The day
155
+ // must survive verbatim — re-parsing the bare form as UTC midnight
156
+ // would move it to the 16th west of Greenwich.
157
+ for (const stored of ['2026-06-17', '2026-06-17T00:00:00.000Z']) {
158
+ const { container, unmount } = render(<DateField {...baseProps} value={stored} />);
159
+ const input = container.querySelector('input[type="date"]') as HTMLInputElement;
160
+ expect(input.value).toBe('2026-06-17');
161
+ unmount();
162
+ }
163
+ });
164
+ });
165
+
74
166
  describe('TimeField', () => {
75
167
  it('renders time input in edit mode', () => {
76
168
  const { container } = render(<TimeField {...baseProps} />);
@@ -0,0 +1,81 @@
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
+ * The widget id is a function of the (type, `multiple`) PAIR, not of the type
11
+ * string alone (objectui#3986).
12
+ *
13
+ * `mapFieldTypeToFormType` mapped `select` to `field:select` unconditionally,
14
+ * while `SelectField` delegated to `MultiSelectField` whenever the metadata said
15
+ * `multiple`. Three correct pieces, one gap between them: the component that
16
+ * RENDERED was `MultiSelectField`, but everything keyed on the widget id — above
17
+ * all the label-association declaration (`ComponentMeta.labelling`, resolved via
18
+ * `getMeta('select')`) — was still answering for the single-value combobox. The
19
+ * host label of a normal `{ type: 'select', multiple: true }` picklist therefore
20
+ * emitted a `for` at the chip row's wrapper `div`, where a `for` is inert.
21
+ *
22
+ * `select` cannot simply be declared `labelling: 'group'` instead: the
23
+ * single-value trigger IS labelable, and a bare `select` is a builtin the form
24
+ * renderer resolves without the registry at all (pinned in
25
+ * `form-group-label-association.test.ts`). The producer has to name the widget
26
+ * that renders, which is what this file pins.
27
+ *
28
+ * Metadata only — nothing renders here, so no widget module is loaded.
29
+ */
30
+
31
+ import { describe, it, expect } from 'vitest';
32
+ import { mapFieldTypeToFormType } from './field-type-alias';
33
+
34
+ describe('select carries its arity in the widget id (objectui#3986)', () => {
35
+ it('maps select + multiple to the multi-value widget', () => {
36
+ // The one assertion that was `field:select` before this change.
37
+ expect(mapFieldTypeToFormType('select', { multiple: true })).toBe('field:multiselect');
38
+ });
39
+
40
+ it('keeps the single-value widget without multiple, and for multiple: false', () => {
41
+ // The guard direction: single-select must not move. Its trigger is a
42
+ // labelable `button[role=combobox]` and its `for` association works — taking
43
+ // that away would break a field that is fine to fix one that is not.
44
+ expect(mapFieldTypeToFormType('select')).toBe('field:select');
45
+ expect(mapFieldTypeToFormType('select', {})).toBe('field:select');
46
+ expect(mapFieldTypeToFormType('select', { multiple: false })).toBe('field:select');
47
+ expect(mapFieldTypeToFormType('select', { multiple: null })).toBe('field:select');
48
+ });
49
+
50
+ it('is idempotent for the type that is already inherently multi', () => {
51
+ // `multiselect` is a type in its own right; the arity override must not turn
52
+ // it into something else, whichever way the flag is set.
53
+ expect(mapFieldTypeToFormType('multiselect')).toBe('field:multiselect');
54
+ expect(mapFieldTypeToFormType('multiselect', { multiple: true })).toBe('field:multiselect');
55
+ });
56
+
57
+ /**
58
+ * The spec's multi-capable set is larger than the widget-moving set, and the
59
+ * difference is the whole reason this override is table-driven rather than a
60
+ * `config.multiple` branch sprinkled per type:
61
+ *
62
+ * `MULTI_CAPABLE_TYPES` = select / lookup / file / image (+ `radio` on the
63
+ * select branch, `user` storing like `lookup`)
64
+ *
65
+ * Every member except `select` renders BOTH arities inside one widget —
66
+ * `LookupField`, `FileField`, `ImageField` each read `multiple` themselves — so
67
+ * their widget id, and with it their `labelling` declaration, is correct for
68
+ * either arity. Moving them would point at widgets that do not exist.
69
+ */
70
+ const UNMOVED_MULTI_CAPABLE = ['lookup', 'master_detail', 'user', 'owner', 'file', 'image', 'radio'] as const;
71
+
72
+ it.each(UNMOVED_MULTI_CAPABLE)('%s resolves identically with and without multiple', (type) => {
73
+ expect(mapFieldTypeToFormType(type, { multiple: true })).toBe(mapFieldTypeToFormType(type));
74
+ });
75
+
76
+ it('does not invent a widget for an unknown type flagged multiple', () => {
77
+ // The fallback is unchanged: an unknown type is `field:text` at either arity,
78
+ // never `field:text-multiple`-anything.
79
+ expect(mapFieldTypeToFormType('something-unknown', { multiple: true })).toBe('field:text');
80
+ });
81
+ });
@@ -6,15 +6,63 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
 
9
+ /**
10
+ * The slice of a field definition the widget decision reads beyond `type`.
11
+ *
12
+ * Structural (not the spec `Field`) for the same reason the spec's own
13
+ * `ValueShapeFieldDef` is: every caller — object-schema metadata, a spec
14
+ * `FormFieldSchema` override, an action param def — can pass its own trimmed
15
+ * shape verbatim. `multiple` is the only key read; nothing else here is
16
+ * consulted, so a caller that has the whole metadata object may hand it over
17
+ * as-is.
18
+ */
19
+ export interface FieldTypeMappingConfig {
20
+ /** `FieldSchema.multiple` — the field holds zero-or-more values. */
21
+ multiple?: boolean | null;
22
+ }
23
+
24
+ /**
25
+ * Field types whose `multiple: true` form is a DIFFERENT registered widget,
26
+ * rather than the same widget in another mode (objectui#3986).
27
+ *
28
+ * `select` is the only member, and the narrowness is measured, not assumed. The
29
+ * spec's `MULTI_CAPABLE_TYPES` is larger — select / lookup / file / image, with
30
+ * `radio` on the select branch and `user` storing like `lookup` — but every
31
+ * other member renders both arities INSIDE one widget: `LookupField`,
32
+ * `FileField` and `ImageField` each branch on `multiple` themselves, so their
33
+ * registry id, and with it their `labelling` declaration, is the same either
34
+ * way. A `select` declared `multiple: true` renders `MultiSelectField` instead:
35
+ * a different component, whose labelled surface is a chip row's container that
36
+ * a `<label for>` cannot address (it must be named by IDREF, which is what
37
+ * `field:multiselect`'s `labelling: 'group'` declares — objectui#3975/#3961).
38
+ *
39
+ * So the widget id has to carry the arity. When it did not, the host label was
40
+ * associated by the declaration registered under `select` — a single-value
41
+ * combobox that was NOT rendering — and the visible label of a normal
42
+ * `{ type: 'select', multiple: true }` picklist named nothing at all. One place
43
+ * decides which widget renders, so the declaration and the render cannot
44
+ * disagree again.
45
+ */
46
+ const MULTI_VALUE_FORM_TYPES: Record<string, string> = {
47
+ select: 'field:multiselect',
48
+ };
49
+
9
50
  /**
10
51
  * Map field type to form component type
11
- *
52
+ *
12
53
  * @param fieldType - The ObjectQL field type identifier to convert
13
54
  * (for example: `"text"`, `"number"`, `"date"`, `"lookup"`).
55
+ * @param config - The rest of the field definition, for the types whose widget
56
+ * identity depends on more than the type string. Only `multiple` is read; see
57
+ * {@link MULTI_VALUE_FORM_TYPES}. Omitting it maps the single-value form, which
58
+ * is what every non-multi-capable type resolves to anyway.
14
59
  * @returns The normalized form field type string used in the form schema
15
60
  * (for example: `"input"`, `"textarea"`, `"date-picker"`, `"select"`).
16
61
  */
17
- export function mapFieldTypeToFormType(fieldType: string): string {
62
+ export function mapFieldTypeToFormType(
63
+ fieldType: string,
64
+ config?: FieldTypeMappingConfig,
65
+ ): string {
18
66
  const typeMap: Record<string, string> = {
19
67
  // Text-based fields
20
68
  text: 'field:text',
@@ -96,5 +144,13 @@ export function mapFieldTypeToFormType(fieldType: string): string {
96
144
  auto_number: 'field:auto_number',
97
145
  };
98
146
 
147
+ // The arity override comes FIRST and is table-driven: only a type listed in
148
+ // `MULTI_VALUE_FORM_TYPES` has a second widget to move to, so `multiple` on
149
+ // any other type (a multi `lookup`, a multi `file`) resolves exactly as
150
+ // before and keeps reaching the widget that handles both arities itself.
151
+ if (config?.multiple && MULTI_VALUE_FORM_TYPES[fieldType]) {
152
+ return MULTI_VALUE_FORM_TYPES[fieldType];
153
+ }
154
+
99
155
  return typeMap[fieldType] || 'field:text';
100
156
  }
package/src/index.css ADDED
@@ -0,0 +1,44 @@
1
+ /*
2
+ * Tailwind entry for `@object-ui/fields` — BUILD-TIME ONLY.
3
+ *
4
+ * Consumers never compile this file. They import the compiled artifact:
5
+ *
6
+ * @import '@object-ui/components/style.css';
7
+ * @import '@object-ui/fields/style.css'; <- ./dist/index.css, built from this
8
+ *
9
+ * The `@reference` below is what makes the "narrow" shape possible
10
+ * (objectui#4059): it gives this compilation `@object-ui/components`' theme
11
+ * tokens, its class-based `dark` variant and its `tailwindcss-animate` plugin
12
+ * WITHOUT emitting a single byte of them. So the sheet built from this entry
13
+ * carries utilities only — no preflight, no `@theme` `:root` block, no base
14
+ * layer. `@object-ui/components`' own stylesheet owns all of that, and
15
+ * `scripts/build-css.mjs` then subtracts every rule that sheet already ships.
16
+ *
17
+ * That is why the import ORDER above is not cosmetic: this sheet is a
18
+ * supplement to the components sheet, not a replacement for it.
19
+ *
20
+ * This file has no `@import 'tailwindcss'` on purpose — that would pull in
21
+ * preflight and re-emit the shared theme, which is exactly the ~160 kB
22
+ * duplication the narrow variant exists to avoid.
23
+ */
24
+ @reference '../../components/src/index.css';
25
+
26
+ /*
27
+ * `source(none)` is load-bearing, not tidiness. Tailwind's automatic source
28
+ * detection resolves against the PROCESS CWD, and `@reference` above pulls in
29
+ * components' entry, whose own `@import 'tailwindcss'` turns that detection on.
30
+ * Without `source(none)` the candidate set therefore depends on where the build
31
+ * was launched from: run from this package it scanned `packages/fields` and
32
+ * produced a 21 kB sheet, run from the repo root it scanned the whole workspace
33
+ * and produced a 287 kB one full of other packages' utilities. Same commit, same
34
+ * command, two different published artifacts.
35
+ *
36
+ * With it, the ONLY inputs are the explicit `@source` lines below, and the
37
+ * output is byte-identical from any working directory.
38
+ */
39
+ @import 'tailwindcss/utilities.css' layer(utilities) source(none);
40
+
41
+ /* Only shipped source: `*.test.tsx` never reaches a consumer, so utilities used
42
+ solely by tests must not be compiled into the published sheet. */
43
+ @source './**/*.{ts,tsx}';
44
+ @source not './**/*.test.{ts,tsx}';