@object-ui/fields 17.1.0 → 17.3.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 +786 -0
- package/dist/FieldEditWidget.d.ts +2 -2
- package/dist/__tests__/cell-truncation.test.d.ts +0 -0
- package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +8 -0
- package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +8 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2882 -2569
- package/dist/widgets/AddressField.d.ts +2 -2
- package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/AutoNumberField.d.ts +2 -2
- package/dist/widgets/AvatarField.d.ts +2 -2
- package/dist/widgets/BooleanField.d.ts +2 -2
- package/dist/widgets/CapabilityMultiSelectField.d.ts +2 -2
- package/dist/widgets/CharacterCount.d.ts +56 -0
- package/dist/widgets/CheckboxesField.d.ts +2 -2
- package/dist/widgets/CodeField.d.ts +2 -2
- package/dist/widgets/ColorField.d.ts +2 -2
- package/dist/widgets/CurrencyField.d.ts +2 -2
- package/dist/widgets/DateField.d.ts +2 -2
- package/dist/widgets/DateTimeField.d.ts +2 -2
- package/dist/widgets/EmailField.d.ts +2 -2
- package/dist/widgets/FileField.d.ts +2 -2
- package/dist/widgets/FilterConditionField.d.ts +2 -2
- package/dist/widgets/FormulaField.d.ts +2 -2
- package/dist/widgets/FullscreenFieldEditor.d.ts +155 -0
- package/dist/widgets/GeolocationField.d.ts +2 -2
- package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/GridField.d.ts +2 -2
- package/dist/widgets/ImageField.d.ts +2 -2
- package/dist/widgets/LocationField.d.ts +2 -2
- package/dist/widgets/LookupField.d.ts +2 -2
- package/dist/widgets/LookupField.gateHintLabel.test.d.ts +8 -0
- package/dist/widgets/MasterDetailField.d.ts +2 -2
- package/dist/widgets/MultiSelectField.d.ts +2 -2
- package/dist/widgets/NumberField.d.ts +2 -2
- package/dist/widgets/ObjectField.d.ts +2 -2
- package/dist/widgets/ObjectRefField.d.ts +2 -2
- package/dist/widgets/OptionsEmptyState.d.ts +45 -0
- package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
- package/dist/widgets/PasswordField.d.ts +2 -2
- package/dist/widgets/PercentField.d.ts +2 -2
- package/dist/widgets/PhoneField.d.ts +2 -2
- package/dist/widgets/QRCodeField.d.ts +2 -2
- package/dist/widgets/RadioField.d.ts +2 -2
- package/dist/widgets/RatingField.d.ts +2 -2
- package/dist/widgets/RecipientPickerField.d.ts +2 -2
- package/dist/widgets/RecordPickerDialog.d.ts +17 -1
- package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +8 -0
- package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +8 -0
- package/dist/widgets/RichTextField.d.ts +20 -2
- package/dist/widgets/SelectField.d.ts +2 -2
- package/dist/widgets/SignatureField.d.ts +2 -2
- package/dist/widgets/SliderField.d.ts +2 -2
- package/dist/widgets/SummaryField.d.ts +2 -2
- package/dist/widgets/TagsField.d.ts +2 -2
- package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
- package/dist/widgets/TextAreaField.d.ts +45 -6
- package/dist/widgets/TextField.d.ts +2 -2
- package/dist/widgets/TimeField.d.ts +2 -2
- package/dist/widgets/UrlField.d.ts +2 -2
- package/dist/widgets/UserField.d.ts +2 -2
- package/dist/widgets/VectorField.d.ts +2 -2
- package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/file-value.d.ts +20 -33
- package/dist/widgets/toDomProps.d.ts +91 -0
- package/dist/widgets/types.d.ts +215 -4
- package/dist/withFieldCarrier.d.ts +56 -0
- package/package.json +13 -13
- package/src/FieldEditWidget.tsx +6 -4
- package/src/__tests__/cell-truncation.test.tsx +96 -0
- package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
- package/src/__tests__/spec-symbol-batch7.test.ts +126 -0
- 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 +598 -0
- package/src/__tests__/widget-props-contract.test.tsx +142 -0
- package/src/complex-widgets.test.tsx +3 -3
- package/src/datetime-widgets.test.tsx +2 -2
- package/src/index.tsx +77 -27
- package/src/widgets/AddressField.tsx +32 -13
- package/src/widgets/AddressField.uniqueIds.test.tsx +92 -0
- package/src/widgets/AutoNumberField.tsx +2 -2
- package/src/widgets/AvatarField.tsx +10 -3
- package/src/widgets/BooleanField.tsx +5 -5
- package/src/widgets/CapabilityMultiSelectField.tsx +4 -4
- package/src/widgets/CharacterCount.tsx +263 -0
- package/src/widgets/CheckboxesField.tsx +35 -18
- package/src/widgets/CodeField.tsx +10 -3
- package/src/widgets/ColorField.tsx +12 -3
- package/src/widgets/CurrencyField.tsx +6 -5
- package/src/widgets/DateField.tsx +4 -4
- package/src/widgets/DateTimeField.tsx +4 -4
- package/src/widgets/EmailField.tsx +6 -6
- package/src/widgets/FileField.tsx +14 -4
- package/src/widgets/FilterConditionField.tsx +14 -3
- package/src/widgets/FormulaField.tsx +3 -3
- package/src/widgets/FullscreenFieldEditor.tsx +271 -0
- package/src/widgets/GeolocationField.tsx +22 -7
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
- package/src/widgets/GridField.tsx +4 -4
- package/src/widgets/ImageField.tsx +10 -3
- package/src/widgets/LocationField.tsx +10 -3
- package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
- package/src/widgets/LookupField.tsx +50 -15
- package/src/widgets/MasterDetailField.tsx +3 -3
- package/src/widgets/MultiSelectField.tsx +34 -18
- package/src/widgets/NumberField.tsx +5 -5
- package/src/widgets/ObjectField.tsx +18 -9
- package/src/widgets/ObjectRefField.tsx +14 -4
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
- package/src/widgets/OptionsEmptyState.test.tsx +169 -0
- package/src/widgets/OptionsEmptyState.tsx +83 -0
- package/src/widgets/PasswordField.tsx +5 -5
- package/src/widgets/PeoplePicker.tsx +7 -1
- package/src/widgets/PercentField.tsx +6 -5
- package/src/widgets/PhoneField.tsx +6 -6
- package/src/widgets/QRCodeField.tsx +10 -3
- package/src/widgets/RadioField.tsx +28 -18
- package/src/widgets/RatingField.tsx +19 -4
- package/src/widgets/RecipientPickerField.tsx +22 -4
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
- package/src/widgets/RecordPickerDialog.tsx +173 -8
- package/src/widgets/RichTextField.tsx +152 -25
- package/src/widgets/SelectField.tsx +53 -22
- package/src/widgets/SignatureField.tsx +2 -2
- package/src/widgets/SliderField.tsx +3 -3
- package/src/widgets/SummaryField.tsx +3 -3
- 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 +25 -4
- package/src/widgets/TextAreaField.tsx +172 -94
- package/src/widgets/TextField.tsx +5 -5
- package/src/widgets/TimeField.tsx +4 -4
- package/src/widgets/UrlField.tsx +6 -6
- package/src/widgets/UserField.tsx +3 -3
- package/src/widgets/VectorField.tsx +3 -3
- 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/file-value.ts +21 -5
- package/src/widgets/toDomProps.ts +175 -0
- package/src/widgets/types.ts +230 -7
- package/src/widgets/useFieldTranslation.ts +60 -0
- package/src/withFieldCarrier.tsx +75 -0
|
@@ -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,83 @@
|
|
|
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
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The "this option list cannot be filled" state shared by every fixed-option
|
|
15
|
+
* widget (`SelectField` single, `MultiSelectField`, `RadioField`,
|
|
16
|
+
* `CheckboxesField`) — objectui#3231.
|
|
17
|
+
*
|
|
18
|
+
* ## One producer, one consumer
|
|
19
|
+
*
|
|
20
|
+
* `emptyHint` is a declared prop on `FieldWidgetComponentProps`, computed by
|
|
21
|
+
* the form renderer (`form.tsx`, the `#2284` dependency gate) and forwarded to
|
|
22
|
+
* these widgets. **When the host supplies it, it wins**: the host resolves the
|
|
23
|
+
* controlling fields to their human LABELS ("Select Country first"), where a
|
|
24
|
+
* widget on its own only knows the raw metadata names ("Select country first").
|
|
25
|
+
* Absent a host hint — a standalone widget, the inline grid editor, an action
|
|
26
|
+
* param dialog — the widget falls back to its own copy.
|
|
27
|
+
*
|
|
28
|
+
* Each of the four widgets used to inline this box, destructure `emptyHint`
|
|
29
|
+
* into `_emptyHint` and throw it away, then render a hardcoded English literal.
|
|
30
|
+
* Four independent copies is precisely why they drifted together, so the box
|
|
31
|
+
* AND the copy live here only: adding a fifth option widget cannot re-introduce
|
|
32
|
+
* the gap without deliberately re-implementing it.
|
|
33
|
+
*
|
|
34
|
+
* ## Fallback copy is translated
|
|
35
|
+
*
|
|
36
|
+
* The fallback goes through `useFieldTranslation()` (`fields.options.*`,
|
|
37
|
+
* present in all ten locale packs) rather than an English string literal — the
|
|
38
|
+
* gate sentence is the SAME i18n key the form renderer uses, so the two can
|
|
39
|
+
* never say different things in the same locale.
|
|
40
|
+
*/
|
|
41
|
+
export interface OptionsEmptyStateProps {
|
|
42
|
+
/**
|
|
43
|
+
* Host-computed hint. Wins over the widget's own copy whenever it is a
|
|
44
|
+
* non-empty string — this is the whole point of the prop.
|
|
45
|
+
*/
|
|
46
|
+
emptyHint?: string;
|
|
47
|
+
/** The list is waiting on a `dependsOn` controlling field (vs. unconfigured). */
|
|
48
|
+
gated: boolean;
|
|
49
|
+
/** Controlling field names, used by the fallback gate sentence. */
|
|
50
|
+
dependsOnFields: readonly string[];
|
|
51
|
+
/** Widget-specific stable locator, e.g. `select-empty-${fieldName}`. */
|
|
52
|
+
testId?: string;
|
|
53
|
+
/** Widget-specific sizing (the dropdown is a fixed `h-9`, lists grow). */
|
|
54
|
+
className?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function OptionsEmptyState({
|
|
58
|
+
emptyHint,
|
|
59
|
+
gated,
|
|
60
|
+
dependsOnFields,
|
|
61
|
+
testId,
|
|
62
|
+
className,
|
|
63
|
+
}: OptionsEmptyStateProps) {
|
|
64
|
+
const { t } = useFieldTranslation();
|
|
65
|
+
// The host's hint when it computed one; otherwise this widget's own copy,
|
|
66
|
+
// translated. Never an English literal — that was the reported defect.
|
|
67
|
+
const hint =
|
|
68
|
+
emptyHint ||
|
|
69
|
+
(gated
|
|
70
|
+
? t('fields.options.selectFirst', { fields: dependsOnFields.join(' / ') })
|
|
71
|
+
: t('fields.options.empty'));
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
data-testid={testId}
|
|
75
|
+
className={cn(
|
|
76
|
+
'flex w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground',
|
|
77
|
+
className,
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{hint}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -2,22 +2,22 @@ import React, { useState } from 'react';
|
|
|
2
2
|
import { Input } from '@object-ui/components';
|
|
3
3
|
import { Button } from '@object-ui/components';
|
|
4
4
|
import { Eye, EyeOff } from 'lucide-react';
|
|
5
|
-
import {
|
|
5
|
+
import { FieldWidgetComponentProps } from './types';
|
|
6
|
+
import { toDomProps } from './toDomProps';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* PasswordField - Secure password input with visibility toggle
|
|
9
10
|
* Includes show/hide toggle button and masks value in readonly mode
|
|
10
11
|
*/
|
|
11
|
-
export function PasswordField({ value, onChange, field, readonly, className, ...props }:
|
|
12
|
+
export function PasswordField({ value, onChange, field, readonly, className, ...props }: FieldWidgetComponentProps<string>) {
|
|
12
13
|
const [showPassword, setShowPassword] = useState(false);
|
|
13
|
-
const config = field
|
|
14
|
+
const config = field;
|
|
14
15
|
|
|
15
16
|
if (readonly) {
|
|
16
17
|
return <span className="text-sm">••••••••</span>;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
const { inputType, ...domProps } = props as any;
|
|
20
|
+
const domProps = toDomProps(props);
|
|
21
21
|
|
|
22
22
|
return (
|
|
23
23
|
<div className="relative">
|
|
@@ -316,8 +316,14 @@ export function PeoplePicker({
|
|
|
316
316
|
// returns the same records (StrictMode double-effect, refetch-on-focus)
|
|
317
317
|
// re-emits a new array; resetting on identity alone yanked the cursor to -1
|
|
318
318
|
// mid-navigation (flaky ArrowDown→Enter, and a real UX nit).
|
|
319
|
+
//
|
|
320
|
+
// The id separator is the escaped U+0000 spelling, not the byte itself
|
|
321
|
+
// (objectstack#5450): one raw NUL made this whole file binary to grep and
|
|
322
|
+
// ripgrep — a content search printed `binary file matches` and no line. The
|
|
323
|
+
// runtime value is unchanged, and it is the same separator the `cursorEpoch`
|
|
324
|
+
// key below already spelled this way.
|
|
319
325
|
const recordsSignature = useMemo(
|
|
320
|
-
() => query.records.map(r => String(getPersonId(r, idField))).join('
|
|
326
|
+
() => query.records.map(r => String(getPersonId(r, idField))).join('\u0000'),
|
|
321
327
|
[query.records, idField],
|
|
322
328
|
);
|
|
323
329
|
// The reset runs in the RENDER PHASE (the "adjusting state during render"
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, Slider, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
|
+
import { toDomProps } from './toDomProps';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* PercentField - Percentage input with configurable decimal precision
|
|
7
8
|
* Stores values as decimals (0-1) and displays as percentages (0-100%)
|
|
8
9
|
* Includes a slider for interactive control.
|
|
9
10
|
*/
|
|
10
|
-
export function PercentField({ value, onChange, field, readonly,
|
|
11
|
-
const percentField =
|
|
11
|
+
export function PercentField({ value, onChange, field, readonly, error, className, ...props }: FieldWidgetComponentProps<number>) {
|
|
12
|
+
const percentField = field as any;
|
|
12
13
|
const precision = percentField?.precision ?? 2;
|
|
13
14
|
|
|
14
15
|
// Convention detection. A field declaring `max > 1` (e.g. `max: 100`) stores
|
|
@@ -64,7 +65,7 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c
|
|
|
64
65
|
<div className="space-y-2">
|
|
65
66
|
<div className="relative">
|
|
66
67
|
<Input
|
|
67
|
-
{...props}
|
|
68
|
+
{...toDomProps(props)}
|
|
68
69
|
type="number"
|
|
69
70
|
value={displayValue}
|
|
70
71
|
onChange={handleChange}
|
|
@@ -72,7 +73,7 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c
|
|
|
72
73
|
disabled={readonly || props.disabled}
|
|
73
74
|
className={`pr-8 ${className || ''}`}
|
|
74
75
|
step={Math.pow(10, -precision).toFixed(precision)}
|
|
75
|
-
aria-invalid={!!
|
|
76
|
+
aria-invalid={!!error}
|
|
76
77
|
/>
|
|
77
78
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">
|
|
78
79
|
%
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
|
+
import { toDomProps } from './toDomProps';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* PhoneField - Telephone number input with tel link in readonly mode
|
|
7
8
|
* Renders as a clickable tel link when readonly, standard phone input otherwise
|
|
8
9
|
*/
|
|
9
|
-
export function PhoneField({ value, onChange, field, readonly,
|
|
10
|
-
const config = field
|
|
10
|
+
export function PhoneField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
|
|
11
|
+
const config = field;
|
|
11
12
|
if (readonly) {
|
|
12
13
|
if (!value) return <EmptyValue />;
|
|
13
14
|
return (
|
|
@@ -20,8 +21,7 @@ export function PhoneField({ value, onChange, field, readonly, errorMessage, ...
|
|
|
20
21
|
);
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
const { inputType, ...domProps } = props as any;
|
|
24
|
+
const domProps = toDomProps(props);
|
|
25
25
|
|
|
26
26
|
return (
|
|
27
27
|
<Input
|
|
@@ -31,7 +31,7 @@ export function PhoneField({ value, onChange, field, readonly, errorMessage, ...
|
|
|
31
31
|
onChange={(e) => onChange(e.target.value)}
|
|
32
32
|
placeholder={config?.placeholder || '(555) 123-4567'}
|
|
33
33
|
disabled={readonly || domProps.disabled}
|
|
34
|
-
aria-invalid={!!
|
|
34
|
+
aria-invalid={!!error}
|
|
35
35
|
/>
|
|
36
36
|
);
|
|
37
37
|
}
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, Button, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { QrCode, Copy } from 'lucide-react';
|
|
4
|
-
import {
|
|
4
|
+
import { FieldWidgetComponentProps } from './types';
|
|
5
|
+
import { toDomProps } from './toDomProps';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* QR Code field widget - generates QR codes from text
|
|
8
9
|
* Uses a simple SVG-based QR code generator
|
|
9
10
|
*/
|
|
10
|
-
export function QRCodeField({ value, onChange, field, readonly, ...props }:
|
|
11
|
+
export function QRCodeField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
|
|
11
12
|
const [showQR, setShowQR] = React.useState(false);
|
|
12
|
-
const config = field
|
|
13
|
+
const config = field;
|
|
13
14
|
|
|
14
15
|
// Simple QR code generation using an external library would be ideal
|
|
15
16
|
// For now, we'll use a service API approach or placeholder
|
|
@@ -47,12 +48,18 @@ export function QRCodeField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
47
48
|
<div className="space-y-3">
|
|
48
49
|
<div className="flex items-center gap-2">
|
|
49
50
|
<Input
|
|
51
|
+
// DOM pass-through onto the real focusable control (objectui#3318).
|
|
52
|
+
{...toDomProps(props)}
|
|
50
53
|
type="text"
|
|
51
54
|
value={value || ''}
|
|
52
55
|
onChange={(e) => onChange(e.target.value)}
|
|
53
56
|
placeholder={config?.placeholder || 'Enter text for QR code'}
|
|
54
57
|
disabled={readonly || props.disabled}
|
|
55
58
|
className={props.className}
|
|
59
|
+
// AFTER the spread so this widget's own computation wins: `error`
|
|
60
|
+
// is the published validation slot (#3222), `!!undefined` →
|
|
61
|
+
// explicit "false".
|
|
62
|
+
aria-invalid={!!error}
|
|
56
63
|
/>
|
|
57
64
|
{value && (
|
|
58
65
|
<>
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import React, { useId, useEffect } from 'react';
|
|
2
2
|
import { RadioGroup, RadioGroupItem, Label, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { isValueStillOffered, type OptionLike } from '@object-ui/core';
|
|
4
|
-
import {
|
|
4
|
+
import { FieldWidgetComponentProps } from './types';
|
|
5
|
+
import { toDomProps } from './toDomProps';
|
|
6
|
+
import { OptionsEmptyState } from './OptionsEmptyState';
|
|
5
7
|
import { useCascadingOptions } from './useCascadingOptions';
|
|
6
8
|
|
|
7
9
|
type Option = OptionLike;
|
|
@@ -24,17 +26,17 @@ export function RadioField({
|
|
|
24
26
|
field,
|
|
25
27
|
readonly,
|
|
26
28
|
className,
|
|
27
|
-
schema,
|
|
28
29
|
dependentValues,
|
|
29
30
|
dependsOn: dependsOnProp,
|
|
30
|
-
emptyHint
|
|
31
|
+
emptyHint,
|
|
31
32
|
dataSource: _dataSource,
|
|
33
|
+
error,
|
|
32
34
|
...props
|
|
33
|
-
}:
|
|
34
|
-
const config =
|
|
35
|
+
}: FieldWidgetComponentProps<string>) {
|
|
36
|
+
const config = field as any;
|
|
35
37
|
const rawOptions: Option[] = config?.options || [];
|
|
36
38
|
const groupId = useId();
|
|
37
|
-
const fieldName =
|
|
39
|
+
const fieldName = props.name || config?.name || props.id || '';
|
|
38
40
|
|
|
39
41
|
const dependsOn = config?.dependsOn ?? dependsOnProp;
|
|
40
42
|
const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
|
|
@@ -61,28 +63,36 @@ export function RadioField({
|
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
// No offered options is unfillable — surface a legible state instead of an
|
|
64
|
-
// empty radio group:
|
|
65
|
-
//
|
|
66
|
+
// empty radio group: the host's `emptyHint` when it computed one, else this
|
|
67
|
+
// widget's own translated copy. Shared with the select / multiselect /
|
|
68
|
+
// checkboxes so the four cannot drift again (objectui#3231).
|
|
66
69
|
if (options.length === 0) {
|
|
67
|
-
const hint = gated
|
|
68
|
-
? `Select ${dependsOnFields.join(' / ')} first`
|
|
69
|
-
: 'No options available';
|
|
70
70
|
return (
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
{
|
|
76
|
-
|
|
71
|
+
<OptionsEmptyState
|
|
72
|
+
emptyHint={emptyHint}
|
|
73
|
+
gated={gated}
|
|
74
|
+
dependsOnFields={dependsOnFields}
|
|
75
|
+
testId={fieldName ? `radio-empty-${fieldName}` : undefined}
|
|
76
|
+
className="min-h-9"
|
|
77
|
+
/>
|
|
77
78
|
);
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
return (
|
|
82
|
+
// DOM pass-through onto the radiogroup (objectui#3318). Unlike Radix
|
|
83
|
+
// `Select.Root` (#3306) this Root IS a real DOM element — a
|
|
84
|
+
// `<div role="radiogroup">` — and `radiogroup` is exactly the role
|
|
85
|
+
// WAI-ARIA designates to carry `aria-invalid` for a set of radios
|
|
86
|
+
// (`radio` itself does not support it): the group's state is announced
|
|
87
|
+
// when focus lands on any radio inside it.
|
|
81
88
|
<RadioGroup
|
|
89
|
+
{...toDomProps(props)}
|
|
82
90
|
value={value ?? ''}
|
|
83
91
|
onValueChange={onChange}
|
|
84
|
-
disabled={
|
|
92
|
+
disabled={props.disabled}
|
|
85
93
|
className={className}
|
|
94
|
+
// AFTER the spread so this widget's own computation wins (#3222).
|
|
95
|
+
aria-invalid={!!error}
|
|
86
96
|
>
|
|
87
97
|
{options.map((opt) => {
|
|
88
98
|
// Radix speaks strings — stringify the (possibly numeric,
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Star } from 'lucide-react';
|
|
3
3
|
import { cn } from '@object-ui/components';
|
|
4
|
-
import {
|
|
4
|
+
import { FieldWidgetComponentProps } from './types';
|
|
5
|
+
import { toDomProps } from './toDomProps';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Rating field widget - provides a star rating input
|
|
8
9
|
* Supports numeric values from 0 to max (default 5)
|
|
9
10
|
*/
|
|
10
|
-
export function RatingField({ value, onChange, field, readonly, className, ...props }:
|
|
11
|
+
export function RatingField({ value, onChange, field, readonly, className, error, ...props }: FieldWidgetComponentProps<number>) {
|
|
11
12
|
// Get rating-specific configuration from field metadata
|
|
12
|
-
const ratingField =
|
|
13
|
+
const ratingField = field as any;
|
|
13
14
|
const max = ratingField?.max ?? 5;
|
|
14
15
|
const currentValue = value ?? 0;
|
|
15
16
|
|
|
@@ -37,8 +38,21 @@ export function RatingField({ value, onChange, field, readonly, className, ...pr
|
|
|
37
38
|
);
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
// DOM pass-through (objectui#3318): the container carries the form
|
|
42
|
+
// renderer's id / aria-describedby, but NOT its `aria-invalid` — a plain
|
|
43
|
+
// wrapper div is not where assistive tech reads the invalid state. That
|
|
44
|
+
// state goes onto every focusable star button below, computed from the
|
|
45
|
+
// published `error` slot (#3222). `name` is withheld too: it is only
|
|
46
|
+
// DOM-legal on form controls, and on this div it is exactly the leak
|
|
47
|
+
// #3291 sweeps for.
|
|
48
|
+
const {
|
|
49
|
+
'aria-invalid': _hostAriaInvalid,
|
|
50
|
+
name: _domName,
|
|
51
|
+
...groupDomProps
|
|
52
|
+
} = toDomProps(props);
|
|
53
|
+
|
|
40
54
|
return (
|
|
41
|
-
<div className={cn("flex items-center gap-1", className)}>
|
|
55
|
+
<div {...groupDomProps} className={cn("flex items-center gap-1", className)}>
|
|
42
56
|
{Array.from({ length: max }, (_, i) => (
|
|
43
57
|
<button
|
|
44
58
|
key={i}
|
|
@@ -48,6 +62,7 @@ export function RatingField({ value, onChange, field, readonly, className, ...pr
|
|
|
48
62
|
onMouseLeave={() => setHoverValue(null)}
|
|
49
63
|
className="focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded"
|
|
50
64
|
disabled={readonly || props.disabled}
|
|
65
|
+
aria-invalid={!!error}
|
|
51
66
|
>
|
|
52
67
|
<Star
|
|
53
68
|
className={`w-5 h-5 transition-colors ${
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Combobox, EmptyValue, cn } from '@object-ui/components';
|
|
3
3
|
import { SchemaRendererContext } from '@object-ui/react';
|
|
4
|
-
import type {
|
|
4
|
+
import type { FieldWidgetComponentProps } from './types';
|
|
5
|
+
import { toDomProps } from './toDomProps';
|
|
5
6
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -53,12 +54,13 @@ export function RecipientPickerField({
|
|
|
53
54
|
onChange,
|
|
54
55
|
readonly,
|
|
55
56
|
className,
|
|
57
|
+
error,
|
|
56
58
|
...props
|
|
57
|
-
}:
|
|
59
|
+
}: FieldWidgetComponentProps<string>) {
|
|
58
60
|
const ctx = React.useContext(SchemaRendererContext);
|
|
59
61
|
const { t } = useFieldTranslation();
|
|
60
|
-
const dataSource: any =
|
|
61
|
-
const disabled =
|
|
62
|
+
const dataSource: any = props.dataSource ?? (ctx as any)?.dataSource ?? null;
|
|
63
|
+
const disabled = props.disabled;
|
|
62
64
|
const dependentValues: Record<string, any> = (props as any).dependentValues ?? {};
|
|
63
65
|
const recipientType = String(dependentValues.recipient_type ?? '');
|
|
64
66
|
const mapping = TYPE_TO_OBJECT[recipientType];
|
|
@@ -133,6 +135,8 @@ export function RecipientPickerField({
|
|
|
133
135
|
// field is never un-editable.
|
|
134
136
|
return (
|
|
135
137
|
<input
|
|
138
|
+
// DOM pass-through onto the real focusable control (objectui#3318).
|
|
139
|
+
{...toDomProps(props)}
|
|
136
140
|
className={cn(
|
|
137
141
|
'w-full rounded border bg-background px-2 py-1 text-sm',
|
|
138
142
|
className,
|
|
@@ -140,6 +144,7 @@ export function RecipientPickerField({
|
|
|
140
144
|
value={value ?? ''}
|
|
141
145
|
disabled={disabled || readonly}
|
|
142
146
|
onChange={(e) => onChange(e.target.value as any)}
|
|
147
|
+
aria-invalid={!!error}
|
|
143
148
|
/>
|
|
144
149
|
);
|
|
145
150
|
}
|
|
@@ -149,8 +154,19 @@ export function RecipientPickerField({
|
|
|
149
154
|
return <span className={className}>{options.find((o) => o.value === value)?.label ?? value}</span>;
|
|
150
155
|
}
|
|
151
156
|
|
|
157
|
+
// DOM pass-through onto the combobox trigger — the widget's real focusable
|
|
158
|
+
// control (objectui#3318). `name` is withheld: the trigger is a button, not
|
|
159
|
+
// a submission control (same reasoning as #3306's SelectTrigger).
|
|
160
|
+
//
|
|
161
|
+
// NOTE this widget stays on the #3318 ledger regardless: its dependency-
|
|
162
|
+
// gated state (no `recipient_type` chosen yet — the state a fresh form and
|
|
163
|
+
// the registry sweep render) is a plain hint paragraph with no focusable
|
|
164
|
+
// control, so there is nothing there to carry the attribute.
|
|
165
|
+
const { name: _domName, ...triggerDomProps } = toDomProps(props);
|
|
166
|
+
|
|
152
167
|
return (
|
|
153
168
|
<Combobox
|
|
169
|
+
{...triggerDomProps}
|
|
154
170
|
options={options}
|
|
155
171
|
value={value ?? ''}
|
|
156
172
|
onValueChange={(v) => onChange(v as any)}
|
|
@@ -163,6 +179,8 @@ export function RecipientPickerField({
|
|
|
163
179
|
emptyText={records === null ? t('fields.recipient.loading') : t('fields.recipient.empty')}
|
|
164
180
|
disabled={disabled}
|
|
165
181
|
className={cn('w-full', className)}
|
|
182
|
+
// AFTER the spread so this widget's own computation wins (#3222).
|
|
183
|
+
aria-invalid={!!error}
|
|
166
184
|
/>
|
|
167
185
|
);
|
|
168
186
|
}
|