@object-ui/fields 17.2.0 → 17.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1533 -0
- package/README.md +1 -12
- package/dist/FieldEditWidget.d.ts +7 -1
- package/dist/currency.d.ts +46 -0
- package/dist/field-type-alias.d.ts +19 -1
- package/dist/index.cjs +5 -5
- package/dist/index.css +633 -0
- package/dist/index.d.ts +52 -12
- package/dist/index.js +5167 -4759
- package/dist/widgets/AddressField.d.ts +4 -12
- package/dist/widgets/AvatarField.d.ts +1 -1
- package/dist/widgets/CharacterCount.d.ts +56 -0
- package/dist/widgets/CheckboxesField.d.ts +1 -1
- package/dist/widgets/CodeField.d.ts +1 -1
- package/dist/widgets/ColorField.d.ts +1 -1
- package/dist/widgets/CurrencyField.d.ts +1 -1
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +1 -1
- package/dist/widgets/FilterConditionField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +45 -0
- package/dist/widgets/GeolocationField.d.ts +1 -1
- package/dist/widgets/GridField.d.ts +12 -2
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +1 -1
- package/dist/widgets/LookupField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/ObjectField.d.ts +1 -1
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +59 -0
- package/dist/widgets/PercentField.d.ts +1 -1
- package/dist/widgets/PhoneField.d.ts +1 -1
- package/dist/widgets/QRCodeField.d.ts +1 -1
- package/dist/widgets/RadioField.d.ts +1 -1
- package/dist/widgets/RatingField.d.ts +1 -1
- package/dist/widgets/RecipientPickerField.d.ts +1 -1
- package/dist/widgets/RecordPickerDialog.d.ts +49 -7
- package/dist/widgets/RichTextField.d.ts +19 -1
- package/dist/widgets/SelectField.d.ts +25 -5
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TextAreaField.d.ts +44 -5
- package/dist/widgets/UrlField.d.ts +1 -1
- package/dist/widgets/address-format.d.ts +68 -0
- package/dist/widgets/file-size-guard.d.ts +41 -0
- package/dist/widgets/nativeDateValue.d.ts +37 -0
- package/dist/widgets/toDomProps.d.ts +111 -0
- package/dist/widgets/toHostGroupProps.d.ts +91 -0
- package/dist/widgets/types.d.ts +128 -33
- package/dist/widgets/useRecordQuery.d.ts +7 -1
- package/dist/withFieldCarrier.d.ts +56 -0
- package/package.json +20 -16
- package/src/FieldEditWidget.tsx +17 -1
- package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
- package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
- package/src/__tests__/DateCellRenderer.test.tsx +9 -0
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
- package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
- package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
- package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
- package/src/__tests__/cell-truncation.test.tsx +96 -0
- package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
- package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
- package/src/__tests__/date-locale-channel.test.tsx +305 -0
- package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
- package/src/__tests__/group-labelling-declaration.test.ts +148 -0
- package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
- package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
- package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
- package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
- package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
- package/src/__tests__/validation-feedback.test.tsx +22 -13
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +357 -0
- package/src/__tests__/widget-props-contract.test.tsx +22 -9
- package/src/complex-widgets.test.tsx +22 -6
- package/src/currency.ts +130 -0
- package/src/datetime-widgets.test.tsx +94 -2
- package/src/field-type-alias.multiple.test.ts +81 -0
- package/src/field-type-alias.ts +58 -2
- package/src/index.css +44 -0
- package/src/index.tsx +558 -268
- package/src/standard-widgets.test.tsx +8 -1
- package/src/widgets/AddressField.postalCode.test.tsx +257 -0
- package/src/widgets/AddressField.tsx +107 -38
- package/src/widgets/AddressField.uniqueIds.test.tsx +93 -0
- package/src/widgets/AvatarField.tsx +9 -2
- package/src/widgets/BooleanField.tsx +44 -10
- package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
- package/src/widgets/CharacterCount.tsx +263 -0
- package/src/widgets/CheckboxesField.tsx +66 -16
- package/src/widgets/CodeField.tsx +9 -2
- package/src/widgets/ColorField.tsx +11 -2
- package/src/widgets/CurrencyField.tsx +60 -19
- package/src/widgets/DateField.tsx +13 -4
- package/src/widgets/DateTimeField.tsx +14 -5
- package/src/widgets/EmailField.tsx +5 -5
- package/src/widgets/FileField.tsx +39 -11
- package/src/widgets/FilterConditionField.tsx +11 -0
- package/src/widgets/FormulaField.tsx +7 -2
- package/src/widgets/FullscreenFieldEditor.tsx +60 -0
- package/src/widgets/GeolocationField.tsx +49 -8
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
- package/src/widgets/GridField.test.tsx +226 -0
- package/src/widgets/GridField.tsx +115 -11
- package/src/widgets/ImageField.maxSize.test.tsx +154 -0
- package/src/widgets/ImageField.tsx +63 -6
- package/src/widgets/LocationField.tsx +9 -2
- package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
- package/src/widgets/LookupField.hydration.test.tsx +1 -1
- package/src/widgets/LookupField.tsx +49 -10
- package/src/widgets/MasterDetailField.tsx +1 -1
- package/src/widgets/MultiSelectField.tsx +86 -16
- package/src/widgets/NumberField.tsx +3 -3
- package/src/widgets/ObjectField.tsx +17 -8
- package/src/widgets/ObjectRefField.tsx +10 -0
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
- package/src/widgets/OptionsEmptyState.test.tsx +169 -0
- package/src/widgets/OptionsEmptyState.tsx +99 -0
- package/src/widgets/PasswordField.tsx +3 -3
- package/src/widgets/PeoplePicker.tsx +7 -1
- package/src/widgets/PercentField.tsx +5 -4
- package/src/widgets/PhoneField.tsx +5 -5
- package/src/widgets/QRCodeField.tsx +9 -2
- package/src/widgets/RadioField.tsx +58 -16
- package/src/widgets/RatingField.tsx +32 -4
- package/src/widgets/RecipientPickerField.tsx +18 -0
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
- package/src/widgets/RecordPickerDialog.tsx +235 -21
- package/src/widgets/RichTextField.tsx +151 -24
- package/src/widgets/SelectField.tsx +84 -22
- package/src/widgets/SliderField.tsx +1 -1
- package/src/widgets/SummaryField.tsx +1 -1
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
- package/src/widgets/TagsField.placeholder.test.tsx +95 -0
- package/src/widgets/TagsField.tsx +23 -2
- package/src/widgets/TextAreaField.tsx +171 -93
- package/src/widgets/TextField.tsx +3 -3
- package/src/widgets/TimeField.tsx +2 -2
- package/src/widgets/UrlField.tsx +5 -5
- package/src/widgets/UserField.tsx +4 -3
- package/src/widgets/VectorField.tsx +1 -1
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
- package/src/widgets/address-format.ts +120 -0
- package/src/widgets/file-size-guard.ts +55 -0
- package/src/widgets/nativeDateValue.test.ts +115 -0
- package/src/widgets/nativeDateValue.ts +112 -0
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
- package/src/widgets/toDomProps.ts +204 -0
- package/src/widgets/toHostGroupProps.ts +102 -0
- package/src/widgets/types.ts +135 -35
- package/src/widgets/useFieldTranslation.ts +46 -4
- package/src/widgets/useRecordQuery.ts +30 -3
- package/src/withFieldCarrier.tsx +75 -0
- package/dist/FieldEditWidget.test.d.ts +0 -8
- package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
- package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
- package/dist/__tests__/registry-collision.test.d.ts +0 -0
- package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
- package/dist/__tests__/validation-feedback.test.d.ts +0 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
- package/dist/coerce-safe-value.test.d.ts +0 -1
- package/dist/complex-widgets.test.d.ts +0 -1
- package/dist/datetime-widgets.test.d.ts +0 -1
- package/dist/field-type-coverage.test.d.ts +0 -1
- package/dist/standard-widgets.test.d.ts +0 -0
- package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/GridField.test.d.ts +0 -1
- package/dist/widgets/ImageLightbox.test.d.ts +0 -1
- package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
- package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
- package/dist/widgets/LookupField.idField.test.d.ts +0 -1
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/PeoplePicker.test.d.ts +0 -8
- package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
- package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
- package/dist/widgets/file-value.test.d.ts +0 -1
- package/dist/widgets/personDisplay.test.d.ts +0 -8
- package/dist/widgets/recentLookups.test.d.ts +0 -1
- package/dist/widgets/useRecordQuery.test.d.ts +0 -8
- package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
|
@@ -2,6 +2,9 @@ import React, { useEffect } from 'react';
|
|
|
2
2
|
import { Badge, EmptyValue, cn } from '@object-ui/components';
|
|
3
3
|
import type { OptionLike } from '@object-ui/core';
|
|
4
4
|
import { FieldWidgetComponentProps } from './types';
|
|
5
|
+
import { toDomProps } from './toDomProps';
|
|
6
|
+
import { toHostGroupProps } from './toHostGroupProps';
|
|
7
|
+
import { OptionsEmptyState } from './OptionsEmptyState';
|
|
5
8
|
import { useCascadingOptions } from './useCascadingOptions';
|
|
6
9
|
|
|
7
10
|
interface Option extends OptionLike { color?: string }
|
|
@@ -26,14 +29,14 @@ export function MultiSelectField({
|
|
|
26
29
|
field,
|
|
27
30
|
readonly,
|
|
28
31
|
className,
|
|
29
|
-
schema,
|
|
30
32
|
dependentValues,
|
|
31
33
|
dependsOn: dependsOnProp,
|
|
32
|
-
emptyHint
|
|
34
|
+
emptyHint,
|
|
33
35
|
dataSource: _dataSource,
|
|
36
|
+
error,
|
|
34
37
|
...props
|
|
35
38
|
}: FieldWidgetComponentProps<string[]>) {
|
|
36
|
-
const config =
|
|
39
|
+
const config = field as any;
|
|
37
40
|
const rawOptions: Option[] = config?.options || [];
|
|
38
41
|
const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
|
|
39
42
|
const fieldName = props.name || config?.name || props.id || '';
|
|
@@ -50,16 +53,56 @@ export function MultiSelectField({
|
|
|
50
53
|
// the scalar case we prune per-element rather than clearing the whole field.
|
|
51
54
|
useEffect(() => {
|
|
52
55
|
if (readonly) return;
|
|
56
|
+
// An empty offered set has THREE very different causes, and only one of them
|
|
57
|
+
// is a decision to prune against. This is the canonical copy of the guards;
|
|
58
|
+
// the other three option widgets carry the same pair.
|
|
59
|
+
//
|
|
60
|
+
// 1. Nothing was ever CONFIGURED (objectui#4220) — a field authored with no
|
|
61
|
+
// `options` at all. No decision was made anywhere, and the widget renders
|
|
62
|
+
// its "unfillable" state below. Pruning here is not a cascade, it is
|
|
63
|
+
// deleting the stored value of a field the user was only ever shown a
|
|
64
|
+
// hint for — measured on the detail page's inline editor, which stages
|
|
65
|
+
// that empty array into the record draft the moment the row enters edit
|
|
66
|
+
// mode, and on the grid's inline cell editor, which has always taken this
|
|
67
|
+
// path.
|
|
68
|
+
if (rawOptions.length === 0) return;
|
|
69
|
+
// 2. The list is GATED (objectui#4247) — an authored list withheld because a
|
|
70
|
+
// declared `dependsOn` parent is still empty, so `resolveCascadingOptions`
|
|
71
|
+
// returns nothing at all. Gated means UNKNOWN, not invalid: the widget has
|
|
72
|
+
// no information about which stored values are valid, which is exactly
|
|
73
|
+
// what the `OptionsEmptyState` below tells the user ("select Country
|
|
74
|
+
// first"). Clearing here fired on MOUNT with no interaction — a record
|
|
75
|
+
// that merely ARRIVES with the parent empty (a later-cleared parent, an
|
|
76
|
+
// import, a partially-migrated row) had its picklist silently staged for
|
|
77
|
+
// deletion. Read from the resolver's own `gated` flag rather than
|
|
78
|
+
// re-derived from `options.length === 0`, which would collide with case 1.
|
|
79
|
+
if (gated) return;
|
|
80
|
+
// 3. The list RESOLVED and genuinely excludes the value — a real cascade
|
|
81
|
+
// (the parent changed, a predicate flipped). That is the ADR-0058
|
|
82
|
+
// contract and it still prunes, below.
|
|
53
83
|
if (selected.length === 0) return;
|
|
54
84
|
const stillOffered = selected.filter((v) => options.some((o) => o.value === v));
|
|
55
85
|
if (stillOffered.length !== selected.length) onChange(stillOffered);
|
|
56
86
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
57
87
|
}, [options, gated]);
|
|
58
88
|
|
|
89
|
+
// The host's group label has to be consumed on EVERY surface this widget can
|
|
90
|
+
// render, not only the editable one (objectui#3990): both branches below
|
|
91
|
+
// return before the editable container's `groupDomProps` spread, which is how
|
|
92
|
+
// a field-level `readonly: true` and a zero-option list ended up with a
|
|
93
|
+
// published label id and no consumer at all. See `toHostGroupProps`.
|
|
94
|
+
const hostGroupProps = toHostGroupProps(props);
|
|
95
|
+
|
|
59
96
|
if (readonly) {
|
|
60
|
-
|
|
97
|
+
// A readonly set of values is still a set, so it takes the same `group`
|
|
98
|
+
// answer the editable chip row gives. `EmptyValue` is that surface with
|
|
99
|
+
// nothing in it: it publishes an `aria-label` ("No value") of its own, and
|
|
100
|
+
// `aria-labelledby` outranks that per accname — correctly, because on the
|
|
101
|
+
// `generic` role that placeholder span carries, an author name is prohibited
|
|
102
|
+
// and never exposed, so the real choice is the field's name or no name.
|
|
103
|
+
if (selected.length === 0) return <EmptyValue {...hostGroupProps} />;
|
|
61
104
|
return (
|
|
62
|
-
<div className="flex flex-wrap gap-1">
|
|
105
|
+
<div {...hostGroupProps} className="flex flex-wrap gap-1">
|
|
63
106
|
{selected.map((v) => {
|
|
64
107
|
// Label from the raw set so a stored value hidden by `visibleWhen`
|
|
65
108
|
// still renders its label rather than a bare id.
|
|
@@ -71,19 +114,21 @@ export function MultiSelectField({
|
|
|
71
114
|
}
|
|
72
115
|
|
|
73
116
|
// No offered options is unfillable — surface a legible state instead of an
|
|
74
|
-
// empty chip row:
|
|
75
|
-
//
|
|
117
|
+
// empty chip row: the host's `emptyHint` when it computed one, else this
|
|
118
|
+
// widget's own translated copy. Shared with the single select / radio /
|
|
119
|
+
// checkboxes so the four cannot drift again (objectui#3231).
|
|
76
120
|
if (options.length === 0) {
|
|
77
|
-
const hint = gated
|
|
78
|
-
? `Select ${dependsOnFields.join(' / ')} first`
|
|
79
|
-
: 'No options available';
|
|
80
121
|
return (
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
{
|
|
86
|
-
|
|
122
|
+
<OptionsEmptyState
|
|
123
|
+
emptyHint={emptyHint}
|
|
124
|
+
gated={gated}
|
|
125
|
+
dependsOnFields={dependsOnFields}
|
|
126
|
+
testId={fieldName ? `multiselect-empty-${fieldName}` : undefined}
|
|
127
|
+
className="min-h-9"
|
|
128
|
+
// This box IS the field in this state, so it is what the host label
|
|
129
|
+
// names (objectui#3990).
|
|
130
|
+
hostGroupProps={hostGroupProps}
|
|
131
|
+
/>
|
|
87
132
|
);
|
|
88
133
|
}
|
|
89
134
|
|
|
@@ -92,8 +137,32 @@ export function MultiSelectField({
|
|
|
92
137
|
onChange(next);
|
|
93
138
|
};
|
|
94
139
|
|
|
140
|
+
// DOM pass-through (objectui#3318): the container carries the form
|
|
141
|
+
// renderer's id / aria-describedby, but NOT its `aria-invalid` — a plain
|
|
142
|
+
// wrapper div is not where assistive tech reads the invalid state. That
|
|
143
|
+
// state goes onto every focusable chip button below, computed from the
|
|
144
|
+
// published `error` slot (#3222), so whichever chip the user tabs to
|
|
145
|
+
// announces the failure. `name` is withheld too: it is only DOM-legal on
|
|
146
|
+
// form controls, and on this div it is exactly the leak #3291 sweeps for.
|
|
147
|
+
const {
|
|
148
|
+
'aria-invalid': _hostAriaInvalid,
|
|
149
|
+
name: _domName,
|
|
150
|
+
...groupDomProps
|
|
151
|
+
} = toDomProps(props);
|
|
152
|
+
// When the host associated its visible label with this container by IDREF
|
|
153
|
+
// (`aria-labelledby`, objectui#3961 / #3975) the container IS the labelled
|
|
154
|
+
// group, so it answers with the matching role — without one, the name sits on
|
|
155
|
+
// a generic `div` and contributes nothing: `label for` pointing here was
|
|
156
|
+
// already inert (`HTMLLabelElement.control` is null for a div), which is the
|
|
157
|
+
// whole defect. Each chip keeps its own accessible name from its text content,
|
|
158
|
+
// which the group name does not override. Absent (standalone: the inline grid
|
|
159
|
+
// editor, a bare SDUI node) nothing is emitted and the markup is unchanged.
|
|
160
|
+
const isLabelledGroup = groupDomProps['aria-labelledby'] != null;
|
|
161
|
+
|
|
95
162
|
return (
|
|
96
163
|
<div
|
|
164
|
+
{...groupDomProps}
|
|
165
|
+
role={isLabelledGroup ? 'group' : undefined}
|
|
97
166
|
className={cn('flex flex-wrap gap-1.5', className)}
|
|
98
167
|
data-testid={fieldName ? `multiselect-${fieldName}` : undefined}
|
|
99
168
|
>
|
|
@@ -109,6 +178,7 @@ export function MultiSelectField({
|
|
|
109
178
|
onClick={() => toggle(value)}
|
|
110
179
|
disabled={props.disabled}
|
|
111
180
|
aria-pressed={active}
|
|
181
|
+
aria-invalid={!!error}
|
|
112
182
|
data-testid={`multiselect-option-${opt.value}`}
|
|
113
183
|
className={cn(
|
|
114
184
|
'rounded-full border px-3 py-1 text-sm transition-colors',
|
|
@@ -2,6 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { NumberFieldMetadata } from '@object-ui/types';
|
|
4
4
|
import { FieldWidgetComponentProps } from './types';
|
|
5
|
+
import { toDomProps } from './toDomProps';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* NumberField - Numeric input with optional decimal precision
|
|
@@ -12,7 +13,7 @@ export function NumberField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
12
13
|
return value == null ? <EmptyValue /> : <span className="text-sm">{value}</span>;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
const numberField =
|
|
16
|
+
const numberField = field as NumberFieldMetadata;
|
|
16
17
|
// Step follows `scale` (decimal places), not `precision` (total digit count):
|
|
17
18
|
// a decimal(10, 0) column has 0 decimal places, so the input should step by 1
|
|
18
19
|
// (`scale: 0` is a valid declaration — hence the typeof check, not truthiness).
|
|
@@ -25,8 +26,7 @@ export function NumberField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
25
26
|
? Math.pow(10, -scale)
|
|
26
27
|
: 'any';
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
const { inputType, ...domProps } = props as any;
|
|
29
|
+
const domProps = toDomProps(props);
|
|
30
30
|
|
|
31
31
|
return (
|
|
32
32
|
<Input
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import React, { useState, useEffect } from 'react';
|
|
2
2
|
import { Textarea, cn, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetComponentProps } from './types';
|
|
4
|
+
import { toDomProps } from './toDomProps';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* ObjectField - JSON object editor
|
|
7
8
|
* Allows editing structured JSON data
|
|
8
9
|
*/
|
|
9
|
-
export function ObjectField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<any>) {
|
|
10
|
-
const config = field
|
|
10
|
+
export function ObjectField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<any>) {
|
|
11
|
+
const config = field;
|
|
11
12
|
|
|
12
13
|
// Initialize string state based on value
|
|
13
14
|
const getInitialJsonString = () => {
|
|
@@ -16,7 +17,9 @@ export function ObjectField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
16
17
|
};
|
|
17
18
|
|
|
18
19
|
const [jsonString, setJsonString] = useState(getInitialJsonString);
|
|
19
|
-
|
|
20
|
+
// Named `parseError`, NOT `error`: `error` is the published validation slot
|
|
21
|
+
// on the widget contract (#3222) and is destructured above.
|
|
22
|
+
const [parseError, setParseError] = useState<string | null>(null);
|
|
20
23
|
|
|
21
24
|
// Sync internal string state when value changes externally
|
|
22
25
|
// This is a controlled component pattern where we need to sync external changes
|
|
@@ -53,33 +56,39 @@ export function ObjectField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
53
56
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
54
57
|
const str = e.target.value;
|
|
55
58
|
setJsonString(str);
|
|
56
|
-
|
|
59
|
+
setParseError(null);
|
|
57
60
|
|
|
58
61
|
if (!str.trim()) {
|
|
59
62
|
onChange(null);
|
|
60
63
|
return;
|
|
61
64
|
}
|
|
62
|
-
|
|
65
|
+
|
|
63
66
|
try {
|
|
64
67
|
const parsed = JSON.parse(str);
|
|
65
68
|
onChange(parsed);
|
|
66
69
|
} catch (e) {
|
|
67
70
|
// Invalid JSON - don't propagate change to parent, but keep local state
|
|
68
|
-
|
|
71
|
+
setParseError("Invalid JSON");
|
|
69
72
|
}
|
|
70
73
|
};
|
|
71
74
|
|
|
72
75
|
return (
|
|
73
76
|
<div className="space-y-1">
|
|
74
77
|
<Textarea
|
|
78
|
+
// DOM pass-through onto the real focusable control (objectui#3318).
|
|
79
|
+
{...toDomProps(props)}
|
|
75
80
|
value={jsonString}
|
|
76
81
|
onChange={handleChange}
|
|
77
82
|
placeholder={config?.placeholder || '{\n "key": "value"\n}'}
|
|
78
83
|
disabled={readonly || props.disabled}
|
|
79
|
-
className={cn("font-mono text-xs",
|
|
84
|
+
className={cn("font-mono text-xs", parseError ? "border-red-500 focus-visible:ring-red-500" : "", props.className)}
|
|
80
85
|
rows={6}
|
|
86
|
+
// AFTER the spread so this widget's own computation wins (#3222).
|
|
87
|
+
// Invalid = the form's validation slot OR this widget's own unparsable
|
|
88
|
+
// draft (which already draws the red border above).
|
|
89
|
+
aria-invalid={!!error || !!parseError}
|
|
81
90
|
/>
|
|
82
|
-
{
|
|
91
|
+
{parseError && <p className="text-xs text-red-500">{parseError}</p>}
|
|
83
92
|
</div>
|
|
84
93
|
);
|
|
85
94
|
}
|
|
@@ -2,6 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import { Combobox, EmptyValue, cn } from '@object-ui/components';
|
|
3
3
|
import { SchemaRendererContext } from '@object-ui/react';
|
|
4
4
|
import type { FieldWidgetComponentProps } from './types';
|
|
5
|
+
import { toDomProps } from './toDomProps';
|
|
5
6
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -31,6 +32,7 @@ export function ObjectRefField({
|
|
|
31
32
|
onChange,
|
|
32
33
|
readonly,
|
|
33
34
|
className,
|
|
35
|
+
error,
|
|
34
36
|
...props
|
|
35
37
|
}: FieldWidgetComponentProps<string>) {
|
|
36
38
|
const ctx = React.useContext(SchemaRendererContext);
|
|
@@ -91,8 +93,14 @@ export function ObjectRefField({
|
|
|
91
93
|
return <span className={className}>{label}</span>;
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
// DOM pass-through onto the combobox trigger — the widget's real focusable
|
|
97
|
+
// control (objectui#3318). `name` is withheld: the trigger is a button, not
|
|
98
|
+
// a submission control (same reasoning as #3306's SelectTrigger).
|
|
99
|
+
const { name: _domName, ...triggerDomProps } = toDomProps(props);
|
|
100
|
+
|
|
94
101
|
return (
|
|
95
102
|
<Combobox
|
|
103
|
+
{...triggerDomProps}
|
|
96
104
|
options={options}
|
|
97
105
|
value={value ?? ''}
|
|
98
106
|
onValueChange={(v) => onChange(v as any)}
|
|
@@ -104,6 +112,8 @@ export function ObjectRefField({
|
|
|
104
112
|
// `w-[200px]` is a component default that left this control stranded at
|
|
105
113
|
// a third of the row while `名称` beside it ran full width.
|
|
106
114
|
className={cn('w-full', className)}
|
|
115
|
+
// AFTER the spread so this widget's own computation wins (#3222).
|
|
116
|
+
aria-invalid={!!error}
|
|
107
117
|
/>
|
|
108
118
|
);
|
|
109
119
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The option widgets' empty/gate copy with NO `I18nProvider` mounted
|
|
11
|
+
* (objectui#3231) — standalone/embedded usage, and every test in the repo that
|
|
12
|
+
* renders a field widget bare.
|
|
13
|
+
*
|
|
14
|
+
* Routing the copy through `useFieldTranslation()` must not turn it into a raw
|
|
15
|
+
* i18n key when nothing is configured: `createSafeTranslation` probes the
|
|
16
|
+
* instance and falls back to its English defaults map. That is the behaviour
|
|
17
|
+
* the removed literals used to provide unconditionally, so it gets its own pin.
|
|
18
|
+
*
|
|
19
|
+
* Why a separate FILE rather than another case in `OptionsEmptyState.test.tsx`:
|
|
20
|
+
* mounting `I18nProvider` calls `initReactI18next`, which installs that
|
|
21
|
+
* instance as react-i18next's GLOBAL default. Once any sibling test has done
|
|
22
|
+
* so there is no "no provider" state left to observe in that module graph, and
|
|
23
|
+
* the assertion would silently read the last locale mounted instead. Vitest's
|
|
24
|
+
* per-file isolation (`isolate: true` on the `dom` project) is what makes this
|
|
25
|
+
* file's observation honest.
|
|
26
|
+
*/
|
|
27
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
28
|
+
import React from 'react';
|
|
29
|
+
import { render, screen } from '@testing-library/react';
|
|
30
|
+
import '@testing-library/jest-dom';
|
|
31
|
+
import { SelectField } from './SelectField';
|
|
32
|
+
import { MultiSelectField } from './MultiSelectField';
|
|
33
|
+
import { RadioField } from './RadioField';
|
|
34
|
+
import { CheckboxesField } from './CheckboxesField';
|
|
35
|
+
|
|
36
|
+
const WIDGETS = [
|
|
37
|
+
{ label: 'SelectField', Widget: SelectField, type: 'select', testId: 'select-empty-province' },
|
|
38
|
+
{
|
|
39
|
+
label: 'MultiSelectField',
|
|
40
|
+
Widget: MultiSelectField,
|
|
41
|
+
type: 'multiselect',
|
|
42
|
+
testId: 'multiselect-empty-province',
|
|
43
|
+
},
|
|
44
|
+
{ label: 'RadioField', Widget: RadioField, type: 'radio', testId: 'radio-empty-province' },
|
|
45
|
+
{
|
|
46
|
+
label: 'CheckboxesField',
|
|
47
|
+
Widget: CheckboxesField,
|
|
48
|
+
type: 'checkboxes',
|
|
49
|
+
testId: 'checkboxes-empty-province',
|
|
50
|
+
},
|
|
51
|
+
] as const;
|
|
52
|
+
|
|
53
|
+
const gatedField = (type: string) =>
|
|
54
|
+
({
|
|
55
|
+
name: 'province',
|
|
56
|
+
type,
|
|
57
|
+
dependsOn: 'country',
|
|
58
|
+
options: [{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" }],
|
|
59
|
+
}) as any;
|
|
60
|
+
|
|
61
|
+
const unconfiguredField = (type: string) => ({ name: 'province', type, options: [] }) as any;
|
|
62
|
+
|
|
63
|
+
function renderWidget(Widget: React.ComponentType<any>, field: any) {
|
|
64
|
+
return render(
|
|
65
|
+
<Widget
|
|
66
|
+
value={undefined}
|
|
67
|
+
onChange={vi.fn()}
|
|
68
|
+
field={field}
|
|
69
|
+
{...({ name: 'province', dependentValues: {} } as any)}
|
|
70
|
+
/>,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('option widgets — English fallback survives with no i18n configured', () => {
|
|
75
|
+
it.each(WIDGETS)('$label renders the gate sentence, not a raw key', ({ Widget, type, testId }) => {
|
|
76
|
+
renderWidget(Widget, gatedField(type));
|
|
77
|
+
const box = screen.getByTestId(testId);
|
|
78
|
+
expect(box).toHaveTextContent('Select country first');
|
|
79
|
+
expect(box.textContent).not.toContain('fields.options');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it.each(WIDGETS)('$label renders the empty sentence, not a raw key', ({ Widget, type, testId }) => {
|
|
83
|
+
renderWidget(Widget, unconfiguredField(type));
|
|
84
|
+
const box = screen.getByTestId(testId);
|
|
85
|
+
expect(box).toHaveTextContent('No options available');
|
|
86
|
+
expect(box.textContent).not.toContain('fields.options');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The empty / dependency-gated state of the fixed-option widgets (objectui#3231).
|
|
11
|
+
*
|
|
12
|
+
* Two invariants, pinned for ALL FOUR widgets — they are what regressed:
|
|
13
|
+
*
|
|
14
|
+
* 1. **declared ⇒ delivered.** `emptyHint` is declared on
|
|
15
|
+
* `FieldWidgetComponentProps` and computed by the form renderer. Every one
|
|
16
|
+
* of the four widgets used to destructure it into `_emptyHint` and drop it
|
|
17
|
+
* on the floor, so the host's hint could never reach a user once the field
|
|
18
|
+
* went through the `field:` registry (i.e. always, in the real console).
|
|
19
|
+
* 2. **the fallback is translated.** With no host hint the widget renders its
|
|
20
|
+
* own copy — which used to be a hardcoded English literal
|
|
21
|
+
* (`'No options available'`, `` `Select ${…} first` ``) even though
|
|
22
|
+
* `useFieldTranslation()` was already imported in one of the same files.
|
|
23
|
+
*
|
|
24
|
+
* All four are covered deliberately: they were four independent copies of the
|
|
25
|
+
* same block, which is exactly why they drifted together and why one widget's
|
|
26
|
+
* test would have proven nothing about the other three. They now share
|
|
27
|
+
* `OptionsEmptyState`; these tests keep that true from the outside, so the
|
|
28
|
+
* guarantee survives someone re-inlining the block.
|
|
29
|
+
*/
|
|
30
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
31
|
+
import React from 'react';
|
|
32
|
+
import { render, screen } from '@testing-library/react';
|
|
33
|
+
import '@testing-library/jest-dom';
|
|
34
|
+
import { I18nProvider } from '@object-ui/i18n';
|
|
35
|
+
import { SelectField } from './SelectField';
|
|
36
|
+
import { MultiSelectField } from './MultiSelectField';
|
|
37
|
+
import { RadioField } from './RadioField';
|
|
38
|
+
import { CheckboxesField } from './CheckboxesField';
|
|
39
|
+
|
|
40
|
+
interface WidgetCase {
|
|
41
|
+
label: string;
|
|
42
|
+
Widget: React.ComponentType<any>;
|
|
43
|
+
type: string;
|
|
44
|
+
testId: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const WIDGETS: WidgetCase[] = [
|
|
48
|
+
{ label: 'SelectField', Widget: SelectField, type: 'select', testId: 'select-empty-province' },
|
|
49
|
+
{
|
|
50
|
+
label: 'MultiSelectField',
|
|
51
|
+
Widget: MultiSelectField,
|
|
52
|
+
type: 'multiselect',
|
|
53
|
+
testId: 'multiselect-empty-province',
|
|
54
|
+
},
|
|
55
|
+
{ label: 'RadioField', Widget: RadioField, type: 'radio', testId: 'radio-empty-province' },
|
|
56
|
+
{
|
|
57
|
+
label: 'CheckboxesField',
|
|
58
|
+
Widget: CheckboxesField,
|
|
59
|
+
type: 'checkboxes',
|
|
60
|
+
testId: 'checkboxes-empty-province',
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/** Gated: `country` is empty, so no option is offered yet (#2284). */
|
|
65
|
+
const gatedField = (type: string) =>
|
|
66
|
+
({
|
|
67
|
+
name: 'province',
|
|
68
|
+
type,
|
|
69
|
+
dependsOn: 'country',
|
|
70
|
+
options: [
|
|
71
|
+
{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
|
|
72
|
+
{ label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
|
|
73
|
+
],
|
|
74
|
+
}) as any;
|
|
75
|
+
|
|
76
|
+
/** Not gated, simply nothing to offer — the other way a list ends up empty. */
|
|
77
|
+
const unconfiguredField = (type: string) => ({ name: 'province', type, options: [] }) as any;
|
|
78
|
+
|
|
79
|
+
function renderWidget(
|
|
80
|
+
{ Widget }: WidgetCase,
|
|
81
|
+
field: any,
|
|
82
|
+
extra: Record<string, unknown> = {},
|
|
83
|
+
language?: string,
|
|
84
|
+
) {
|
|
85
|
+
const element = (
|
|
86
|
+
<Widget
|
|
87
|
+
value={undefined}
|
|
88
|
+
onChange={vi.fn()}
|
|
89
|
+
field={field}
|
|
90
|
+
{...({ name: 'province', dependentValues: {}, ...extra } as any)}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
return render(
|
|
94
|
+
language ? (
|
|
95
|
+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
|
|
96
|
+
{element}
|
|
97
|
+
</I18nProvider>
|
|
98
|
+
) : (
|
|
99
|
+
element
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe('option widgets — the host-supplied `emptyHint` reaches the user (objectui#3231)', () => {
|
|
105
|
+
it.each(WIDGETS)(
|
|
106
|
+
'$label renders the host hint instead of its own gate copy',
|
|
107
|
+
(widget) => {
|
|
108
|
+
// What the form renderer actually computes: a sentence built from the
|
|
109
|
+
// controlling field's human LABEL, which the widget cannot know (its
|
|
110
|
+
// metadata only carries the name `country`).
|
|
111
|
+
renderWidget(widget, gatedField(widget.type), {
|
|
112
|
+
emptyHint: 'Choose the Country field first',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const box = screen.getByTestId(widget.testId);
|
|
116
|
+
expect(box).toHaveTextContent('Choose the Country field first');
|
|
117
|
+
// …and NOT the widget's own copy, which is what used to render.
|
|
118
|
+
expect(box.textContent).not.toMatch(/select country first/i);
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
it.each(WIDGETS)(
|
|
123
|
+
'$label renders the host hint for an unconfigured (ungated) list too',
|
|
124
|
+
(widget) => {
|
|
125
|
+
// `emptyHint` is a declared prop of the widget contract, not a private
|
|
126
|
+
// channel of one form-renderer branch: supplied ⇒ used, always.
|
|
127
|
+
renderWidget(widget, unconfiguredField(widget.type), {
|
|
128
|
+
emptyHint: 'Options come from the price book',
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(screen.getByTestId(widget.testId)).toHaveTextContent(
|
|
132
|
+
'Options come from the price book',
|
|
133
|
+
);
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
it.each(WIDGETS)('$label prefers a host hint over its translated fallback', (widget) => {
|
|
138
|
+
// Even under a non-English locale the HOST wins — it is the single source.
|
|
139
|
+
renderWidget(widget, gatedField(widget.type), { emptyHint: 'Pick a Country first' }, 'zh');
|
|
140
|
+
|
|
141
|
+
const box = screen.getByTestId(widget.testId);
|
|
142
|
+
expect(box).toHaveTextContent('Pick a Country first');
|
|
143
|
+
expect(box.textContent).not.toContain('请先选择');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('option widgets — the fallback copy is translated, never a hardcoded literal', () => {
|
|
148
|
+
it.each(WIDGETS)('$label translates the gate copy when no host hint is given', (widget) => {
|
|
149
|
+
renderWidget(widget, gatedField(widget.type), {}, 'zh');
|
|
150
|
+
|
|
151
|
+
const box = screen.getByTestId(widget.testId);
|
|
152
|
+
expect(box).toHaveTextContent('请先选择country');
|
|
153
|
+
expect(box.textContent).not.toMatch(/select .* first/i);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it.each(WIDGETS)('$label translates the empty copy when no host hint is given', (widget) => {
|
|
157
|
+
renderWidget(widget, unconfiguredField(widget.type), {}, 'zh');
|
|
158
|
+
|
|
159
|
+
const box = screen.getByTestId(widget.testId);
|
|
160
|
+
expect(box).toHaveTextContent('暂无可选项');
|
|
161
|
+
expect(box.textContent).not.toMatch(/no options available/i);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// The no-provider fallback lives in `OptionsEmptyState.no-provider.test.tsx`:
|
|
166
|
+
// mounting `I18nProvider` here installs its instance as react-i18next's global
|
|
167
|
+
// default (`initReactI18next`), so after any test above there is no longer a
|
|
168
|
+
// "no provider" state to observe within this module graph.
|
|
169
|
+
|
|
@@ -0,0 +1,99 @@
|
|
|
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 { cn } from '@object-ui/components';
|
|
11
|
+
import { useFieldTranslation } from './useFieldTranslation';
|
|
12
|
+
import type { HostGroupProps } from './toHostGroupProps';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The "this option list cannot be filled" state shared by every fixed-option
|
|
16
|
+
* widget (`SelectField` single, `MultiSelectField`, `RadioField`,
|
|
17
|
+
* `CheckboxesField`) — objectui#3231.
|
|
18
|
+
*
|
|
19
|
+
* ## One producer, one consumer
|
|
20
|
+
*
|
|
21
|
+
* `emptyHint` is a declared prop on `FieldWidgetComponentProps`, computed by
|
|
22
|
+
* the form renderer (`form.tsx`, the `#2284` dependency gate) and forwarded to
|
|
23
|
+
* these widgets. **When the host supplies it, it wins**: the host resolves the
|
|
24
|
+
* controlling fields to their human LABELS ("Select Country first"), where a
|
|
25
|
+
* widget on its own only knows the raw metadata names ("Select country first").
|
|
26
|
+
* Absent a host hint — a standalone widget, the inline grid editor, an action
|
|
27
|
+
* param dialog — the widget falls back to its own copy.
|
|
28
|
+
*
|
|
29
|
+
* Each of the four widgets used to inline this box, destructure `emptyHint`
|
|
30
|
+
* into `_emptyHint` and throw it away, then render a hardcoded English literal.
|
|
31
|
+
* Four independent copies is precisely why they drifted together, so the box
|
|
32
|
+
* AND the copy live here only: adding a fifth option widget cannot re-introduce
|
|
33
|
+
* the gap without deliberately re-implementing it.
|
|
34
|
+
*
|
|
35
|
+
* ## Fallback copy is translated
|
|
36
|
+
*
|
|
37
|
+
* The fallback goes through `useFieldTranslation()` (`fields.options.*`,
|
|
38
|
+
* present in all ten locale packs) rather than an English string literal — the
|
|
39
|
+
* gate sentence is the SAME i18n key the form renderer uses, so the two can
|
|
40
|
+
* never say different things in the same locale.
|
|
41
|
+
*/
|
|
42
|
+
export interface OptionsEmptyStateProps {
|
|
43
|
+
/**
|
|
44
|
+
* Host-computed hint. Wins over the widget's own copy whenever it is a
|
|
45
|
+
* non-empty string — this is the whole point of the prop.
|
|
46
|
+
*/
|
|
47
|
+
emptyHint?: string;
|
|
48
|
+
/** The list is waiting on a `dependsOn` controlling field (vs. unconfigured). */
|
|
49
|
+
gated: boolean;
|
|
50
|
+
/** Controlling field names, used by the fallback gate sentence. */
|
|
51
|
+
dependsOnFields: readonly string[];
|
|
52
|
+
/** Widget-specific stable locator, e.g. `select-empty-${fieldName}`. */
|
|
53
|
+
testId?: string;
|
|
54
|
+
/** Widget-specific sizing (the dropdown is a fixed `h-9`, lists grow). */
|
|
55
|
+
className?: string;
|
|
56
|
+
/**
|
|
57
|
+
* The host label's IDREF plumbing, when this box is the whole rendered surface
|
|
58
|
+
* of a group-labelled field (objectui#3990) — a `checkboxes` / `radio` /
|
|
59
|
+
* `multiselect` whose option list came back empty renders NOTHING but this
|
|
60
|
+
* box, so the visible label named zero elements until it landed here.
|
|
61
|
+
*
|
|
62
|
+
* A closed three-key type rather than an open props tail: only `id`,
|
|
63
|
+
* `aria-labelledby` and the `role` that makes them meaningful may reach this
|
|
64
|
+
* element, and reopening a spread is what objectui#3291 exists to prevent.
|
|
65
|
+
* Absent for the single `SelectField` — it is not group-labelled, its label
|
|
66
|
+
* still uses a plain `for`, and it must keep emitting no such attributes.
|
|
67
|
+
*/
|
|
68
|
+
hostGroupProps?: HostGroupProps;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function OptionsEmptyState({
|
|
72
|
+
emptyHint,
|
|
73
|
+
gated,
|
|
74
|
+
dependsOnFields,
|
|
75
|
+
testId,
|
|
76
|
+
className,
|
|
77
|
+
hostGroupProps,
|
|
78
|
+
}: OptionsEmptyStateProps) {
|
|
79
|
+
const { t } = useFieldTranslation();
|
|
80
|
+
// The host's hint when it computed one; otherwise this widget's own copy,
|
|
81
|
+
// translated. Never an English literal — that was the reported defect.
|
|
82
|
+
const hint =
|
|
83
|
+
emptyHint ||
|
|
84
|
+
(gated
|
|
85
|
+
? t('fields.options.selectFirst', { fields: dependsOnFields.join(' / ') })
|
|
86
|
+
: t('fields.options.empty'));
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
{...hostGroupProps}
|
|
90
|
+
data-testid={testId}
|
|
91
|
+
className={cn(
|
|
92
|
+
'flex w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground',
|
|
93
|
+
className,
|
|
94
|
+
)}
|
|
95
|
+
>
|
|
96
|
+
{hint}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|