@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,176 +0,0 @@
1
- import React from 'react';
2
- import { cn, Textarea, EmptyValue } from '@object-ui/components';
3
- import { useObjectTranslation } from '@object-ui/react';
4
- import { FullscreenFieldEditor } from './FullscreenFieldEditor';
5
- import { FieldWidgetComponentProps } from './types';
6
-
7
- /**
8
- * The rich-text editing surface, rendered by `RichTextField` in BOTH positions:
9
- * inline in the form, and inside the fullscreen dialog.
10
- *
11
- * Sharing it is the point of objectui#3301's acceptance criterion "the dialog
12
- * holds the real editor". Had the dialog inlined a bare `<Textarea>` instead,
13
- * the two surfaces would already disagree (no format indicator) and would keep
14
- * disagreeing every time this widget gains an affordance — whatever the editor
15
- * grows into (a toolbar, a TipTap/Lexical instance, a preview toggle), both
16
- * positions get it at once because there is only one of them.
17
- *
18
- * `fullHeight` is the only difference between the two renderings: inline the
19
- * textarea is `rows`-sized, in the dialog it fills the available height.
20
- */
21
- function RichTextEditorSurface({
22
- value,
23
- onChange,
24
- formatLabel,
25
- hint,
26
- placeholder,
27
- rows,
28
- disabled,
29
- error,
30
- className,
31
- fullHeight,
32
- autoFocus,
33
- textareaTestId,
34
- overlay,
35
- }: {
36
- value: string;
37
- onChange: (next: string) => void;
38
- formatLabel: string;
39
- hint: string;
40
- placeholder?: string;
41
- rows?: number;
42
- disabled?: boolean;
43
- error?: string;
44
- className?: string;
45
- fullHeight?: boolean;
46
- autoFocus?: boolean;
47
- textareaTestId?: string;
48
- /** Absolutely-positioned children over the textarea (the expand affordance). */
49
- overlay?: React.ReactNode;
50
- }) {
51
- return (
52
- <div className={cn('space-y-2', fullHeight && 'flex flex-col h-full')}>
53
- <div className="flex items-center justify-between text-xs text-gray-500">
54
- <span>{formatLabel}</span>
55
- <span className="italic">{hint}</span>
56
- </div>
57
- <div className={cn('relative', fullHeight && 'flex-1 min-h-0')}>
58
- <Textarea
59
- value={value || ''}
60
- onChange={(e) => onChange(e.target.value)}
61
- placeholder={placeholder}
62
- disabled={disabled}
63
- autoFocus={autoFocus}
64
- rows={fullHeight ? undefined : rows}
65
- // `text-base` in the dialog for the same reason `TextAreaField` uses
66
- // it there: sub-16px inputs make iOS Safari zoom on focus, which is
67
- // exactly wrong for a surface the user opened to get more room.
68
- className={cn(
69
- 'font-mono',
70
- fullHeight ? 'text-base h-full min-h-full resize-none' : 'text-sm',
71
- className,
72
- )}
73
- aria-invalid={!!error}
74
- data-testid={textareaTestId}
75
- />
76
- {overlay}
77
- </div>
78
- </div>
79
- );
80
- }
81
-
82
- /**
83
- * Rich text field with markdown/HTML support
84
- * For now, this is a simple textarea. A full implementation would use
85
- * a rich text editor like TipTap, Lexical, or Slate.
86
- *
87
- * Reached by forms as `field:markdown` and `field:html`; both resolve here.
88
- *
89
- * ## Fullscreen editing (objectui#3301)
90
- *
91
- * `ObjectForm` stamps `mobile_fullscreen` onto the metadata of every long-text
92
- * field — textarea AND rich-text — when `ObjectFormSchema.mobile
93
- * .fullscreenLongText` is set, and `ObjectFormSchema.mobile`'s own JSDoc has
94
- * always promised "textarea/rich-text get an expand button". This widget never
95
- * read the flag, so for `field:markdown` / `field:html` that promise did
96
- * nothing at all: the producer stamped, and no consumer existed.
97
- *
98
- * The flag is read off `field` and nowhere else — the single metadata carrier
99
- * since objectui#3233 — matching `TextAreaField` read for read, so a
100
- * misspelled flag stays inert in both widgets rather than being caught by a
101
- * tolerant fallback in one of them. The affordance and dialog themselves come
102
- * from the shared `FullscreenFieldEditor`, so one form-level setting keeps
103
- * producing one behaviour across both widgets.
104
- */
105
- export function RichTextField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
106
- const { t } = useObjectTranslation();
107
- if (readonly) {
108
- return (
109
- <div
110
- className="text-sm prose prose-sm max-w-none"
111
- >
112
- {value || <EmptyValue />}
113
- </div>
114
- );
115
- }
116
-
117
- const richField = field as any;
118
- const rows = richField?.rows || 8;
119
- const format = richField?.format || 'markdown'; // 'markdown' or 'html'
120
- // Same single read as `TextAreaField`: the field metadata is the only
121
- // carrier, and this widget is the second consumer the flag always had.
122
- const showFullscreenButton = Boolean(richField?.mobile_fullscreen);
123
- // Resolved once and given to BOTH renderings of the editor (objectui#3402) —
124
- // exactly like `formatLabel` / `hint` / `placeholder` below, and for the same
125
- // reason. Landing it on the inline surface alone left a disabled rich-text
126
- // field greyed out next to a live expand button whose dialog committed any
127
- // edit through `onCommit`. `disabled` also carries the form's `isSubmitting`.
128
- const disabled = Boolean(props.disabled);
129
-
130
- // Resolved once and handed to BOTH renderings of the editor, so the dialog
131
- // cannot drift into showing different copy than the inline surface.
132
- const formatLabel = t('fields.richText.format', { format, defaultValue: `Format: ${format}` });
133
- const hint = t('fields.richText.basicEditorHint', { defaultValue: 'Rich text editor (basic)' });
134
- const placeholder =
135
- richField?.placeholder ||
136
- t('fields.richText.placeholder', { defaultValue: 'Enter text…' });
137
-
138
- return (
139
- <RichTextEditorSurface
140
- value={value}
141
- onChange={onChange}
142
- formatLabel={formatLabel}
143
- hint={hint}
144
- placeholder={placeholder}
145
- rows={rows}
146
- disabled={readonly || disabled}
147
- error={error}
148
- className={props.className}
149
- overlay={
150
- showFullscreenButton && (
151
- <FullscreenFieldEditor
152
- value={value ?? ''}
153
- onCommit={onChange}
154
- label={richField?.label}
155
- testIdPrefix="richtext"
156
- disabled={disabled}
157
- >
158
- {(draft, setDraft, editorDisabled) => (
159
- <RichTextEditorSurface
160
- value={draft}
161
- onChange={setDraft}
162
- formatLabel={formatLabel}
163
- hint={hint}
164
- placeholder={placeholder}
165
- disabled={editorDisabled}
166
- autoFocus
167
- fullHeight
168
- textareaTestId="richtext-fullscreen-input"
169
- />
170
- )}
171
- </FullscreenFieldEditor>
172
- )
173
- }
174
- />
175
- );
176
- }
@@ -1,138 +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
- * Cascading / role-gated `select` options (#2284).
11
- *
12
- * Exercises the observable, non-Radix behaviour of {@link SelectField}: the
13
- * dependency gate (a "select the parent first" empty-state) and the cascade
14
- * clear (a value dropped when the offered set no longer includes it). The
15
- * per-option filtering itself is unit-tested in `@object-ui/core`
16
- * (`optionRules.test.ts`); here we prove the widget wires `dependentValues` +
17
- * predicate scope into that resolver.
18
- */
19
- import { describe, it, expect, vi, beforeEach } from 'vitest';
20
- import React from 'react';
21
- import { render, screen } from '@testing-library/react';
22
- import '@testing-library/jest-dom';
23
- import { PredicateScopeProvider } from '@object-ui/react';
24
- import { SelectField } from './SelectField';
25
-
26
- const provinceField = {
27
- name: 'province',
28
- type: 'select',
29
- dependsOn: 'country',
30
- options: [
31
- { label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
32
- { label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
33
- ],
34
- } as any;
35
-
36
- beforeEach(() => {
37
- vi.clearAllMocks();
38
- });
39
-
40
- describe('SelectField — dependency gating (#2284)', () => {
41
- it('gates with a "select parent first" hint while the controlling field is empty', () => {
42
- render(
43
- <SelectField
44
- value={undefined}
45
- onChange={vi.fn()}
46
- field={provinceField}
47
- {...({ name: 'province', dependentValues: {} } as any)}
48
- />,
49
- );
50
- const gate = screen.getByTestId('select-empty-province');
51
- expect(gate).toHaveTextContent(/select country first/i);
52
- // Gated → no live combobox is offered.
53
- expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
54
- });
55
-
56
- it('unlocks the combobox once the controlling field is set', () => {
57
- render(
58
- <SelectField
59
- value={undefined}
60
- onChange={vi.fn()}
61
- field={provinceField}
62
- {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
63
- />,
64
- );
65
- expect(screen.queryByTestId('select-empty-province')).not.toBeInTheDocument();
66
- expect(screen.getByRole('combobox')).toBeInTheDocument();
67
- });
68
- });
69
-
70
- describe('SelectField — cascade clear (#2284)', () => {
71
- it('clears a value the parent change no longer offers', () => {
72
- const onChange = vi.fn();
73
- render(
74
- <SelectField
75
- value={'ca'}
76
- onChange={onChange}
77
- field={provinceField}
78
- {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
79
- />,
80
- );
81
- // 'ca' is a US province — under country=cn it is not offered, so it is dropped.
82
- expect(onChange).toHaveBeenCalledWith(undefined);
83
- });
84
-
85
- it('keeps a value that is still offered', () => {
86
- const onChange = vi.fn();
87
- render(
88
- <SelectField
89
- value={'zj'}
90
- onChange={onChange}
91
- field={provinceField}
92
- {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
93
- />,
94
- );
95
- expect(onChange).not.toHaveBeenCalled();
96
- });
97
- });
98
-
99
- describe('SelectField — role / context gating (#2284)', () => {
100
- const tierField = {
101
- name: 'tier',
102
- type: 'select',
103
- options: [
104
- { label: 'Standard', value: 'standard' },
105
- { label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
106
- ],
107
- } as any;
108
-
109
- it('clears an admin-only value for a non-admin (offered set excludes it)', () => {
110
- const onChange = vi.fn();
111
- render(
112
- <PredicateScopeProvider scope={{ current_user: { positions: ['sales'] } }}>
113
- <SelectField
114
- value={'admin_only'}
115
- onChange={onChange}
116
- field={tierField}
117
- {...({ name: 'tier', dependentValues: {} } as any)}
118
- />
119
- </PredicateScopeProvider>,
120
- );
121
- expect(onChange).toHaveBeenCalledWith(undefined);
122
- });
123
-
124
- it('keeps an admin-only value for an admin', () => {
125
- const onChange = vi.fn();
126
- render(
127
- <PredicateScopeProvider scope={{ current_user: { positions: ['admin'] } }}>
128
- <SelectField
129
- value={'admin_only'}
130
- onChange={onChange}
131
- field={tierField}
132
- {...({ name: 'tier', dependentValues: {} } as any)}
133
- />
134
- </PredicateScopeProvider>,
135
- );
136
- expect(onChange).not.toHaveBeenCalled();
137
- });
138
- });
@@ -1,193 +0,0 @@
1
- import React, { useEffect } from 'react';
2
- import {
3
- Select,
4
- SelectContent,
5
- SelectItem,
6
- SelectTrigger,
7
- SelectValue,
8
- EmptyValue,
9
- } from '@object-ui/components';
10
- import { isValueStillOffered } from '@object-ui/core';
11
- import { SelectFieldMetadata } from '@object-ui/types';
12
- import { useFieldTranslation } from './useFieldTranslation';
13
- import { FieldWidgetComponentProps } from './types';
14
- import { toDomProps } from './toDomProps';
15
- import { MultiSelectField } from './MultiSelectField';
16
- import { OptionsEmptyState } from './OptionsEmptyState';
17
- import { useCascadingOptions } from './useCascadingOptions';
18
-
19
- /**
20
- * SelectField - dropdown selection widget.
21
- *
22
- * A field declared `multiple: true` selects zero-or-more values (spec:
23
- * `multiple` is valid on `select`), so it renders the multi-value chip picker
24
- * — the same widget the `multiselect` type uses. Single-value selects keep the
25
- * cascading dropdown below.
26
- *
27
- * The FORM no longer arrives here with `multiple` (objectui#3986):
28
- * `mapFieldTypeToFormType` now resolves a `select` + `multiple: true` field to
29
- * `field:multiselect`, so the object-form path renders `MultiSelectField`
30
- * directly under its own registry id — which is what carries the
31
- * `labelling: 'group'` declaration the host label needs. Deciding the widget at
32
- * the type-resolution layer is what keeps the declaration and the render from
33
- * disagreeing; a delegation invisible to the resolver could not.
34
- *
35
- * The branch below stays because it is NOT dead — measured entrances that reach
36
- * it with `multiple` set, none of which consult that resolver:
37
- *
38
- * - **the inline grid editor** — `FieldEditWidget` looks `select` up in its own
39
- * `EDIT_WIDGETS` table, which SHORT-CIRCUITS before the alias map is
40
- * consulted, and forwards the whole metadata object as `field`;
41
- * - **`ActionParamDialog`** — `paramToField` resolves through
42
- * `resolveFormWidgetType`, which likewise returns `select` from
43
- * `fieldWidgetMap` before reaching the alias map, and carries
44
- * `multiple: param.multiple` on the field it builds;
45
- * - **hand-written SDUI** — a `{ type: 'field:select' }` node whose metadata
46
- * declares `multiple`, which addresses this widget by name.
47
- *
48
- * All three then inherit multi-select from here identically, with no drift.
49
- *
50
- * Both branches resolve per-option `visibleWhen` cascading / role-gating through
51
- * the shared {@link useCascadingOptions} hook (#2715), so single and multi stay
52
- * in lockstep.
53
- */
54
- export function SelectField(props: FieldWidgetComponentProps<any>) {
55
- const config = props.field as SelectFieldMetadata | undefined;
56
- if ((config as any)?.multiple) {
57
- // NOT `toDomProps` — this is a widget-to-widget delegation, not a DOM
58
- // spread. `MultiSelectField` implements the same contract and needs the
59
- // whole of it (`value`, `onChange`, `field`, `dataSource`, …); narrowing
60
- // here to the DOM whitelist would hand it an empty widget.
61
- return <MultiSelectField {...props} />;
62
- }
63
- return <SingleSelectField {...(props as FieldWidgetComponentProps<string>)} />;
64
- }
65
-
66
- /**
67
- * SingleSelectField - single-value dropdown with configurable options.
68
- *
69
- * Supports cascading / role-gated options (#2284): each option may carry a
70
- * `visibleWhen` CEL predicate, evaluated against the live form record +
71
- * `current_user`, so the offered set narrows as a controlling field changes
72
- * (`record.country == 'cn'`) or by role (`'admin' in current_user.positions`). A
73
- * field declares which sibling fields drive its list via `dependsOn`; while any
74
- * of those is empty the control is gated with a "select the parent first" hint,
75
- * mirroring the dependent-lookup UX.
76
- */
77
- function SingleSelectField({
78
- value,
79
- onChange,
80
- field,
81
- readonly,
82
- error,
83
- dependentValues,
84
- dependsOn: dependsOnProp,
85
- emptyHint,
86
- dataSource: _dataSource,
87
- ...props
88
- }: FieldWidgetComponentProps<string>) {
89
- const config = field as SelectFieldMetadata;
90
- const rawOptions = config?.options || [];
91
- const { t } = useFieldTranslation();
92
- // Stable hook for automation/e2e — react-hook-form + Radix Select cannot be
93
- // driven by synthetic DOM events, so e2e must target the trigger/options by a
94
- // deterministic testid keyed on the field name. `props.name` is the
95
- // react-hook-form field name spread in by the form renderer (FormField).
96
- const fieldName = props.name || (config as any)?.name || props.id || '';
97
-
98
- const dependsOn = (config as any)?.dependsOn ?? dependsOnProp;
99
- const { options, gated, dependsOnFields } = useCascadingOptions(
100
- rawOptions,
101
- dependsOn,
102
- dependentValues,
103
- );
104
-
105
- // Cascade clear: once the offered set no longer includes the current value
106
- // (parent changed / predicate flipped), drop it so no stale pair persists.
107
- useEffect(() => {
108
- if (readonly) return;
109
- // Never configured → nothing to prune against; see `MultiSelectField`'s
110
- // copy of this guard for the measured failure (objectui#4220). Kept
111
- // identical across the four option widgets: an authored list is what the
112
- // cascade prunes, and all four render the same `OptionsEmptyState` when
113
- // there is none.
114
- if (rawOptions.length === 0) return;
115
- // Gated → the authored list is withheld until the `dependsOn` parent is
116
- // chosen, so the empty offered set is missing information, not a verdict on
117
- // the stored value; clearing here fired on mount (objectui#4247). Same
118
- // reasoning, at length, in `MultiSelectField`.
119
- if (gated) return;
120
- if (value === undefined || value === null || (value as unknown) === '') return;
121
- if (!isValueStillOffered(value, options)) onChange?.(undefined as unknown as string);
122
- // eslint-disable-next-line react-hooks/exhaustive-deps
123
- }, [options, gated]);
124
-
125
- if (readonly) {
126
- const option = rawOptions.find((o) => o.value === value);
127
- const display = option?.label || value;
128
- return display ? <span className="text-sm">{display}</span> : <EmptyValue />;
129
- }
130
-
131
- // A select with no options is unfillable — a silently-empty Radix dropdown
132
- // reads as "broken widget" and hides the real cause. Surface a legible state:
133
- // the host's `emptyHint` when it computed one (it knows the controlling
134
- // fields' LABELS), else this widget's own translated copy. Shared with the
135
- // other option widgets so the four cannot drift again (objectui#3231).
136
- if (options.length === 0) {
137
- return (
138
- <OptionsEmptyState
139
- emptyHint={emptyHint}
140
- gated={gated}
141
- dependsOnFields={dependsOnFields}
142
- testId={fieldName ? `select-empty-${fieldName}` : undefined}
143
- className="h-9"
144
- />
145
- );
146
- }
147
-
148
- // Radix `Select.Root` renders no DOM element of its own — anything it does
149
- // not recognise (every `aria-*` / `data-*` a host hands this widget) is
150
- // silently DROPPED, never reaching a real element. That is how a required
151
- // select failed validation with the red message on screen while assistive
152
- // tech was told nothing (objectui#3306): the form renderer's `aria-invalid`
153
- // / `aria-describedby` / `aria-required` landed on Root and vanished. The
154
- // DOM pass-through therefore goes to `SelectTrigger` — the focusable
155
- // `<button role="combobox">` a user and their screen reader actually
156
- // interact with — with two deliberate exceptions kept on Root:
157
- //
158
- // - `name`: the ONE whitelist key Root genuinely consumes — it forwards it
159
- // to the hidden native `<select>` that takes part in form submission.
160
- // On the trigger it would sit uselessly on a non-submitter `<button>`.
161
- // - `disabled`: Root is the single authority (it disables trigger, item
162
- // interaction and the hidden select together); forwarding the raw prop to
163
- // the trigger as well would give the state a second, OR-merged author.
164
- const { name: domName, disabled: _domDisabled, ...triggerDomProps } = toDomProps(props);
165
-
166
- return (
167
- <Select
168
- name={domName}
169
- value={value}
170
- onValueChange={onChange}
171
- disabled={readonly || props.disabled}
172
- >
173
- <SelectTrigger
174
- {...triggerDomProps}
175
- data-testid={fieldName ? `select-trigger-${fieldName}` : undefined}
176
- // AFTER the spread so this widget's own computation wins, the #3222
177
- // discipline: `error` is the published validation slot
178
- // (`FieldWidgetPropsSchema`), and `!!undefined` must yield an explicit
179
- // `"false"` — a valid field SAYS it is valid rather than staying mute.
180
- aria-invalid={!!error}
181
- >
182
- <SelectValue placeholder={config?.placeholder || t('common.selectOption')} />
183
- </SelectTrigger>
184
- <SelectContent position="popper">
185
- {options.map((option) => (
186
- <SelectItem key={option.value} value={option.value} data-testid={`select-option-${option.value}`}>
187
- {option.label}
188
- </SelectItem>
189
- ))}
190
- </SelectContent>
191
- </Select>
192
- );
193
- }
@@ -1,110 +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
- import React from 'react';
10
- import { Avatar, AvatarFallback, AvatarImage, cn } from '@object-ui/components';
11
- import { X } from 'lucide-react';
12
- import {
13
- getPersonName,
14
- getPersonInitials,
15
- getPersonAvatarUrl,
16
- getPersonId,
17
- } from './personDisplay';
18
-
19
- /**
20
- * "Selected" transfer-box area (穿梭框式已选区): live echo of the current
21
- * multi-selection as removable avatar chips. Reusable kernel — the future
22
- * org-tree tier composes the same tray beside its right-hand list. Purely
23
- * presentational: it takes the resolved records + an onRemove callback, and
24
- * accepts already-translated `label`/`emptyText` so it stays i18n-agnostic.
25
- */
26
- export interface SelectionTrayProps {
27
- /** Full record objects for the current selection. */
28
- records: any[];
29
- /** Remove a selected record by its id. */
30
- onRemove: (id: any) => void;
31
- displayField?: string;
32
- avatarField?: string;
33
- idField?: string;
34
- /** Header label, e.g. "Selected (3)" — omit to hide the header. */
35
- label?: string;
36
- /** When provided, a "Clear all" action shows in the header while non-empty. */
37
- onClear?: () => void;
38
- /** Localized "Clear all" text (i18n-agnostic — caller passes it). */
39
- clearLabel?: string;
40
- /** Shown when the selection is empty. */
41
- emptyText?: string;
42
- className?: string;
43
- }
44
-
45
- export function SelectionTray({
46
- records,
47
- onRemove,
48
- displayField = 'name',
49
- avatarField = 'image',
50
- idField = 'id',
51
- label,
52
- onClear,
53
- clearLabel,
54
- emptyText,
55
- className,
56
- }: SelectionTrayProps) {
57
- const showHeader = !!label || (!!onClear && records.length > 0);
58
- return (
59
- <div className={cn('flex flex-col gap-1.5', className)} data-testid="selection-tray">
60
- {showHeader && (
61
- <div className="flex items-center justify-between">
62
- {label && <div className="text-xs font-medium text-muted-foreground">{label}</div>}
63
- {onClear && records.length > 0 && (
64
- <button
65
- type="button"
66
- onClick={onClear}
67
- className="text-xs text-muted-foreground transition-colors hover:text-foreground"
68
- data-testid="selection-clear"
69
- >
70
- {clearLabel ?? 'Clear all'}
71
- </button>
72
- )}
73
- </div>
74
- )}
75
- {records.length === 0 ? (
76
- <div className="text-xs text-muted-foreground">{emptyText ?? ''}</div>
77
- ) : (
78
- <div className="flex flex-wrap gap-1.5">
79
- {records.map(record => {
80
- const id = getPersonId(record, idField);
81
- const name = getPersonName(record, displayField);
82
- const avatarUrl = getPersonAvatarUrl(record, avatarField);
83
- const initials = getPersonInitials(name);
84
- return (
85
- <span
86
- key={String(id)}
87
- data-testid="selection-chip"
88
- className="inline-flex items-center gap-1.5 rounded-full border bg-background py-0.5 pl-0.5 pr-1.5 text-sm"
89
- >
90
- <Avatar className="size-6 shrink-0">
91
- {avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
92
- <AvatarFallback className="text-[10px]">{initials}</AvatarFallback>
93
- </Avatar>
94
- <span className="max-w-[10rem] truncate">{name || '—'}</span>
95
- <button
96
- type="button"
97
- onClick={() => onRemove(id)}
98
- aria-label={`Remove ${name}`}
99
- className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
100
- >
101
- <X className="size-3.5" aria-hidden />
102
- </button>
103
- </span>
104
- );
105
- })}
106
- </div>
107
- )}
108
- </div>
109
- );
110
- }