@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,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
- }