@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.
Files changed (171) hide show
  1. package/CHANGELOG.md +786 -0
  2. package/dist/FieldEditWidget.d.ts +2 -2
  3. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  4. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  5. package/dist/__tests__/spec-symbol-batch7.test.d.ts +8 -0
  6. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  7. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  8. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  9. package/dist/__tests__/widget-props-contract.test.d.ts +8 -0
  10. package/dist/index.cjs +5 -5
  11. package/dist/index.d.ts +4 -0
  12. package/dist/index.js +2882 -2569
  13. package/dist/widgets/AddressField.d.ts +2 -2
  14. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  15. package/dist/widgets/AutoNumberField.d.ts +2 -2
  16. package/dist/widgets/AvatarField.d.ts +2 -2
  17. package/dist/widgets/BooleanField.d.ts +2 -2
  18. package/dist/widgets/CapabilityMultiSelectField.d.ts +2 -2
  19. package/dist/widgets/CharacterCount.d.ts +56 -0
  20. package/dist/widgets/CheckboxesField.d.ts +2 -2
  21. package/dist/widgets/CodeField.d.ts +2 -2
  22. package/dist/widgets/ColorField.d.ts +2 -2
  23. package/dist/widgets/CurrencyField.d.ts +2 -2
  24. package/dist/widgets/DateField.d.ts +2 -2
  25. package/dist/widgets/DateTimeField.d.ts +2 -2
  26. package/dist/widgets/EmailField.d.ts +2 -2
  27. package/dist/widgets/FileField.d.ts +2 -2
  28. package/dist/widgets/FilterConditionField.d.ts +2 -2
  29. package/dist/widgets/FormulaField.d.ts +2 -2
  30. package/dist/widgets/FullscreenFieldEditor.d.ts +155 -0
  31. package/dist/widgets/GeolocationField.d.ts +2 -2
  32. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  33. package/dist/widgets/GridField.d.ts +2 -2
  34. package/dist/widgets/ImageField.d.ts +2 -2
  35. package/dist/widgets/LocationField.d.ts +2 -2
  36. package/dist/widgets/LookupField.d.ts +2 -2
  37. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +8 -0
  38. package/dist/widgets/MasterDetailField.d.ts +2 -2
  39. package/dist/widgets/MultiSelectField.d.ts +2 -2
  40. package/dist/widgets/NumberField.d.ts +2 -2
  41. package/dist/widgets/ObjectField.d.ts +2 -2
  42. package/dist/widgets/ObjectRefField.d.ts +2 -2
  43. package/dist/widgets/OptionsEmptyState.d.ts +45 -0
  44. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  45. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  46. package/dist/widgets/PasswordField.d.ts +2 -2
  47. package/dist/widgets/PercentField.d.ts +2 -2
  48. package/dist/widgets/PhoneField.d.ts +2 -2
  49. package/dist/widgets/QRCodeField.d.ts +2 -2
  50. package/dist/widgets/RadioField.d.ts +2 -2
  51. package/dist/widgets/RatingField.d.ts +2 -2
  52. package/dist/widgets/RecipientPickerField.d.ts +2 -2
  53. package/dist/widgets/RecordPickerDialog.d.ts +17 -1
  54. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +8 -0
  55. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +8 -0
  56. package/dist/widgets/RichTextField.d.ts +20 -2
  57. package/dist/widgets/SelectField.d.ts +2 -2
  58. package/dist/widgets/SignatureField.d.ts +2 -2
  59. package/dist/widgets/SliderField.d.ts +2 -2
  60. package/dist/widgets/SummaryField.d.ts +2 -2
  61. package/dist/widgets/TagsField.d.ts +2 -2
  62. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  63. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  64. package/dist/widgets/TextAreaField.d.ts +45 -6
  65. package/dist/widgets/TextField.d.ts +2 -2
  66. package/dist/widgets/TimeField.d.ts +2 -2
  67. package/dist/widgets/UrlField.d.ts +2 -2
  68. package/dist/widgets/UserField.d.ts +2 -2
  69. package/dist/widgets/VectorField.d.ts +2 -2
  70. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  71. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  72. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  73. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  74. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  75. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  76. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  77. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  78. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  79. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  80. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  81. package/dist/widgets/file-value.d.ts +20 -33
  82. package/dist/widgets/toDomProps.d.ts +91 -0
  83. package/dist/widgets/types.d.ts +215 -4
  84. package/dist/withFieldCarrier.d.ts +56 -0
  85. package/package.json +13 -13
  86. package/src/FieldEditWidget.tsx +6 -4
  87. package/src/__tests__/cell-truncation.test.tsx +96 -0
  88. package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
  89. package/src/__tests__/spec-symbol-batch7.test.ts +126 -0
  90. package/src/__tests__/validation-feedback.test.tsx +22 -13
  91. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
  92. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
  93. package/src/__tests__/widget-dom-leak-e2e.test.tsx +598 -0
  94. package/src/__tests__/widget-props-contract.test.tsx +142 -0
  95. package/src/complex-widgets.test.tsx +3 -3
  96. package/src/datetime-widgets.test.tsx +2 -2
  97. package/src/index.tsx +77 -27
  98. package/src/widgets/AddressField.tsx +32 -13
  99. package/src/widgets/AddressField.uniqueIds.test.tsx +92 -0
  100. package/src/widgets/AutoNumberField.tsx +2 -2
  101. package/src/widgets/AvatarField.tsx +10 -3
  102. package/src/widgets/BooleanField.tsx +5 -5
  103. package/src/widgets/CapabilityMultiSelectField.tsx +4 -4
  104. package/src/widgets/CharacterCount.tsx +263 -0
  105. package/src/widgets/CheckboxesField.tsx +35 -18
  106. package/src/widgets/CodeField.tsx +10 -3
  107. package/src/widgets/ColorField.tsx +12 -3
  108. package/src/widgets/CurrencyField.tsx +6 -5
  109. package/src/widgets/DateField.tsx +4 -4
  110. package/src/widgets/DateTimeField.tsx +4 -4
  111. package/src/widgets/EmailField.tsx +6 -6
  112. package/src/widgets/FileField.tsx +14 -4
  113. package/src/widgets/FilterConditionField.tsx +14 -3
  114. package/src/widgets/FormulaField.tsx +3 -3
  115. package/src/widgets/FullscreenFieldEditor.tsx +271 -0
  116. package/src/widgets/GeolocationField.tsx +22 -7
  117. package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
  118. package/src/widgets/GridField.tsx +4 -4
  119. package/src/widgets/ImageField.tsx +10 -3
  120. package/src/widgets/LocationField.tsx +10 -3
  121. package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
  122. package/src/widgets/LookupField.tsx +50 -15
  123. package/src/widgets/MasterDetailField.tsx +3 -3
  124. package/src/widgets/MultiSelectField.tsx +34 -18
  125. package/src/widgets/NumberField.tsx +5 -5
  126. package/src/widgets/ObjectField.tsx +18 -9
  127. package/src/widgets/ObjectRefField.tsx +14 -4
  128. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
  129. package/src/widgets/OptionsEmptyState.test.tsx +169 -0
  130. package/src/widgets/OptionsEmptyState.tsx +83 -0
  131. package/src/widgets/PasswordField.tsx +5 -5
  132. package/src/widgets/PeoplePicker.tsx +7 -1
  133. package/src/widgets/PercentField.tsx +6 -5
  134. package/src/widgets/PhoneField.tsx +6 -6
  135. package/src/widgets/QRCodeField.tsx +10 -3
  136. package/src/widgets/RadioField.tsx +28 -18
  137. package/src/widgets/RatingField.tsx +19 -4
  138. package/src/widgets/RecipientPickerField.tsx +22 -4
  139. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
  140. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
  141. package/src/widgets/RecordPickerDialog.tsx +173 -8
  142. package/src/widgets/RichTextField.tsx +152 -25
  143. package/src/widgets/SelectField.tsx +53 -22
  144. package/src/widgets/SignatureField.tsx +2 -2
  145. package/src/widgets/SliderField.tsx +3 -3
  146. package/src/widgets/SummaryField.tsx +3 -3
  147. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
  148. package/src/widgets/TagsField.placeholder.test.tsx +95 -0
  149. package/src/widgets/TagsField.tsx +25 -4
  150. package/src/widgets/TextAreaField.tsx +172 -94
  151. package/src/widgets/TextField.tsx +5 -5
  152. package/src/widgets/TimeField.tsx +4 -4
  153. package/src/widgets/UrlField.tsx +6 -6
  154. package/src/widgets/UserField.tsx +3 -3
  155. package/src/widgets/VectorField.tsx +3 -3
  156. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
  157. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
  158. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
  159. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
  160. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
  161. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
  162. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
  163. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
  164. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
  165. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
  166. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
  167. package/src/widgets/file-value.ts +21 -5
  168. package/src/widgets/toDomProps.ts +175 -0
  169. package/src/widgets/types.ts +230 -7
  170. package/src/widgets/useFieldTranslation.ts +60 -0
  171. 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 { FieldWidgetProps } from './types';
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: FieldWidgetProps<any>) {
33
- const config = (props.field || (props as any).schema) as SelectFieldMetadata | undefined;
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 FieldWidgetProps<string>)} />;
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
- schema,
62
+ error,
57
63
  dependentValues,
58
64
  dependsOn: dependsOnProp,
59
- emptyHint: _emptyHint,
65
+ emptyHint,
60
66
  dataSource: _dataSource,
61
67
  ...props
62
- }: FieldWidgetProps<string>) {
63
- const config = (field || schema) as SelectFieldMetadata;
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 = (props as any).name || (config as any)?.name || props.id || '';
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
- // a dependency-gated list prompts for its controlling field; an unconfigured
97
- // list says so. Mirrors the inline form renderer's behaviour.
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
- <div
104
- data-testid={fieldName ? `select-empty-${fieldName}` : undefined}
105
- className="flex h-9 w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
106
- >
107
- {hint}
108
- </div>
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
- {...props}
137
+ name={domName}
115
138
  value={value}
116
139
  onValueChange={onChange}
117
140
  disabled={readonly || props.disabled}
118
141
  >
119
- <SelectTrigger className={props.className} id={props.id} data-testid={fieldName ? `select-trigger-${fieldName}` : undefined}>
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 { FieldWidgetProps } from './types';
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 }: FieldWidgetProps<string>) {
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 { FieldWidgetProps } from './types';
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 }: FieldWidgetProps<number>) {
9
+ export function SliderField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<number>) {
10
10
  // Get slider-specific configuration from field metadata
11
- const sliderField = (field || (props as any).schema) as any;
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 { FieldWidgetProps } from './types';
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 }: FieldWidgetProps<any>) {
10
- const summaryField = (field || (props as any).schema) as any;
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 { FieldWidgetProps } from './types';
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 }: FieldWidgetProps<string[]>) {
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={(props as any).disabled}
62
- placeholder={tags.length === 0 ? '输入后回车添加…' : ''}
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
  );