@object-ui/fields 17.5.0 → 17.7.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 (249) hide show
  1. package/CHANGELOG.md +6150 -0
  2. package/README.md +108 -8
  3. package/dist/FieldEditWidget.d.ts +70 -8
  4. package/dist/coerceToSafeValue.d.ts +54 -0
  5. package/dist/currency.d.ts +1 -1
  6. package/dist/field-type-alias.d.ts +16 -0
  7. package/dist/index.cjs +5 -5
  8. package/dist/index.css +53 -11
  9. package/dist/index.d.ts +653 -128
  10. package/dist/index.js +4275 -3171
  11. package/dist/widgets/AddressField.d.ts +3 -3
  12. package/dist/widgets/AutoNumberField.d.ts +1 -1
  13. package/dist/widgets/AvatarField.d.ts +20 -4
  14. package/dist/widgets/BooleanField.d.ts +2 -2
  15. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  16. package/dist/widgets/CheckboxesField.d.ts +1 -1
  17. package/dist/widgets/CodeField.d.ts +1 -1
  18. package/dist/widgets/ColorField.d.ts +1 -1
  19. package/dist/widgets/CurrencyField.d.ts +1 -1
  20. package/dist/widgets/DateField.d.ts +2 -2
  21. package/dist/widgets/DateTimeField.d.ts +2 -2
  22. package/dist/widgets/EmailField.d.ts +1 -1
  23. package/dist/widgets/FileField.d.ts +18 -2
  24. package/dist/widgets/FilterConditionField.d.ts +43 -2
  25. package/dist/widgets/FormulaField.d.ts +1 -1
  26. package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
  27. package/dist/widgets/GeolocationField.d.ts +10 -4
  28. package/dist/widgets/GridField.d.ts +102 -86
  29. package/dist/widgets/ImageField.d.ts +1 -1
  30. package/dist/widgets/LocationField.d.ts +31 -4
  31. package/dist/widgets/LookupField.d.ts +9 -1
  32. package/dist/widgets/MasterDetailField.d.ts +1 -1
  33. package/dist/widgets/MultiSelectField.d.ts +1 -1
  34. package/dist/widgets/NumberField.d.ts +2 -2
  35. package/dist/widgets/ObjectField.d.ts +10 -3
  36. package/dist/widgets/ObjectRefField.d.ts +1 -1
  37. package/dist/widgets/OptionsEmptyState.d.ts +38 -8
  38. package/dist/widgets/PasswordField.d.ts +15 -1
  39. package/dist/widgets/PeoplePicker.d.ts +17 -2
  40. package/dist/widgets/PercentField.d.ts +3 -2
  41. package/dist/widgets/PersonRow.d.ts +13 -2
  42. package/dist/widgets/PhoneField.d.ts +1 -1
  43. package/dist/widgets/QRCodeField.d.ts +1 -1
  44. package/dist/widgets/RadioField.d.ts +1 -1
  45. package/dist/widgets/RatingField.d.ts +1 -1
  46. package/dist/widgets/RecipientPickerField.d.ts +44 -1
  47. package/dist/widgets/RecordPickerDialog.d.ts +33 -16
  48. package/dist/widgets/RichTextField.d.ts +37 -1
  49. package/dist/widgets/SelectField.d.ts +1 -1
  50. package/dist/widgets/SelectionTray.d.ts +13 -2
  51. package/dist/widgets/SignatureField.d.ts +32 -2
  52. package/dist/widgets/SliderField.d.ts +37 -2
  53. package/dist/widgets/SummaryField.d.ts +1 -1
  54. package/dist/widgets/TagsField.d.ts +1 -1
  55. package/dist/widgets/TextAreaField.d.ts +6 -2
  56. package/dist/widgets/TextField.d.ts +2 -2
  57. package/dist/widgets/TimeField.d.ts +2 -2
  58. package/dist/widgets/UrlField.d.ts +1 -1
  59. package/dist/widgets/UserField.d.ts +1 -1
  60. package/dist/widgets/VectorField.d.ts +1 -1
  61. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  62. package/dist/widgets/address-format.d.ts +27 -7
  63. package/dist/widgets/file-affordance.d.ts +33 -0
  64. package/dist/widgets/file-size-guard.d.ts +5 -23
  65. package/dist/widgets/file-value.d.ts +38 -9
  66. package/dist/widgets/lookupColumnDisplay.d.ts +110 -0
  67. package/dist/widgets/nativeDateValue.d.ts +9 -27
  68. package/dist/widgets/number-format.d.ts +137 -0
  69. package/dist/widgets/numberBadInput.d.ts +135 -0
  70. package/dist/widgets/personDisplay.d.ts +22 -4
  71. package/dist/widgets/richTextDisplay.d.ts +116 -0
  72. package/dist/widgets/toDomProps.d.ts +10 -1
  73. package/dist/widgets/toHostGroupProps.d.ts +172 -21
  74. package/dist/widgets/toHostProps.d.ts +105 -0
  75. package/dist/widgets/types.d.ts +19 -6
  76. package/dist/widgets/upload-error-message.d.ts +6 -0
  77. package/dist/widgets/uploadingScope.d.ts +142 -0
  78. package/dist/widgets/useCascadingOptions.d.ts +3 -2
  79. package/dist/widgets/useRecordQuery.d.ts +4 -2
  80. package/dist/widgets/useUploadingSignal.d.ts +8 -0
  81. package/package.json +11 -13
  82. package/dist/widgets/CharacterCount.d.ts +0 -56
  83. package/src/FieldEditWidget.test.ts +0 -133
  84. package/src/FieldEditWidget.tsx +0 -207
  85. package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
  86. package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
  87. package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
  88. package/src/__tests__/DateCellRenderer.test.tsx +0 -82
  89. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
  90. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  91. package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
  92. package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
  93. package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
  94. package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
  95. package/src/__tests__/cell-truncation.test.tsx +0 -96
  96. package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
  97. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
  98. package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
  99. package/src/__tests__/date-locale-channel.test.tsx +0 -305
  100. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  101. package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
  102. package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
  103. package/src/__tests__/group-labelling-declaration.test.ts +0 -148
  104. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  105. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  106. package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
  107. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  108. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
  109. package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
  110. package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
  111. package/src/__tests__/registry-collision.test.ts +0 -91
  112. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  113. package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
  114. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  115. package/src/__tests__/validation-feedback.test.tsx +0 -361
  116. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  117. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  118. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
  119. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  120. package/src/coerce-safe-value.test.ts +0 -34
  121. package/src/complex-widgets.test.tsx +0 -924
  122. package/src/currency.ts +0 -144
  123. package/src/datetime-widgets.test.tsx +0 -185
  124. package/src/field-type-alias.multiple.test.ts +0 -81
  125. package/src/field-type-alias.ts +0 -156
  126. package/src/field-type-coverage.test.ts +0 -67
  127. package/src/index.css +0 -44
  128. package/src/index.tsx +0 -2745
  129. package/src/standard-widgets.test.tsx +0 -364
  130. package/src/widgets/AddressField.postalCode.test.tsx +0 -257
  131. package/src/widgets/AddressField.tsx +0 -182
  132. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
  133. package/src/widgets/AutoNumberField.tsx +0 -18
  134. package/src/widgets/AvatarField.tsx +0 -117
  135. package/src/widgets/BooleanField.tsx +0 -86
  136. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  137. package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
  138. package/src/widgets/CharacterCount.tsx +0 -263
  139. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  140. package/src/widgets/CheckboxesField.tsx +0 -169
  141. package/src/widgets/CodeField.tsx +0 -41
  142. package/src/widgets/ColorField.tsx +0 -53
  143. package/src/widgets/CurrencyField.tsx +0 -135
  144. package/src/widgets/DateField.tsx +0 -40
  145. package/src/widgets/DateTimeField.tsx +0 -46
  146. package/src/widgets/EmailField.tsx +0 -37
  147. package/src/widgets/FileField.tsx +0 -512
  148. package/src/widgets/FilterConditionField.tsx +0 -454
  149. package/src/widgets/FormulaField.tsx +0 -38
  150. package/src/widgets/FullscreenFieldEditor.tsx +0 -60
  151. package/src/widgets/GeolocationField.tsx +0 -193
  152. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  153. package/src/widgets/GridField.test.tsx +0 -632
  154. package/src/widgets/GridField.tsx +0 -1070
  155. package/src/widgets/ImageCropperDialog.tsx +0 -245
  156. package/src/widgets/ImageField.maxSize.test.tsx +0 -154
  157. package/src/widgets/ImageField.tsx +0 -305
  158. package/src/widgets/ImageLightbox.test.tsx +0 -52
  159. package/src/widgets/ImageLightbox.tsx +0 -108
  160. package/src/widgets/LocationField.tsx +0 -57
  161. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  162. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  163. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  164. package/src/widgets/LookupField.idField.test.tsx +0 -61
  165. package/src/widgets/LookupField.tsx +0 -1295
  166. package/src/widgets/MarkdownContent.tsx +0 -19
  167. package/src/widgets/MasterDetailField.tsx +0 -131
  168. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  169. package/src/widgets/MultiSelectField.tsx +0 -196
  170. package/src/widgets/NumberField.tsx +0 -49
  171. package/src/widgets/ObjectField.tsx +0 -94
  172. package/src/widgets/ObjectRefField.tsx +0 -121
  173. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  174. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  175. package/src/widgets/OptionsEmptyState.tsx +0 -99
  176. package/src/widgets/PasswordField.tsx +0 -49
  177. package/src/widgets/PeoplePicker.test.tsx +0 -360
  178. package/src/widgets/PeoplePicker.tsx +0 -579
  179. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  180. package/src/widgets/PercentField.tsx +0 -95
  181. package/src/widgets/PersonRow.tsx +0 -117
  182. package/src/widgets/PhoneField.tsx +0 -37
  183. package/src/widgets/QRCodeField.tsx +0 -103
  184. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  185. package/src/widgets/RadioField.tsx +0 -143
  186. package/src/widgets/RatingField.tsx +0 -94
  187. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  188. package/src/widgets/RecipientPickerField.tsx +0 -188
  189. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
  190. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
  191. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  192. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  193. package/src/widgets/RecordPickerDialog.tsx +0 -1365
  194. package/src/widgets/RichTextField.tsx +0 -176
  195. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  196. package/src/widgets/SelectField.tsx +0 -193
  197. package/src/widgets/SelectionTray.tsx +0 -110
  198. package/src/widgets/SignatureField.tsx +0 -140
  199. package/src/widgets/SliderField.tsx +0 -39
  200. package/src/widgets/SummaryField.tsx +0 -31
  201. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  202. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  203. package/src/widgets/TagsField.tsx +0 -88
  204. package/src/widgets/TextAreaField.tsx +0 -213
  205. package/src/widgets/TextField.tsx +0 -45
  206. package/src/widgets/TimeField.tsx +0 -31
  207. package/src/widgets/UrlField.tsx +0 -46
  208. package/src/widgets/UserField.tsx +0 -59
  209. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  210. package/src/widgets/VectorField.tsx +0 -29
  211. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  212. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
  213. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  214. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  215. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  216. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  217. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  218. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  219. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  220. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  221. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  222. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  223. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  224. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  225. package/src/widgets/address-format.ts +0 -120
  226. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  227. package/src/widgets/deriveLookupColumns.ts +0 -108
  228. package/src/widgets/file-size-guard.ts +0 -55
  229. package/src/widgets/file-value.test.ts +0 -230
  230. package/src/widgets/file-value.ts +0 -246
  231. package/src/widgets/nativeDateValue.test.ts +0 -115
  232. package/src/widgets/nativeDateValue.ts +0 -112
  233. package/src/widgets/openNativePicker.ts +0 -18
  234. package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
  235. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
  236. package/src/widgets/personDisplay.test.ts +0 -113
  237. package/src/widgets/personDisplay.ts +0 -92
  238. package/src/widgets/recentLookups.test.ts +0 -45
  239. package/src/widgets/recentLookups.ts +0 -47
  240. package/src/widgets/toDomProps.ts +0 -204
  241. package/src/widgets/toHostGroupProps.ts +0 -102
  242. package/src/widgets/types.ts +0 -242
  243. package/src/widgets/useCascadingOptions.ts +0 -46
  244. package/src/widgets/useFieldTranslation.ts +0 -132
  245. package/src/widgets/useRecordQuery.test.ts +0 -174
  246. package/src/widgets/useRecordQuery.ts +0 -291
  247. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  248. package/src/widgets/useUploadingSignal.ts +0 -20
  249. package/src/withFieldCarrier.tsx +0 -75
@@ -1,182 +0,0 @@
1
- import React, { useId } from 'react';
2
- import { Input, Label, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetComponentProps } from './types';
4
- import { toDomProps } from './toDomProps';
5
- import { toHostGroupProps } from './toHostGroupProps';
6
- // The value shape and the single-line formatting rule now live in a pure module
7
- // so the display (read) cell renderer formats a stored address exactly the way
8
- // this widget's readonly branch does — one rule, not two copies that drift
9
- // (objectui#4037). Behaviour here is unchanged; only the definition moved.
10
- import {
11
- formatAddress,
12
- readPostalCode,
13
- type AddressValue,
14
- type LegacyAddressValue,
15
- } from './address-format';
16
-
17
- // Re-exported through its declaring module rather than bare, so the spec-symbol
18
- // guard resolves the name to where it is actually defined: `address-format`
19
- // imports `AddressValue` from `@objectstack/spec/data` (objectui#4167), and a
20
- // bare `export type { AddressValue }` here would read to that guard as a second,
21
- // local declaration of a name the spec owns.
22
- export type { AddressValue } from './address-format';
23
-
24
- /**
25
- * Address field widget - provides a structured address input
26
- * Supports street, city, state, postal code, and country
27
- */
28
- export function AddressField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<AddressValue>) {
29
- const address = value || {};
30
- // DOM pass-through (objectui#3318): the whitelist spread goes onto the FIRST
31
- // sub-input (street) — one carrier for the form renderer's aria-describedby;
32
- // the composite's validation state goes onto EVERY focusable sub-input via
33
- // `aria-invalid={!!error}` (a form-level failure means the whole address is
34
- // missing/invalid, and whichever sub-input the user reaches must announce it).
35
- //
36
- // Two keys are held back from that spread and land on the group CONTAINER
37
- // instead (objectui#3961), because they address the field as a WHOLE:
38
- //
39
- // - `id` — the host's control id. It never reached the DOM at all: it was
40
- // spread here and then overwritten one line later by `id={subId('street')}`
41
- // (objectui#3343), which is why the form's "Shipping Address" label pointed
42
- // at an id no element carried. The sub-input ids stay exactly as they are;
43
- // only the host id moves out to the element it was always meant to name.
44
- // - `aria-labelledby` — the host's label, associated by IDREF. On the street
45
- // input it would be actively wrong: `aria-labelledby` OVERRIDES a control's
46
- // `<label for>`, so the first sub-input would be announced as "Shipping
47
- // Address" and its own "Street Address" label would vanish.
48
- //
49
- // Everything else (`aria-describedby`, `name`, focus handlers …) deliberately
50
- // stays on the first sub-input: a description or error must be announced when
51
- // focus lands on something focusable, and a container is not.
52
- const {
53
- id: _hostId,
54
- 'aria-labelledby': _hostLabelledBy,
55
- ...domProps
56
- } = toDomProps(props);
57
- // The pair held back above, in the one spelling every group-labelled widget
58
- // uses for it — and computed HERE, before the readonly early return below,
59
- // because that return used to drop both keys on the floor: a published label
60
- // id with no consumer in the document (objectui#3990). See `toHostGroupProps`.
61
- const hostGroupProps = toHostGroupProps(props);
62
- // Sub-input ids (objectui#3343): `useId()` prefix + sub-field name — the
63
- // `groupId` paradigm of RadioField / CheckboxesField. Hardcoded literals
64
- // ("street", "city", …) collide as soon as a form renders two address
65
- // fields, and every label's htmlFor then resolves to the FIRST match.
66
- const groupId = useId();
67
- const subId = (name: keyof AddressValue) => `${groupId}-${name}`;
68
-
69
- // Read through the legacy key, write only the canonical one (objectstack#5143).
70
- const postalCode = readPostalCode(address);
71
-
72
- const handleFieldChange = (fieldName: keyof AddressValue, fieldValue: string) => {
73
- // Normalize while we are here: the object we write back never carries
74
- // `zipCode`, and a legacy postal code is carried forward under
75
- // `postalCode`. Without the carry, editing an unrelated part of a
76
- // legacy-shaped address would write the postal code straight back out of
77
- // the record — the very data loss this fixes, one build later.
78
- const canonical: LegacyAddressValue = { ...address };
79
- delete canonical.zipCode;
80
- onChange({
81
- ...canonical,
82
- ...(postalCode ? { postalCode } : null),
83
- [fieldName]: fieldValue,
84
- });
85
- };
86
-
87
- if (readonly) {
88
- // Readonly the composite collapses to ONE formatted line — no sub-inputs and
89
- // no sub-labels — so that line is the surface the host label names, and
90
- // `EmptyValue` is the same surface with nothing in it (objectui#3990). The
91
- // placeholder's own `aria-label` ("No value") is outranked by
92
- // `aria-labelledby` per accname, and on its `generic` role an author name was
93
- // never exposed anyway.
94
- const formatted = formatAddress(address);
95
- return formatted ? (
96
- <span {...hostGroupProps} className="text-sm">{formatted}</span>
97
- ) : (
98
- <EmptyValue {...hostGroupProps} />
99
- );
100
- }
101
-
102
- return (
103
- // `role="group"` only when a host actually named this container
104
- // (objectui#3961): an unnamed group adds nothing for assistive tech, and
105
- // standalone rendering — the inline grid editor, a bare SDUI node — must stay
106
- // byte-identical to what it was before. That condition now lives in
107
- // `toHostGroupProps` so this container and the readonly line above cannot
108
- // answer differently (objectui#3990).
109
- <div className="space-y-3" {...hostGroupProps}>
110
- <div>
111
- <Label htmlFor={subId('street')} className="text-xs">Street Address</Label>
112
- <Input
113
- {...domProps}
114
- id={subId('street')}
115
- type="text"
116
- value={address.street || ''}
117
- onChange={(e) => handleFieldChange('street', e.target.value)}
118
- placeholder="123 Main St"
119
- disabled={readonly || props.disabled}
120
- className={props.className}
121
- aria-invalid={!!error}
122
- />
123
- </div>
124
-
125
- <div className="grid grid-cols-2 gap-3">
126
- <div>
127
- <Label htmlFor={subId('city')} className="text-xs">City</Label>
128
- <Input
129
- id={subId('city')}
130
- type="text"
131
- value={address.city || ''}
132
- onChange={(e) => handleFieldChange('city', e.target.value)}
133
- placeholder="San Francisco"
134
- disabled={readonly || props.disabled}
135
- aria-invalid={!!error}
136
- />
137
- </div>
138
-
139
- <div>
140
- <Label htmlFor={subId('state')} className="text-xs">State / Province</Label>
141
- <Input
142
- id={subId('state')}
143
- type="text"
144
- value={address.state || ''}
145
- onChange={(e) => handleFieldChange('state', e.target.value)}
146
- placeholder="CA"
147
- disabled={readonly || props.disabled}
148
- aria-invalid={!!error}
149
- />
150
- </div>
151
- </div>
152
-
153
- <div className="grid grid-cols-2 gap-3">
154
- <div>
155
- <Label htmlFor={subId('postalCode')} className="text-xs">ZIP / Postal Code</Label>
156
- <Input
157
- id={subId('postalCode')}
158
- type="text"
159
- value={postalCode || ''}
160
- onChange={(e) => handleFieldChange('postalCode', e.target.value)}
161
- placeholder="94102"
162
- disabled={readonly || props.disabled}
163
- aria-invalid={!!error}
164
- />
165
- </div>
166
-
167
- <div>
168
- <Label htmlFor={subId('country')} className="text-xs">Country</Label>
169
- <Input
170
- id={subId('country')}
171
- type="text"
172
- value={address.country || ''}
173
- onChange={(e) => handleFieldChange('country', e.target.value)}
174
- placeholder="United States"
175
- disabled={readonly || props.disabled}
176
- aria-invalid={!!error}
177
- />
178
- </div>
179
- </div>
180
- </div>
181
- );
182
- }
@@ -1,93 +0,0 @@
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 `address` widget (objectui#3343).
11
- *
12
- * The widget used to hardcode literal ids ("street", "city", "state",
13
- * "postalCode" — spelled "zipCode" until objectstack#5143 —, "country") on its
14
- * sub-inputs. Two address fields in one form —
15
- * e.g. billing + shipping — then produced duplicate DOM ids, and every
16
- * `Label htmlFor` resolved to the FIRST match in the document: each sub-label
17
- * of the second field 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 id="street");
25
- * 2. each sub-label of the SECOND instance resolves — via the exact
26
- * mechanism browsers use for label activation, `for` → getElementById —
27
- * to an input inside its OWN instance, and focusing it lands there.
28
- */
29
- import { describe, it, expect, vi } from 'vitest';
30
- import React from 'react';
31
- import { render, within } from '@testing-library/react';
32
- import '@testing-library/jest-dom';
33
- import { AddressField } from './AddressField';
34
-
35
- const field = { name: 'address', type: 'address' } as any;
36
-
37
- const SUB_LABELS = [
38
- 'Street Address',
39
- 'City',
40
- 'State / Province',
41
- 'ZIP / Postal Code',
42
- 'Country',
43
- ];
44
-
45
- function renderTwoInOneForm() {
46
- return render(
47
- <form>
48
- <div data-testid="address-a">
49
- <AddressField value={{}} onChange={vi.fn()} field={field} />
50
- </div>
51
- <div data-testid="address-b">
52
- <AddressField value={{}} onChange={vi.fn()} field={field} />
53
- </div>
54
- </form>,
55
- );
56
- }
57
-
58
- describe('AddressField — unique sub-input ids (objectui#3343)', () => {
59
- it('two address fields in one form produce globally unique DOM ids', () => {
60
- const { baseElement } = renderTwoInOneForm();
61
- const ids = Array.from(baseElement.querySelectorAll('[id]')).map((el) => el.id);
62
- // 5 sub-inputs per instance — both instances must actually render ids.
63
- expect(ids.length).toBeGreaterThanOrEqual(10);
64
- const duplicates = ids.filter((id, i) => ids.indexOf(id) !== i);
65
- expect(duplicates).toEqual([]);
66
- });
67
-
68
- it("each sub-label of the SECOND field focuses that field's own input", () => {
69
- const { getByTestId } = renderTwoInOneForm();
70
- const first = getByTestId('address-a');
71
- const second = getByTestId('address-b');
72
-
73
- for (const text of SUB_LABELS) {
74
- const label = within(second).getByText(text);
75
- const forId = label.getAttribute('for');
76
- expect(forId, `label "${text}" must carry htmlFor`).toBeTruthy();
77
-
78
- // Exactly what the browser does on label click / SR announcement:
79
- // resolve `for` against the document.
80
- const control = document.getElementById(forId!);
81
- expect(control, `htmlFor of "${text}" must resolve`).not.toBeNull();
82
- expect(control!.tagName).toBe('INPUT');
83
- expect(
84
- second.contains(control),
85
- `"${text}" must resolve into its OWN field, not the first one`,
86
- ).toBe(true);
87
- expect(first.contains(control)).toBe(false);
88
-
89
- (control as HTMLInputElement).focus();
90
- expect(control).toHaveFocus();
91
- }
92
- });
93
- });
@@ -1,18 +0,0 @@
1
- import React from 'react';
2
- import { EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetComponentProps } from './types';
4
-
5
- /**
6
- * AutoNumberField - Read-only auto-generated field
7
- * Values are automatically generated by the system and cannot be edited
8
- */
9
- export function AutoNumberField({ value, field, ...props }: FieldWidgetComponentProps<string | number>) {
10
- if (value == null) {
11
- return <EmptyValue className={props.className} />;
12
- }
13
- return (
14
- <span className={`text-sm font-mono text-gray-700 ${props.className || ''}`}>
15
- {String(value)}
16
- </span>
17
- );
18
- }
@@ -1,117 +0,0 @@
1
- import React from 'react';
2
- import { Avatar, AvatarFallback, AvatarImage, Button } from '@object-ui/components';
3
- import { Upload, X } from 'lucide-react';
4
- import { FieldWidgetComponentProps } from './types';
5
- import { toDomProps } from './toDomProps';
6
-
7
- /**
8
- * Avatar field widget - provides an avatar/profile picture uploader
9
- * Supports image URLs or file uploads
10
- */
11
- export function AvatarField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
12
- const [isHovered, setIsHovered] = React.useState(false);
13
- const fileInputRef = React.useRef<HTMLInputElement>(null);
14
-
15
- const avatarField = field as any;
16
-
17
- const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
18
- const file = event.target.files?.[0];
19
- if (!file) return;
20
-
21
- // Check file type
22
- if (!file.type.startsWith('image/')) {
23
- console.error('Please select an image file');
24
- return;
25
- }
26
-
27
- // Check file size (max 5MB)
28
- if (file.size > 5 * 1024 * 1024) {
29
- console.error('File size must be less than 5MB');
30
- return;
31
- }
32
-
33
- // Convert to base64 or upload to server
34
- const reader = new FileReader();
35
- reader.onloadend = () => {
36
- onChange(reader.result as string);
37
- };
38
- reader.readAsDataURL(file);
39
- };
40
-
41
- const handleRemove = () => {
42
- onChange('');
43
- };
44
-
45
- // Extract initials for fallback
46
- const getInitials = (): string => {
47
- const name = avatarField?.defaultName || avatarField?.label || 'User';
48
- return name
49
- .split(' ')
50
- .map((word: string) => word[0])
51
- .join('')
52
- .toUpperCase()
53
- .slice(0, 2);
54
- };
55
-
56
- if (readonly) {
57
- return (
58
- <Avatar className="w-16 h-16">
59
- {value && <AvatarImage src={value} alt={avatarField?.label} />}
60
- <AvatarFallback>{getInitials()}</AvatarFallback>
61
- </Avatar>
62
- );
63
- }
64
-
65
- return (
66
- <div className="flex items-center gap-4">
67
- <div
68
- className="relative"
69
- onMouseEnter={() => setIsHovered(true)}
70
- onMouseLeave={() => setIsHovered(false)}
71
- >
72
- <Avatar className="w-16 h-16">
73
- {value && <AvatarImage src={value} alt={avatarField?.label} />}
74
- <AvatarFallback>{getInitials()}</AvatarFallback>
75
- </Avatar>
76
- {!readonly && isHovered && value && (
77
- <button
78
- type="button"
79
- onClick={handleRemove}
80
- className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground rounded-full p-1 hover:bg-destructive/90"
81
- >
82
- <X className="w-3 h-3" />
83
- </button>
84
- )}
85
- </div>
86
-
87
- <div className="flex flex-col gap-2">
88
- <input
89
- ref={fileInputRef}
90
- type="file"
91
- accept="image/*"
92
- onChange={handleFileSelect}
93
- className="hidden"
94
- />
95
- <Button
96
- // DOM pass-through onto the widget's real focusable control — the
97
- // upload button is the keyboard path to the hidden file input
98
- // (objectui#3318).
99
- {...toDomProps(props)}
100
- type="button"
101
- variant="outline"
102
- size="sm"
103
- onClick={() => fileInputRef.current?.click()}
104
- disabled={readonly || props.disabled}
105
- // AFTER the spread so this widget's own computation wins (#3222).
106
- aria-invalid={!!error}
107
- >
108
- <Upload className="w-4 h-4 mr-2" />
109
- {value ? 'Change' : 'Upload'} Avatar
110
- </Button>
111
- <p className="text-xs text-muted-foreground">
112
- PNG, JPG up to 5MB
113
- </p>
114
- </div>
115
- </div>
116
- );
117
- }
@@ -1,86 +0,0 @@
1
- import React, { useId } from 'react';
2
- import { Switch, Checkbox, Label } from '@object-ui/components';
3
- import { FieldWidgetComponentProps } from './types';
4
- import { toDomProps } from './toDomProps';
5
-
6
- /**
7
- * BooleanField - Toggle input supporting switch and checkbox variants
8
- * Renders as Switch or Checkbox based on field widget configuration
9
- */
10
- export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<boolean>) {
11
- const config = field as any;
12
- // Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
13
- const widget = config?.widget;
14
- // Generate unique ID using React's useId hook - must be before early returns (rules of hooks)
15
- const generatedId = useId();
16
- /**
17
- * The HOST owns the control's id (objectui#3952).
18
- *
19
- * `<FormControl>` is a Radix `Slot` that hands its child the id its
20
- * `<FormLabel htmlFor>` already points at (`_r_N_-form-item`). This widget
21
- * used to REPLACE it with the field name, so the form's visible label
22
- * referenced an id no element carried: `label for="_r_3_-form-item"` against
23
- * `all ids in form = ["notifications"]`. Clicking the label — the normal
24
- * affordance for a switch/checkbox row — toggled nothing, on every generated
25
- * form in every app.
26
- *
27
- * `id` is a DECLARED key of the widget contract (`FieldWidgetDomProps`) and
28
- * `toDomProps` forwards it, so honouring it here is the contract's own
29
- * direction: the fix belongs at the consumer that discarded a delivered
30
- * value, not in the form renderer (AGENTS.md #0.1).
31
- *
32
- * The fallback chain stays for the STANDALONE hosts that hand down no id —
33
- * `FieldEditWidget` (the grid's inline cell editor) and a bare SDUI node.
34
- */
35
- const hostId = props.id;
36
- const id = hostId || config?.name || generatedId;
37
- const label = config?.label || 'Checkbox';
38
- /**
39
- * A host that supplies the id is a host that renders its own label for it
40
- * (that is what the id is FOR), so the widget's `sr-only` label would be the
41
- * second copy of one name — the other half of #3952, and why a naive
42
- * `getByText(label)` on a boolean field returned two nodes.
43
- *
44
- * Standalone it is the control's ONLY accessible name (`FieldEditWidget`
45
- * renders no label of its own), so there it stays. Deliberately keyed on the
46
- * host id rather than on `config.label`: inside a form the naming is the
47
- * host's job for EVERY widget — a text field in a label-less form row is
48
- * equally unnamed — and the generic `'Checkbox'` fallback emitted there was
49
- * an actively WRONG accessible name, not a safety net.
50
- */
51
- const emitOwnLabel = !hostId;
52
-
53
- if (readonly) {
54
- return <span className="text-sm">{value ? 'Yes' : 'No'}</span>;
55
- }
56
-
57
- const domProps = toDomProps(props);
58
-
59
- if (widget === 'checkbox') {
60
- return (
61
- <div className="flex items-center space-x-2">
62
- <Checkbox
63
- {...domProps}
64
- id={id}
65
- checked={!!value}
66
- onCheckedChange={(checked) => onChange(!!checked)}
67
- disabled={readonly || domProps.disabled}
68
- />
69
- {emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
70
- </div>
71
- )
72
- }
73
-
74
- return (
75
- <div className="flex items-center space-x-2">
76
- <Switch
77
- {...domProps}
78
- id={id}
79
- checked={!!value}
80
- onCheckedChange={onChange}
81
- disabled={readonly || domProps.disabled}
82
- />
83
- {emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
84
- </div>
85
- );
86
- }
@@ -1,168 +0,0 @@
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
- * CapabilityMultiSelectField (ADR-0056 P2, epic #2398).
11
- *
12
- * Proves the two properties P2 depends on:
13
- * 1. the stored value parses tolerantly into a capability-name list, and
14
- * 2. every edit emits a JSON-**string** array of names — byte-equivalent to
15
- * the `sys_permission_set.system_permissions` textarea storage, so the
16
- * round-trip through the picker never changes the on-disk shape.
17
- */
18
- import { describe, it, expect, vi } from 'vitest';
19
- import React from 'react';
20
- import { render, screen, fireEvent } from '@testing-library/react';
21
- import '@testing-library/jest-dom';
22
- import { CapabilityMultiSelectField, parseCapabilityNames } from './CapabilityMultiSelectField';
23
-
24
- describe('parseCapabilityNames', () => {
25
- it('parses the canonical JSON-string array', () => {
26
- expect(parseCapabilityNames('["setup.access","studio.access"]')).toEqual([
27
- 'setup.access',
28
- 'studio.access',
29
- ]);
30
- });
31
-
32
- it('treats empty / null / undefined as no selection', () => {
33
- expect(parseCapabilityNames('')).toEqual([]);
34
- expect(parseCapabilityNames(' ')).toEqual([]);
35
- expect(parseCapabilityNames(null)).toEqual([]);
36
- expect(parseCapabilityNames(undefined)).toEqual([]);
37
- expect(parseCapabilityNames('[]')).toEqual([]);
38
- });
39
-
40
- it('tolerates an already-parsed array', () => {
41
- expect(parseCapabilityNames(['a', 'b'])).toEqual(['a', 'b']);
42
- });
43
-
44
- it('falls back to comma-splitting a non-JSON legacy string', () => {
45
- expect(parseCapabilityNames('setup.access, studio.access')).toEqual([
46
- 'setup.access',
47
- 'studio.access',
48
- ]);
49
- });
50
- });
51
-
52
- const CAPS = [
53
- { name: 'manage_users', label: 'Manage Users', description: 'Manage users', scope: 'platform', active: true },
54
- { name: 'studio.access', label: 'Studio Access', description: 'Enter Studio', scope: 'platform', active: true },
55
- { name: 'manage_org_users', label: 'Manage Org Users', description: 'Org members', scope: 'org', active: true },
56
- ];
57
-
58
- function mockDataSource(rows = CAPS) {
59
- return { find: vi.fn().mockResolvedValue({ data: rows }) } as any;
60
- }
61
-
62
- describe('CapabilityMultiSelectField', () => {
63
- it('renders capabilities grouped by scope with human labels', async () => {
64
- render(
65
- <CapabilityMultiSelectField
66
- value={'["studio.access"]'}
67
- onChange={vi.fn()}
68
- field={{ name: 'system_permissions' } as any}
69
- dataSource={mockDataSource()}
70
- />,
71
- );
72
- // group headers
73
- expect(await screen.findByText('Platform')).toBeInTheDocument();
74
- expect(screen.getByText('Organization')).toBeInTheDocument();
75
- // labels, not raw names
76
- expect(screen.getByRole('button', { name: 'Studio Access' })).toHaveAttribute('aria-pressed', 'true');
77
- expect(screen.getByRole('button', { name: 'Manage Users' })).toHaveAttribute('aria-pressed', 'false');
78
- });
79
-
80
- it('emits a JSON-string array when a capability is toggled on', async () => {
81
- const onChange = vi.fn();
82
- render(
83
- <CapabilityMultiSelectField
84
- value={'["studio.access"]'}
85
- onChange={onChange}
86
- field={{ name: 'system_permissions' } as any}
87
- dataSource={mockDataSource()}
88
- />,
89
- );
90
- fireEvent.click(await screen.findByRole('button', { name: 'Manage Users' }));
91
- expect(onChange).toHaveBeenCalledTimes(1);
92
- const emitted = onChange.mock.calls[0][0];
93
- // The emitted value must be a JSON *string* (not a raw array), byte-equal
94
- // to the textarea storage contract.
95
- expect(typeof emitted).toBe('string');
96
- expect(JSON.parse(emitted)).toEqual(['studio.access', 'manage_users']);
97
- });
98
-
99
- it('removes a capability (and keeps emitting a JSON string) when toggled off', async () => {
100
- const onChange = vi.fn();
101
- render(
102
- <CapabilityMultiSelectField
103
- value={'["studio.access","manage_users"]'}
104
- onChange={onChange}
105
- field={{ name: 'system_permissions' } as any}
106
- dataSource={mockDataSource()}
107
- />,
108
- );
109
- // Chip labels now resolve through the i18n bundle (objectui#2600 B5), so an
110
- // async translation-settle can re-render the chips between findBy and the
111
- // click — re-query at click time so we act on the live node, not a detached one.
112
- await screen.findByRole('button', { name: 'Studio Access' });
113
- fireEvent.click(screen.getByRole('button', { name: 'Studio Access' }));
114
- const emitted = onChange.mock.calls[0][0];
115
- expect(typeof emitted).toBe('string');
116
- expect(JSON.parse(emitted)).toEqual(['manage_users']);
117
- });
118
-
119
- it('preserves an unknown/legacy selected name not in the registry', async () => {
120
- render(
121
- <CapabilityMultiSelectField
122
- value={'["studio.access","legacy.custom"]'}
123
- onChange={vi.fn()}
124
- field={{ name: 'system_permissions' } as any}
125
- dataSource={mockDataSource()}
126
- />,
127
- );
128
- // The unknown name still renders as a (selected) chip so it is not dropped.
129
- const legacy = await screen.findByRole('button', { name: 'legacy.custom' });
130
- expect(legacy).toHaveAttribute('aria-pressed', 'true');
131
- });
132
-
133
- it('renders selected labels as read-only badges when readonly', async () => {
134
- render(
135
- <CapabilityMultiSelectField
136
- value={'["studio.access"]'}
137
- onChange={vi.fn()}
138
- field={{ name: 'system_permissions' } as any}
139
- dataSource={mockDataSource()}
140
- readonly
141
- />,
142
- );
143
- // No toggle buttons in readonly mode.
144
- expect(await screen.findByText('Studio Access')).toBeInTheDocument();
145
- expect(screen.queryByRole('button', { name: 'Manage Users' })).not.toBeInTheDocument();
146
- });
147
-
148
- // objectui#2600 B5 — curated platform caps get a localized label; package/
149
- // admin-authored caps keep whatever label the registry served.
150
- it('localizes curated capability labels but preserves registry labels for others', async () => {
151
- render(
152
- <CapabilityMultiSelectField
153
- value={'[]'}
154
- onChange={vi.fn()}
155
- field={{ name: 'system_permissions' } as any}
156
- dataSource={mockDataSource([
157
- // Registry sends a shorter label; the curated client map wins.
158
- { name: 'manage_org_users', label: 'Manage Org Users', description: 'Org members', scope: 'org', active: true },
159
- // Not a curated platform capability — its registry label is kept.
160
- { name: 'export_data', label: 'Export Data', description: 'Export', scope: 'org', active: true },
161
- ])}
162
- />,
163
- );
164
- expect(await screen.findByRole('button', { name: 'Manage Organization Users' })).toBeInTheDocument();
165
- expect(screen.queryByRole('button', { name: 'Manage Org Users' })).not.toBeInTheDocument();
166
- expect(screen.getByRole('button', { name: 'Export Data' })).toBeInTheDocument();
167
- });
168
- });