@object-ui/fields 17.5.0 → 17.6.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 (229) hide show
  1. package/CHANGELOG.md +714 -0
  2. package/dist/FieldEditWidget.d.ts +16 -2
  3. package/dist/field-type-alias.d.ts +16 -0
  4. package/dist/index.cjs +4 -4
  5. package/dist/index.css +27 -9
  6. package/dist/index.d.ts +425 -72
  7. package/dist/index.js +2530 -2228
  8. package/dist/widgets/AddressField.d.ts +3 -3
  9. package/dist/widgets/AutoNumberField.d.ts +1 -1
  10. package/dist/widgets/AvatarField.d.ts +1 -1
  11. package/dist/widgets/BooleanField.d.ts +1 -1
  12. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  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/DateField.d.ts +1 -1
  18. package/dist/widgets/DateTimeField.d.ts +1 -1
  19. package/dist/widgets/EmailField.d.ts +1 -1
  20. package/dist/widgets/FileField.d.ts +18 -2
  21. package/dist/widgets/FilterConditionField.d.ts +28 -1
  22. package/dist/widgets/FormulaField.d.ts +1 -1
  23. package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
  24. package/dist/widgets/GeolocationField.d.ts +1 -1
  25. package/dist/widgets/GridField.d.ts +56 -4
  26. package/dist/widgets/ImageField.d.ts +1 -1
  27. package/dist/widgets/LocationField.d.ts +1 -1
  28. package/dist/widgets/LookupField.d.ts +1 -1
  29. package/dist/widgets/MasterDetailField.d.ts +1 -1
  30. package/dist/widgets/MultiSelectField.d.ts +1 -1
  31. package/dist/widgets/NumberField.d.ts +1 -1
  32. package/dist/widgets/ObjectField.d.ts +1 -1
  33. package/dist/widgets/ObjectRefField.d.ts +1 -1
  34. package/dist/widgets/OptionsEmptyState.d.ts +13 -7
  35. package/dist/widgets/PasswordField.d.ts +1 -1
  36. package/dist/widgets/PercentField.d.ts +1 -1
  37. package/dist/widgets/PhoneField.d.ts +1 -1
  38. package/dist/widgets/QRCodeField.d.ts +1 -1
  39. package/dist/widgets/RadioField.d.ts +1 -1
  40. package/dist/widgets/RatingField.d.ts +1 -1
  41. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  42. package/dist/widgets/RecordPickerDialog.d.ts +2 -4
  43. package/dist/widgets/RichTextField.d.ts +37 -1
  44. package/dist/widgets/SelectField.d.ts +1 -1
  45. package/dist/widgets/SignatureField.d.ts +32 -2
  46. package/dist/widgets/SliderField.d.ts +37 -2
  47. package/dist/widgets/SummaryField.d.ts +1 -1
  48. package/dist/widgets/TagsField.d.ts +1 -1
  49. package/dist/widgets/TextAreaField.d.ts +6 -2
  50. package/dist/widgets/TextField.d.ts +1 -1
  51. package/dist/widgets/TimeField.d.ts +1 -1
  52. package/dist/widgets/UrlField.d.ts +1 -1
  53. package/dist/widgets/UserField.d.ts +1 -1
  54. package/dist/widgets/VectorField.d.ts +1 -1
  55. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  56. package/dist/widgets/address-format.d.ts +27 -7
  57. package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
  58. package/dist/widgets/richTextDisplay.d.ts +80 -0
  59. package/dist/widgets/toHostGroupProps.d.ts +119 -21
  60. package/dist/widgets/types.d.ts +8 -3
  61. package/package.json +9 -10
  62. package/dist/widgets/CharacterCount.d.ts +0 -56
  63. package/src/FieldEditWidget.test.ts +0 -133
  64. package/src/FieldEditWidget.tsx +0 -207
  65. package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
  66. package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
  67. package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
  68. package/src/__tests__/DateCellRenderer.test.tsx +0 -82
  69. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
  70. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  71. package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
  72. package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
  73. package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
  74. package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
  75. package/src/__tests__/cell-truncation.test.tsx +0 -96
  76. package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
  77. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
  78. package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
  79. package/src/__tests__/date-locale-channel.test.tsx +0 -305
  80. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  81. package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
  82. package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
  83. package/src/__tests__/group-labelling-declaration.test.ts +0 -148
  84. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  85. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  86. package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
  87. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  88. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
  89. package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
  90. package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
  91. package/src/__tests__/registry-collision.test.ts +0 -91
  92. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  93. package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
  94. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  95. package/src/__tests__/validation-feedback.test.tsx +0 -361
  96. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  97. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  98. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
  99. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  100. package/src/coerce-safe-value.test.ts +0 -34
  101. package/src/complex-widgets.test.tsx +0 -924
  102. package/src/currency.ts +0 -144
  103. package/src/datetime-widgets.test.tsx +0 -185
  104. package/src/field-type-alias.multiple.test.ts +0 -81
  105. package/src/field-type-alias.ts +0 -156
  106. package/src/field-type-coverage.test.ts +0 -67
  107. package/src/index.css +0 -44
  108. package/src/index.tsx +0 -2745
  109. package/src/standard-widgets.test.tsx +0 -364
  110. package/src/widgets/AddressField.postalCode.test.tsx +0 -257
  111. package/src/widgets/AddressField.tsx +0 -182
  112. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
  113. package/src/widgets/AutoNumberField.tsx +0 -18
  114. package/src/widgets/AvatarField.tsx +0 -117
  115. package/src/widgets/BooleanField.tsx +0 -86
  116. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  117. package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
  118. package/src/widgets/CharacterCount.tsx +0 -263
  119. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  120. package/src/widgets/CheckboxesField.tsx +0 -169
  121. package/src/widgets/CodeField.tsx +0 -41
  122. package/src/widgets/ColorField.tsx +0 -53
  123. package/src/widgets/CurrencyField.tsx +0 -135
  124. package/src/widgets/DateField.tsx +0 -40
  125. package/src/widgets/DateTimeField.tsx +0 -46
  126. package/src/widgets/EmailField.tsx +0 -37
  127. package/src/widgets/FileField.tsx +0 -512
  128. package/src/widgets/FilterConditionField.tsx +0 -454
  129. package/src/widgets/FormulaField.tsx +0 -38
  130. package/src/widgets/FullscreenFieldEditor.tsx +0 -60
  131. package/src/widgets/GeolocationField.tsx +0 -193
  132. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  133. package/src/widgets/GridField.test.tsx +0 -632
  134. package/src/widgets/GridField.tsx +0 -1070
  135. package/src/widgets/ImageCropperDialog.tsx +0 -245
  136. package/src/widgets/ImageField.maxSize.test.tsx +0 -154
  137. package/src/widgets/ImageField.tsx +0 -305
  138. package/src/widgets/ImageLightbox.test.tsx +0 -52
  139. package/src/widgets/ImageLightbox.tsx +0 -108
  140. package/src/widgets/LocationField.tsx +0 -57
  141. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  142. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  143. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  144. package/src/widgets/LookupField.idField.test.tsx +0 -61
  145. package/src/widgets/LookupField.tsx +0 -1295
  146. package/src/widgets/MarkdownContent.tsx +0 -19
  147. package/src/widgets/MasterDetailField.tsx +0 -131
  148. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  149. package/src/widgets/MultiSelectField.tsx +0 -196
  150. package/src/widgets/NumberField.tsx +0 -49
  151. package/src/widgets/ObjectField.tsx +0 -94
  152. package/src/widgets/ObjectRefField.tsx +0 -121
  153. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  154. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  155. package/src/widgets/OptionsEmptyState.tsx +0 -99
  156. package/src/widgets/PasswordField.tsx +0 -49
  157. package/src/widgets/PeoplePicker.test.tsx +0 -360
  158. package/src/widgets/PeoplePicker.tsx +0 -579
  159. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  160. package/src/widgets/PercentField.tsx +0 -95
  161. package/src/widgets/PersonRow.tsx +0 -117
  162. package/src/widgets/PhoneField.tsx +0 -37
  163. package/src/widgets/QRCodeField.tsx +0 -103
  164. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  165. package/src/widgets/RadioField.tsx +0 -143
  166. package/src/widgets/RatingField.tsx +0 -94
  167. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  168. package/src/widgets/RecipientPickerField.tsx +0 -188
  169. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
  170. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
  171. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  172. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  173. package/src/widgets/RecordPickerDialog.tsx +0 -1365
  174. package/src/widgets/RichTextField.tsx +0 -176
  175. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  176. package/src/widgets/SelectField.tsx +0 -193
  177. package/src/widgets/SelectionTray.tsx +0 -110
  178. package/src/widgets/SignatureField.tsx +0 -140
  179. package/src/widgets/SliderField.tsx +0 -39
  180. package/src/widgets/SummaryField.tsx +0 -31
  181. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  182. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  183. package/src/widgets/TagsField.tsx +0 -88
  184. package/src/widgets/TextAreaField.tsx +0 -213
  185. package/src/widgets/TextField.tsx +0 -45
  186. package/src/widgets/TimeField.tsx +0 -31
  187. package/src/widgets/UrlField.tsx +0 -46
  188. package/src/widgets/UserField.tsx +0 -59
  189. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  190. package/src/widgets/VectorField.tsx +0 -29
  191. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  192. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
  193. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  194. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  195. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  196. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  197. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  198. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  199. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  200. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  201. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  202. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  203. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  204. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  205. package/src/widgets/address-format.ts +0 -120
  206. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  207. package/src/widgets/deriveLookupColumns.ts +0 -108
  208. package/src/widgets/file-size-guard.ts +0 -55
  209. package/src/widgets/file-value.test.ts +0 -230
  210. package/src/widgets/file-value.ts +0 -246
  211. package/src/widgets/nativeDateValue.test.ts +0 -115
  212. package/src/widgets/nativeDateValue.ts +0 -112
  213. package/src/widgets/openNativePicker.ts +0 -18
  214. package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
  215. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
  216. package/src/widgets/personDisplay.test.ts +0 -113
  217. package/src/widgets/personDisplay.ts +0 -92
  218. package/src/widgets/recentLookups.test.ts +0 -45
  219. package/src/widgets/recentLookups.ts +0 -47
  220. package/src/widgets/toDomProps.ts +0 -204
  221. package/src/widgets/toHostGroupProps.ts +0 -102
  222. package/src/widgets/types.ts +0 -242
  223. package/src/widgets/useCascadingOptions.ts +0 -46
  224. package/src/widgets/useFieldTranslation.ts +0 -132
  225. package/src/widgets/useRecordQuery.test.ts +0 -174
  226. package/src/widgets/useRecordQuery.ts +0 -291
  227. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  228. package/src/widgets/useUploadingSignal.ts +0 -20
  229. 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
- });