@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
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
import { isValueStillOffered } from '@object-ui/core';
|
|
11
11
|
import { SelectFieldMetadata } from '@object-ui/types';
|
|
12
12
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
13
|
-
import {
|
|
13
|
+
import { FieldWidgetComponentProps } from './types';
|
|
14
|
+
import { toDomProps } from './toDomProps';
|
|
14
15
|
import { MultiSelectField } from './MultiSelectField';
|
|
16
|
+
import { OptionsEmptyState } from './OptionsEmptyState';
|
|
15
17
|
import { useCascadingOptions } from './useCascadingOptions';
|
|
16
18
|
|
|
17
19
|
/**
|
|
@@ -29,12 +31,16 @@ import { useCascadingOptions } from './useCascadingOptions';
|
|
|
29
31
|
* the shared {@link useCascadingOptions} hook (#2715), so single and multi stay
|
|
30
32
|
* in lockstep.
|
|
31
33
|
*/
|
|
32
|
-
export function SelectField(props:
|
|
33
|
-
const config =
|
|
34
|
+
export function SelectField(props: FieldWidgetComponentProps<any>) {
|
|
35
|
+
const config = props.field as SelectFieldMetadata | undefined;
|
|
34
36
|
if ((config as any)?.multiple) {
|
|
37
|
+
// NOT `toDomProps` — this is a widget-to-widget delegation, not a DOM
|
|
38
|
+
// spread. `MultiSelectField` implements the same contract and needs the
|
|
39
|
+
// whole of it (`value`, `onChange`, `field`, `dataSource`, …); narrowing
|
|
40
|
+
// here to the DOM whitelist would hand it an empty widget.
|
|
35
41
|
return <MultiSelectField {...props} />;
|
|
36
42
|
}
|
|
37
|
-
return <SingleSelectField {...(props as
|
|
43
|
+
return <SingleSelectField {...(props as FieldWidgetComponentProps<string>)} />;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
/**
|
|
@@ -53,21 +59,21 @@ function SingleSelectField({
|
|
|
53
59
|
onChange,
|
|
54
60
|
field,
|
|
55
61
|
readonly,
|
|
56
|
-
|
|
62
|
+
error,
|
|
57
63
|
dependentValues,
|
|
58
64
|
dependsOn: dependsOnProp,
|
|
59
|
-
emptyHint
|
|
65
|
+
emptyHint,
|
|
60
66
|
dataSource: _dataSource,
|
|
61
67
|
...props
|
|
62
|
-
}:
|
|
63
|
-
const config =
|
|
68
|
+
}: FieldWidgetComponentProps<string>) {
|
|
69
|
+
const config = field as SelectFieldMetadata;
|
|
64
70
|
const rawOptions = config?.options || [];
|
|
65
71
|
const { t } = useFieldTranslation();
|
|
66
72
|
// Stable hook for automation/e2e — react-hook-form + Radix Select cannot be
|
|
67
73
|
// driven by synthetic DOM events, so e2e must target the trigger/options by a
|
|
68
74
|
// deterministic testid keyed on the field name. `props.name` is the
|
|
69
75
|
// react-hook-form field name spread in by the form renderer (FormField).
|
|
70
|
-
const fieldName =
|
|
76
|
+
const fieldName = props.name || (config as any)?.name || props.id || '';
|
|
71
77
|
|
|
72
78
|
const dependsOn = (config as any)?.dependsOn ?? dependsOnProp;
|
|
73
79
|
const { options, gated, dependsOnFields } = useCascadingOptions(
|
|
@@ -93,30 +99,55 @@ function SingleSelectField({
|
|
|
93
99
|
|
|
94
100
|
// A select with no options is unfillable — a silently-empty Radix dropdown
|
|
95
101
|
// reads as "broken widget" and hides the real cause. Surface a legible state:
|
|
96
|
-
//
|
|
97
|
-
//
|
|
102
|
+
// the host's `emptyHint` when it computed one (it knows the controlling
|
|
103
|
+
// fields' LABELS), else this widget's own translated copy. Shared with the
|
|
104
|
+
// other option widgets so the four cannot drift again (objectui#3231).
|
|
98
105
|
if (options.length === 0) {
|
|
99
|
-
const hint = gated
|
|
100
|
-
? `Select ${dependsOnFields.join(' / ')} first`
|
|
101
|
-
: 'No options available';
|
|
102
106
|
return (
|
|
103
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
{
|
|
108
|
-
|
|
107
|
+
<OptionsEmptyState
|
|
108
|
+
emptyHint={emptyHint}
|
|
109
|
+
gated={gated}
|
|
110
|
+
dependsOnFields={dependsOnFields}
|
|
111
|
+
testId={fieldName ? `select-empty-${fieldName}` : undefined}
|
|
112
|
+
className="h-9"
|
|
113
|
+
/>
|
|
109
114
|
);
|
|
110
115
|
}
|
|
111
116
|
|
|
117
|
+
// Radix `Select.Root` renders no DOM element of its own — anything it does
|
|
118
|
+
// not recognise (every `aria-*` / `data-*` a host hands this widget) is
|
|
119
|
+
// silently DROPPED, never reaching a real element. That is how a required
|
|
120
|
+
// select failed validation with the red message on screen while assistive
|
|
121
|
+
// tech was told nothing (objectui#3306): the form renderer's `aria-invalid`
|
|
122
|
+
// / `aria-describedby` / `aria-required` landed on Root and vanished. The
|
|
123
|
+
// DOM pass-through therefore goes to `SelectTrigger` — the focusable
|
|
124
|
+
// `<button role="combobox">` a user and their screen reader actually
|
|
125
|
+
// interact with — with two deliberate exceptions kept on Root:
|
|
126
|
+
//
|
|
127
|
+
// - `name`: the ONE whitelist key Root genuinely consumes — it forwards it
|
|
128
|
+
// to the hidden native `<select>` that takes part in form submission.
|
|
129
|
+
// On the trigger it would sit uselessly on a non-submitter `<button>`.
|
|
130
|
+
// - `disabled`: Root is the single authority (it disables trigger, item
|
|
131
|
+
// interaction and the hidden select together); forwarding the raw prop to
|
|
132
|
+
// the trigger as well would give the state a second, OR-merged author.
|
|
133
|
+
const { name: domName, disabled: _domDisabled, ...triggerDomProps } = toDomProps(props);
|
|
134
|
+
|
|
112
135
|
return (
|
|
113
136
|
<Select
|
|
114
|
-
{
|
|
137
|
+
name={domName}
|
|
115
138
|
value={value}
|
|
116
139
|
onValueChange={onChange}
|
|
117
140
|
disabled={readonly || props.disabled}
|
|
118
141
|
>
|
|
119
|
-
<SelectTrigger
|
|
142
|
+
<SelectTrigger
|
|
143
|
+
{...triggerDomProps}
|
|
144
|
+
data-testid={fieldName ? `select-trigger-${fieldName}` : undefined}
|
|
145
|
+
// AFTER the spread so this widget's own computation wins, the #3222
|
|
146
|
+
// discipline: `error` is the published validation slot
|
|
147
|
+
// (`FieldWidgetPropsSchema`), and `!!undefined` must yield an explicit
|
|
148
|
+
// `"false"` — a valid field SAYS it is valid rather than staying mute.
|
|
149
|
+
aria-invalid={!!error}
|
|
150
|
+
>
|
|
120
151
|
<SelectValue placeholder={config?.placeholder || t('common.selectOption')} />
|
|
121
152
|
</SelectTrigger>
|
|
122
153
|
<SelectContent position="popper">
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import React, { useRef, useEffect } from 'react';
|
|
2
2
|
import { Button } from '@object-ui/components';
|
|
3
3
|
import { Eraser } from 'lucide-react';
|
|
4
|
-
import {
|
|
4
|
+
import { FieldWidgetComponentProps } from './types';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Signature field widget - provides a signature pad for capturing signatures
|
|
8
8
|
* Outputs signature as a base64 PNG image
|
|
9
9
|
*/
|
|
10
|
-
export function SignatureField({ value, onChange, readonly }:
|
|
10
|
+
export function SignatureField({ value, onChange, readonly }: FieldWidgetComponentProps<string>) {
|
|
11
11
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
12
12
|
const [isDrawing, setIsDrawing] = React.useState(false);
|
|
13
13
|
const [isEmpty, setIsEmpty] = React.useState(!value);
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Slider } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Slider field widget - provides a range slider input
|
|
7
7
|
* Supports numeric values with configurable min, max, and step
|
|
8
8
|
*/
|
|
9
|
-
export function SliderField({ value, onChange, field, readonly, ...props }:
|
|
9
|
+
export function SliderField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<number>) {
|
|
10
10
|
// Get slider-specific configuration from field metadata
|
|
11
|
-
const sliderField =
|
|
11
|
+
const sliderField = field as any;
|
|
12
12
|
const min = sliderField?.min ?? 0;
|
|
13
13
|
const max = sliderField?.max ?? 100;
|
|
14
14
|
const step = sliderField?.step ?? 1;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { EmptyValue } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* SummaryField - Read-only aggregation field
|
|
7
7
|
* Values are aggregated from related records and cannot be edited
|
|
8
8
|
*/
|
|
9
|
-
export function SummaryField({ value, field, ...props }:
|
|
10
|
-
const summaryField =
|
|
9
|
+
export function SummaryField({ value, field, ...props }: FieldWidgetComponentProps<any>) {
|
|
10
|
+
const summaryField = field as any;
|
|
11
11
|
const summaryType = summaryField?.summary_type || 'count';
|
|
12
12
|
|
|
13
13
|
if (value == null) {
|
|
@@ -0,0 +1,68 @@
|
|
|
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 tags widget's placeholder with NO `I18nProvider` mounted
|
|
11
|
+
* (objectui#3342) — standalone/embedded usage, and every test in the repo
|
|
12
|
+
* that renders a field widget bare.
|
|
13
|
+
*
|
|
14
|
+
* Routing the copy through `useFieldTranslation()` must not turn it into a
|
|
15
|
+
* raw i18n key when nothing is configured: `createSafeTranslation` probes the
|
|
16
|
+
* instance and falls back to its English defaults map. This is also where the
|
|
17
|
+
* Commandment #-1 pin lives: the copy shipped FROM CODE (the no-provider
|
|
18
|
+
* default) carries no CJK — Chinese exists only in the zh locale pack and can
|
|
19
|
+
* only arrive through a provider.
|
|
20
|
+
*
|
|
21
|
+
* Why a separate FILE rather than another case in
|
|
22
|
+
* `TagsField.placeholder.test.tsx`: mounting `I18nProvider` calls
|
|
23
|
+
* `initReactI18next`, which installs that instance as react-i18next's GLOBAL
|
|
24
|
+
* default. Once any sibling test has done so there is no "no provider" state
|
|
25
|
+
* left to observe in that module graph. Vitest's per-file isolation is what
|
|
26
|
+
* makes this file's observation honest (same split as
|
|
27
|
+
* `OptionsEmptyState.no-provider.test.tsx`).
|
|
28
|
+
*/
|
|
29
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
30
|
+
import React from 'react';
|
|
31
|
+
import { render, screen } from '@testing-library/react';
|
|
32
|
+
import '@testing-library/jest-dom';
|
|
33
|
+
import { TagsField } from './TagsField';
|
|
34
|
+
|
|
35
|
+
function renderTags(field: Record<string, unknown>) {
|
|
36
|
+
return render(
|
|
37
|
+
<TagsField
|
|
38
|
+
value={[]}
|
|
39
|
+
onChange={vi.fn()}
|
|
40
|
+
field={field as any}
|
|
41
|
+
{...({ name: 'labels' } as any)}
|
|
42
|
+
/>,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const input = () => screen.getByRole('textbox');
|
|
47
|
+
|
|
48
|
+
describe('TagsField placeholder — English fallback survives with no i18n configured', () => {
|
|
49
|
+
it('renders the English default, not a raw key', () => {
|
|
50
|
+
renderTags({ name: 'labels', type: 'tags' });
|
|
51
|
+
expect(input()).toHaveAttribute('placeholder', 'Type and press Enter to add…');
|
|
52
|
+
expect(input().getAttribute('placeholder')).not.toContain('fields.tags');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('the code-shipped default carries no CJK (Commandment #-1)', () => {
|
|
56
|
+
renderTags({ name: 'labels', type: 'tags' });
|
|
57
|
+
// With no provider mounted, whatever renders here came from CODE
|
|
58
|
+
// (FIELD_DEFAULTS) — the commandment pin: no Chinese in the codebase.
|
|
59
|
+
// Escaped ranges on purpose: a literal CJK class would itself violate
|
|
60
|
+
// the commandment this test enforces (same shape as objectui#3321's pin).
|
|
61
|
+
expect(input().getAttribute('placeholder')).not.toMatch(/[\u3000-\u30ff\u4e00-\u9fff]/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('the author-declared field.placeholder still wins without a provider', () => {
|
|
65
|
+
renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' });
|
|
66
|
+
expect(input()).toHaveAttribute('placeholder', 'Add product labels');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
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 tags widget's input placeholder (objectui#3342).
|
|
11
|
+
*
|
|
12
|
+
* It used to be a hardcoded Chinese literal in code — a double violation:
|
|
13
|
+
* Commandment #-1 (English-only codebase) AND a bypass of the i18n channel the
|
|
14
|
+
* sibling widgets already use. The pinned resolution chain is:
|
|
15
|
+
*
|
|
16
|
+
* 1. `field.placeholder` — the field author's declaration always wins;
|
|
17
|
+
* 2. `t('fields.tags.placeholder')` — the widget's own copy, from the
|
|
18
|
+
* locale packs via `useFieldTranslation()`;
|
|
19
|
+
* 3. the English default in FIELD_DEFAULTS when no I18nProvider is mounted
|
|
20
|
+
* (that leg lives in `TagsField.placeholder.no-provider.test.tsx` — see
|
|
21
|
+
* its header for why it must be a separate file).
|
|
22
|
+
*
|
|
23
|
+
* Chinese now lives in the zh locale pack only, which is what the zh-locale
|
|
24
|
+
* case below observes end-to-end.
|
|
25
|
+
*/
|
|
26
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
27
|
+
import React from 'react';
|
|
28
|
+
import { render, screen } from '@testing-library/react';
|
|
29
|
+
import '@testing-library/jest-dom';
|
|
30
|
+
import { I18nProvider } from '@object-ui/i18n';
|
|
31
|
+
import { TagsField } from './TagsField';
|
|
32
|
+
|
|
33
|
+
function renderTags(
|
|
34
|
+
field: Record<string, unknown>,
|
|
35
|
+
{ value = [] as string[], language }: { value?: string[]; language?: string } = {},
|
|
36
|
+
) {
|
|
37
|
+
const element = (
|
|
38
|
+
<TagsField
|
|
39
|
+
value={value}
|
|
40
|
+
onChange={vi.fn()}
|
|
41
|
+
field={field as any}
|
|
42
|
+
{...({ name: 'labels' } as any)}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
return render(
|
|
46
|
+
language ? (
|
|
47
|
+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
|
|
48
|
+
{element}
|
|
49
|
+
</I18nProvider>
|
|
50
|
+
) : (
|
|
51
|
+
element
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const input = () => screen.getByRole('textbox');
|
|
57
|
+
|
|
58
|
+
describe('TagsField placeholder — author declaration beats the widget copy (objectui#3342)', () => {
|
|
59
|
+
it('renders the author-declared field.placeholder', () => {
|
|
60
|
+
renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' }, { language: 'en' });
|
|
61
|
+
expect(input()).toHaveAttribute('placeholder', 'Add product labels');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('the author declaration wins even under a non-English locale', () => {
|
|
65
|
+
// The author's copy is theirs verbatim — a locale switch must not
|
|
66
|
+
// overwrite an explicit declaration with the widget's own translation.
|
|
67
|
+
renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' }, { language: 'zh' });
|
|
68
|
+
expect(input()).toHaveAttribute('placeholder', 'Add product labels');
|
|
69
|
+
expect(input().getAttribute('placeholder')).not.toContain('输入后回车添加');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('TagsField placeholder — undeclared falls back to the translated key', () => {
|
|
74
|
+
it('renders the en pack copy under the en locale', () => {
|
|
75
|
+
renderTags({ name: 'labels', type: 'tags' }, { language: 'en' });
|
|
76
|
+
expect(input()).toHaveAttribute('placeholder', 'Type and press Enter to add…');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('renders the zh pack copy under the zh locale — Chinese comes from the pack, not code', () => {
|
|
80
|
+
renderTags({ name: 'labels', type: 'tags' }, { language: 'zh' });
|
|
81
|
+
expect(input()).toHaveAttribute('placeholder', '输入后回车添加…');
|
|
82
|
+
// …and never the raw key.
|
|
83
|
+
expect(input().getAttribute('placeholder')).not.toContain('fields.tags');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('TagsField placeholder — shown only while the tag list is empty', () => {
|
|
88
|
+
it('is empty once a tag exists (the input is a continuation strip)', () => {
|
|
89
|
+
renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' }, {
|
|
90
|
+
value: ['alpha'],
|
|
91
|
+
language: 'en',
|
|
92
|
+
});
|
|
93
|
+
expect(input()).toHaveAttribute('placeholder', '');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Badge, Input, EmptyValue, cn } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
|
+
import { toDomProps } from './toDomProps';
|
|
5
|
+
import { useFieldTranslation } from './useFieldTranslation';
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* TagsField - free-form list of string tags. Type a value and press Enter (or
|
|
7
9
|
* comma) to add it; click a tag's × to remove it. The stored value is a
|
|
8
10
|
* string[]. Used for the `tags` field type.
|
|
9
11
|
*/
|
|
10
|
-
export function TagsField({ value, onChange, field, readonly, className, ...props }:
|
|
12
|
+
export function TagsField({ value, onChange, field, readonly, className, error, ...props }: FieldWidgetComponentProps<string[]>) {
|
|
11
13
|
const tags: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
|
|
12
14
|
const [draft, setDraft] = React.useState('');
|
|
15
|
+
// Unconditional hook call (rules-of-hooks): before the readonly early return.
|
|
16
|
+
const { t } = useFieldTranslation();
|
|
13
17
|
|
|
14
18
|
if (readonly) {
|
|
15
19
|
if (tags.length === 0) return <EmptyValue />;
|
|
@@ -54,13 +58,30 @@ export function TagsField({ value, onChange, field, readonly, className, ...prop
|
|
|
54
58
|
</Badge>
|
|
55
59
|
))}
|
|
56
60
|
<Input
|
|
61
|
+
// DOM pass-through onto the real focusable control (objectui#3318):
|
|
62
|
+
// the whitelist spread carries the form renderer's id /
|
|
63
|
+
// aria-describedby to the input the user actually types in.
|
|
64
|
+
{...toDomProps(props)}
|
|
57
65
|
value={draft}
|
|
58
66
|
onChange={(e) => setDraft(e.target.value)}
|
|
59
67
|
onKeyDown={onKeyDown}
|
|
60
68
|
onBlur={() => addTag(draft)}
|
|
61
|
-
disabled={
|
|
62
|
-
|
|
69
|
+
disabled={props.disabled}
|
|
70
|
+
// Placeholder chain (objectui#3342): the author-declared
|
|
71
|
+
// `field.placeholder` wins; otherwise the widget's own copy arrives
|
|
72
|
+
// via `useFieldTranslation()` — `fields.tags.placeholder` in the
|
|
73
|
+
// locale packs, English default from FIELD_DEFAULTS when no
|
|
74
|
+
// I18nProvider is mounted. This used to be a hardcoded Chinese
|
|
75
|
+
// literal (Commandment #-1); Chinese now lives in the zh pack only.
|
|
76
|
+
// Shown only while the list is empty — once a tag exists the input is
|
|
77
|
+
// a small continuation strip and the hint would be noise.
|
|
78
|
+
placeholder={tags.length === 0 ? field?.placeholder || t('fields.tags.placeholder') : ''}
|
|
63
79
|
className="h-7 flex-1 border-0 bg-transparent p-0 px-1 shadow-none focus-visible:ring-0 min-w-[8ch]"
|
|
80
|
+
// AFTER the spread so this widget's own computation wins (the #3222
|
|
81
|
+
// discipline): `error` is the published validation slot, and
|
|
82
|
+
// `!!undefined` yields an explicit "false" — a valid field SAYS it is
|
|
83
|
+
// valid rather than staying mute.
|
|
84
|
+
aria-invalid={!!error}
|
|
64
85
|
/>
|
|
65
86
|
</div>
|
|
66
87
|
);
|