@object-ui/fields 17.2.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 (142) hide show
  1. package/CHANGELOG.md +592 -0
  2. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  3. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  4. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  5. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  6. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  7. package/dist/index.cjs +5 -5
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.js +2881 -2566
  10. package/dist/widgets/AddressField.d.ts +1 -1
  11. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  12. package/dist/widgets/AvatarField.d.ts +1 -1
  13. package/dist/widgets/CharacterCount.d.ts +56 -0
  14. package/dist/widgets/CheckboxesField.d.ts +1 -1
  15. package/dist/widgets/CodeField.d.ts +1 -1
  16. package/dist/widgets/ColorField.d.ts +1 -1
  17. package/dist/widgets/CurrencyField.d.ts +1 -1
  18. package/dist/widgets/EmailField.d.ts +1 -1
  19. package/dist/widgets/FileField.d.ts +1 -1
  20. package/dist/widgets/FilterConditionField.d.ts +1 -1
  21. package/dist/widgets/FullscreenFieldEditor.d.ts +155 -0
  22. package/dist/widgets/GeolocationField.d.ts +1 -1
  23. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  24. package/dist/widgets/ImageField.d.ts +1 -1
  25. package/dist/widgets/LocationField.d.ts +1 -1
  26. package/dist/widgets/LookupField.d.ts +1 -1
  27. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +8 -0
  28. package/dist/widgets/MultiSelectField.d.ts +1 -1
  29. package/dist/widgets/ObjectField.d.ts +1 -1
  30. package/dist/widgets/ObjectRefField.d.ts +1 -1
  31. package/dist/widgets/OptionsEmptyState.d.ts +45 -0
  32. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  33. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  34. package/dist/widgets/PercentField.d.ts +1 -1
  35. package/dist/widgets/PhoneField.d.ts +1 -1
  36. package/dist/widgets/QRCodeField.d.ts +1 -1
  37. package/dist/widgets/RadioField.d.ts +1 -1
  38. package/dist/widgets/RatingField.d.ts +1 -1
  39. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  40. package/dist/widgets/RecordPickerDialog.d.ts +17 -1
  41. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +8 -0
  42. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +8 -0
  43. package/dist/widgets/RichTextField.d.ts +19 -1
  44. package/dist/widgets/TagsField.d.ts +1 -1
  45. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  46. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  47. package/dist/widgets/TextAreaField.d.ts +44 -5
  48. package/dist/widgets/UrlField.d.ts +1 -1
  49. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  50. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  51. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  52. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  53. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  54. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  55. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  56. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  57. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  58. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  59. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  60. package/dist/widgets/toDomProps.d.ts +91 -0
  61. package/dist/widgets/types.d.ts +128 -33
  62. package/dist/withFieldCarrier.d.ts +56 -0
  63. package/package.json +13 -13
  64. package/src/__tests__/cell-truncation.test.tsx +96 -0
  65. package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
  66. package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
  67. package/src/__tests__/validation-feedback.test.tsx +22 -13
  68. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
  69. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
  70. package/src/__tests__/widget-dom-leak-e2e.test.tsx +598 -0
  71. package/src/__tests__/widget-props-contract.test.tsx +22 -9
  72. package/src/index.tsx +74 -26
  73. package/src/widgets/AddressField.tsx +31 -12
  74. package/src/widgets/AddressField.uniqueIds.test.tsx +92 -0
  75. package/src/widgets/AvatarField.tsx +9 -2
  76. package/src/widgets/BooleanField.tsx +3 -3
  77. package/src/widgets/CharacterCount.tsx +263 -0
  78. package/src/widgets/CheckboxesField.tsx +31 -14
  79. package/src/widgets/CodeField.tsx +9 -2
  80. package/src/widgets/ColorField.tsx +11 -2
  81. package/src/widgets/CurrencyField.tsx +5 -4
  82. package/src/widgets/DateField.tsx +2 -2
  83. package/src/widgets/DateTimeField.tsx +2 -2
  84. package/src/widgets/EmailField.tsx +5 -5
  85. package/src/widgets/FileField.tsx +13 -3
  86. package/src/widgets/FilterConditionField.tsx +11 -0
  87. package/src/widgets/FormulaField.tsx +1 -1
  88. package/src/widgets/FullscreenFieldEditor.tsx +271 -0
  89. package/src/widgets/GeolocationField.tsx +21 -6
  90. package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
  91. package/src/widgets/GridField.tsx +1 -1
  92. package/src/widgets/ImageField.tsx +9 -2
  93. package/src/widgets/LocationField.tsx +9 -2
  94. package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
  95. package/src/widgets/LookupField.tsx +41 -6
  96. package/src/widgets/MasterDetailField.tsx +1 -1
  97. package/src/widgets/MultiSelectField.tsx +30 -14
  98. package/src/widgets/NumberField.tsx +3 -3
  99. package/src/widgets/ObjectField.tsx +17 -8
  100. package/src/widgets/ObjectRefField.tsx +10 -0
  101. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
  102. package/src/widgets/OptionsEmptyState.test.tsx +169 -0
  103. package/src/widgets/OptionsEmptyState.tsx +83 -0
  104. package/src/widgets/PasswordField.tsx +3 -3
  105. package/src/widgets/PeoplePicker.tsx +7 -1
  106. package/src/widgets/PercentField.tsx +5 -4
  107. package/src/widgets/PhoneField.tsx +5 -5
  108. package/src/widgets/QRCodeField.tsx +9 -2
  109. package/src/widgets/RadioField.tsx +24 -14
  110. package/src/widgets/RatingField.tsx +18 -3
  111. package/src/widgets/RecipientPickerField.tsx +18 -0
  112. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
  113. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
  114. package/src/widgets/RecordPickerDialog.tsx +173 -8
  115. package/src/widgets/RichTextField.tsx +151 -24
  116. package/src/widgets/SelectField.tsx +48 -17
  117. package/src/widgets/SliderField.tsx +1 -1
  118. package/src/widgets/SummaryField.tsx +1 -1
  119. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
  120. package/src/widgets/TagsField.placeholder.test.tsx +95 -0
  121. package/src/widgets/TagsField.tsx +23 -2
  122. package/src/widgets/TextAreaField.tsx +171 -93
  123. package/src/widgets/TextField.tsx +3 -3
  124. package/src/widgets/TimeField.tsx +2 -2
  125. package/src/widgets/UrlField.tsx +5 -5
  126. package/src/widgets/UserField.tsx +1 -1
  127. package/src/widgets/VectorField.tsx +1 -1
  128. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
  129. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
  130. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
  131. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
  132. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
  133. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
  134. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
  135. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
  136. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
  137. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
  138. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
  139. package/src/widgets/toDomProps.ts +175 -0
  140. package/src/widgets/types.ts +135 -35
  141. package/src/widgets/useFieldTranslation.ts +60 -0
  142. package/src/withFieldCarrier.tsx +75 -0
@@ -0,0 +1,175 @@
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
+ * `TextAreaField`'s fullscreen affordance has ONE source (objectui#3232).
11
+ *
12
+ * It used to be read from four places with a `??` chain — a `mobileFullscreen`
13
+ * (camelCase) prop, `field.mobile_fullscreen`, a `mobile_fullscreen` prop, and
14
+ * `schema.mobile_fullscreen`. Only the field-metadata flag was ever produced:
15
+ * `ObjectForm` stamps it on long-text fields from
16
+ * `ObjectFormSchema.mobile.fullscreenLongText`. The camelCase prop had no
17
+ * producer anywhere in the repo, and the snake_case prop could not arrive
18
+ * because `stripRegisteredFieldProps` (components/src/renderers/form/form.tsx)
19
+ * removes it from what registered widgets are forwarded.
20
+ *
21
+ * So this file pins both halves of that convergence:
22
+ *
23
+ * 1. **The live path still works.** The metadata flag drives the affordance
24
+ * end to end — button, dialog, and the committed edit. Deleting the dead
25
+ * reads must not have cost the working behaviour, and this is the test
26
+ * that fails if a future refactor drops the real read too.
27
+ * 2. **The retired prop spellings are gone**, at compile time (they are not
28
+ * on the closed `FieldWidgetComponentProps`, objectui#3221) and at runtime
29
+ * (a host that passes one anyway gets no affordance). Convergence means a
30
+ * misspelled flag is now inert and loud, not silently absorbed.
31
+ *
32
+ * Note the built-in (unregistered) `textarea` branch in `form.tsx` reads
33
+ * `mobile_fullscreen || fullscreen` off the form-field props and renders its
34
+ * own `FullscreenTextarea`. That is a separate, live path and is untouched
35
+ * here — this file is about the registered `field:textarea` widget only.
36
+ */
37
+
38
+ import { describe, it, expect, vi } from 'vitest';
39
+ import { render, screen, fireEvent } from '@testing-library/react';
40
+ import '@testing-library/jest-dom';
41
+
42
+ import { TextAreaField } from '../TextAreaField';
43
+ import type { FieldWidgetComponentProps } from '../types';
44
+ import type { FieldMetadata } from '@object-ui/types';
45
+
46
+ const fieldMeta = (extra: Record<string, unknown> = {}) =>
47
+ ({
48
+ name: 'description',
49
+ label: 'Description',
50
+ type: 'textarea',
51
+ ...extra,
52
+ }) as unknown as FieldMetadata;
53
+
54
+ describe('TextAreaField mobile fullscreen — the metadata flag is the only source', () => {
55
+ it('renders the expand affordance when the field metadata sets mobile_fullscreen', () => {
56
+ render(
57
+ <TextAreaField
58
+ value="hello"
59
+ onChange={() => {}}
60
+ field={fieldMeta({ mobile_fullscreen: true })}
61
+ />,
62
+ );
63
+
64
+ expect(screen.getByTestId('textarea-fullscreen-toggle')).toBeInTheDocument();
65
+ });
66
+
67
+ it('opens the fullscreen dialog and commits the edited draft', () => {
68
+ const onChange = vi.fn();
69
+ render(
70
+ <TextAreaField
71
+ value="hello"
72
+ onChange={onChange}
73
+ field={fieldMeta({ mobile_fullscreen: true })}
74
+ />,
75
+ );
76
+
77
+ // Closed until the affordance is used — the dialog is not just mounted.
78
+ expect(screen.queryByTestId('textarea-fullscreen-dialog')).not.toBeInTheDocument();
79
+
80
+ fireEvent.click(screen.getByTestId('textarea-fullscreen-toggle'));
81
+ expect(screen.getByTestId('textarea-fullscreen-dialog')).toBeInTheDocument();
82
+
83
+ const dialogInput = screen.getByTestId('textarea-fullscreen-input');
84
+ expect(dialogInput).toHaveValue('hello');
85
+
86
+ fireEvent.change(dialogInput, { target: { value: 'hello from fullscreen' } });
87
+ // The draft is local until "Done" — the host is not notified per keystroke.
88
+ expect(onChange).not.toHaveBeenCalled();
89
+
90
+ fireEvent.click(screen.getByTestId('textarea-fullscreen-save'));
91
+ expect(onChange).toHaveBeenCalledWith('hello from fullscreen');
92
+ });
93
+
94
+ it('no longer reads the flag off a `schema` prop (objectui#3233)', () => {
95
+ // This used to be the second carrier: `SchemaRenderer` passed the authored
96
+ // node as `schema` and the widget resolved `field || schema`. v17 converged
97
+ // that at the producer — the registry adapter (`withFieldCarrier`) maps the
98
+ // SDUI node onto `field` before any widget sees it, so the widget has one
99
+ // read. A host that still passes `schema` is now inert AND loud: a compile
100
+ // error on the closed props type, and no affordance at runtime.
101
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
102
+ try {
103
+ const legacyHostProps = {
104
+ schema: fieldMeta({ mobile_fullscreen: true }),
105
+ } as unknown as Partial<FieldWidgetComponentProps<string>>;
106
+
107
+ render(
108
+ <TextAreaField
109
+ value=""
110
+ onChange={() => {}}
111
+ field={fieldMeta()}
112
+ {...legacyHostProps}
113
+ />,
114
+ );
115
+
116
+ expect(screen.queryByTestId('textarea-fullscreen-toggle')).not.toBeInTheDocument();
117
+ } finally {
118
+ consoleError.mockRestore();
119
+ }
120
+ });
121
+
122
+ it('renders no affordance when the metadata does not opt in', () => {
123
+ render(<TextAreaField value="hello" onChange={() => {}} field={fieldMeta()} />);
124
+
125
+ expect(screen.queryByTestId('textarea-fullscreen-toggle')).not.toBeInTheDocument();
126
+ expect(screen.queryByTestId('textarea-fullscreen-dialog')).not.toBeInTheDocument();
127
+ });
128
+ });
129
+
130
+ describe('TextAreaField mobile fullscreen — the retired prop spellings', () => {
131
+ it('does not declare them on the widget contract', () => {
132
+ const props = {} as FieldWidgetComponentProps<string>;
133
+
134
+ // Both were read by the widget and written by no host. With the closed
135
+ // props type (objectui#3221) they are now compile errors, so a future
136
+ // author reaches for the metadata flag instead of a silently ignored prop.
137
+ // @ts-expect-error `mobileFullscreen` is not part of this contract
138
+ void props.mobileFullscreen;
139
+ // @ts-expect-error `mobile_fullscreen` is a metadata key, not a widget prop
140
+ void props.mobile_fullscreen;
141
+ // …and the retired second METADATA carrier (objectui#3233). The flag has
142
+ // one source and that source now has one carrier.
143
+ // @ts-expect-error `schema` was retired from the widget contract in v17
144
+ void props.schema;
145
+
146
+ expect(true).toBe(true);
147
+ });
148
+
149
+ it('ignores them at runtime when a host passes them anyway', () => {
150
+ // Untyped hosts (plain JS, `as any` spreads) can still get these onto the
151
+ // element. They must not resurrect the affordance: the metadata flag is
152
+ // the contract. Unknown props land on the DOM spread, so React's unknown
153
+ // -attribute warnings are expected noise here and are suppressed.
154
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
155
+ try {
156
+ const hostProps = {
157
+ mobileFullscreen: true,
158
+ mobile_fullscreen: true,
159
+ } as unknown as Partial<FieldWidgetComponentProps<string>>;
160
+
161
+ render(
162
+ <TextAreaField
163
+ value="hello"
164
+ onChange={() => {}}
165
+ field={fieldMeta()}
166
+ {...hostProps}
167
+ />,
168
+ );
169
+
170
+ expect(screen.queryByTestId('textarea-fullscreen-toggle')).not.toBeInTheDocument();
171
+ } finally {
172
+ consoleError.mockRestore();
173
+ }
174
+ });
175
+ });
@@ -0,0 +1,175 @@
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 type { FieldWidgetComponentProps, FieldWidgetDomProps } from './types';
10
+
11
+ /**
12
+ * The RUNTIME EXECUTOR of the "DOM pass-through" section of
13
+ * {@link FieldWidgetComponentProps} (objectui#3291).
14
+ *
15
+ * ## The defect
16
+ *
17
+ * Field widgets forwarded their leftover props to a control with a bare
18
+ * spread — `const { inputType, ...domProps } = props as any; <Input
19
+ * {...domProps} />`. Whatever a host handed the widget therefore became a DOM
20
+ * attribute. Measured on `origin/main`, a real form + a real widget:
21
+ *
22
+ * ```
23
+ * <input placeholder="PH-f" zzcanary="CANARY-STR" zzcanaryobj="[object Object]"
24
+ * zzcanarynum="42" id="…" type="text" value="" name="f">
25
+ * ```
26
+ *
27
+ * The `[object Object]` this issue is named for is an authored field-config
28
+ * key being `String()`-ed onto an attribute. React 19 does not warn: an
29
+ * all-lowercase unknown attribute is passed through in complete silence, which
30
+ * is why this survived so long.
31
+ *
32
+ * ## Why a whitelist, and not a list of keys to drop
33
+ *
34
+ * The biggest leak source is not any NAMED renderer prop — it is the open tail
35
+ * of author-supplied keys. The form renderer destructures a fixed set of known
36
+ * keys and forwards `...fieldProps` verbatim
37
+ * (`components/src/renderers/form/form.tsx`), and `SchemaRenderer` is wider
38
+ * still: it spreads the whole authored node as props and has no strip layer at
39
+ * all, so on the SDUI path a widget's own spread is the ONLY line of defence.
40
+ *
41
+ * A blacklist enumerating today's renderer-only keys (`error`, `emptyHint`,
42
+ * `dataSource`, `dependentValues`, `dependsOn`, `options`, `inputType`, …)
43
+ * would pass every one of the canaries above and would not stop the next
44
+ * authored key either. Only "keep the known-safe set, drop the rest" closes
45
+ * it, which is what this function does.
46
+ *
47
+ * ## Declared = enforced
48
+ *
49
+ * {@link FieldWidgetComponentProps} already DECLARES the closed set of keys a
50
+ * widget may receive, and {@link FieldWidgetDomProps} names the subset that may
51
+ * legitimately reach a DOM element (objectui#3221). Until now that was a
52
+ * type-level claim a widget could violate at runtime simply by spreading. This
53
+ * function is that declaration's executable form, and TWO compile-time
54
+ * assertions below bind them in both directions — forwarding an undeclared key
55
+ * and declaring an unforwarded DOM key are each a compile error. Neither
56
+ * direction can drift silently.
57
+ *
58
+ * ## Deliberately NOT forwarded
59
+ *
60
+ * `role` and other HTML global attributes are absent because the contract does
61
+ * not declare them. They reached the DOM before this change only through the
62
+ * open spread. If a field node should be able to author one, DECLARE it on
63
+ * `FieldWidgetComponentProps` first and add it here — do not reopen the spread
64
+ * (AGENTS.md #0.1: fix the contract, never widen the consumer).
65
+ */
66
+ const DOM_PASS_THROUGH_KEYS = [
67
+ /* ── The contract's own "DOM pass-through" block, verbatim ─────────────── */
68
+ 'id',
69
+ 'name',
70
+ 'autoFocus',
71
+ 'tabIndex',
72
+ 'onBlur',
73
+ 'onFocus',
74
+ 'onClick',
75
+
76
+ /* ── Two more declared keys that ARE valid DOM attributes ──────────────── */
77
+ //
78
+ // Both are declared on `FieldWidgetComponentProps` under the
79
+ // controlled-input contract rather than the DOM block, because widgets also
80
+ // INTERPRET them (`className` gets composed with the widget's own classes;
81
+ // `disabled` is OR-ed with `readonly`). They are still `class` and
82
+ // `disabled` on the element, and every widget here already forwards them.
83
+ //
84
+ // Withholding them would make this helper a silent styling- and
85
+ // interactivity-dropper: `<Input {...toDomProps(props)} />` — the shape this
86
+ // change teaches every future widget author to write — would quietly lose
87
+ // the host's className and disabled state. That is a NEW silent failure of
88
+ // exactly the kind the whitelist exists to prevent, so they are forwarded.
89
+ 'className',
90
+ 'disabled',
91
+ ] as const;
92
+
93
+ type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];
94
+
95
+ /**
96
+ * Compile-time link to the declaration, direction 1 of 2: every key forwarded
97
+ * at runtime must exist on {@link FieldWidgetComponentProps}. Deleting a key
98
+ * from the contract without deleting it here is a compile error.
99
+ *
100
+ * Catches: helper forwards something the contract no longer declares.
101
+ */
102
+ type EveryForwardedKeyIsDeclared =
103
+ DomPassThroughKey extends keyof FieldWidgetComponentProps ? true : never;
104
+ const _everyForwardedKeyIsDeclared: EveryForwardedKeyIsDeclared = true;
105
+ void _everyForwardedKeyIsDeclared;
106
+
107
+ /**
108
+ * Direction 2 of 2: every key of {@link FieldWidgetDomProps} — the contract's
109
+ * DOM pass-through block — must be one this helper actually forwards. Adding a
110
+ * DOM key to the contract without adding it to `DOM_PASS_THROUGH_KEYS` is a
111
+ * compile error.
112
+ *
113
+ * Catches: DECLARED BUT NOT DELIVERED — a key that type-checks, reads as
114
+ * supported, and silently never reaches the element. That is the failure this
115
+ * repo treats as first-class (objectui#3290's `aria-required` that never
116
+ * reached a control; objectui#3222's validation slot nobody produced), and it
117
+ * is the one direction the leak test structurally CANNOT see: that test looks
118
+ * for attributes that arrive, not for ones that go missing.
119
+ *
120
+ * `className` / `disabled` are not part of `FieldWidgetDomProps` (they are
121
+ * controlled-input keys this helper also forwards — see above), so they are
122
+ * bound by direction 1 only. That is deliberate, not an oversight.
123
+ */
124
+ type EveryDeclaredDomKeyIsForwarded =
125
+ keyof FieldWidgetDomProps extends DomPassThroughKey ? true : never;
126
+ const _everyDeclaredDomKeyIsForwarded: EveryDeclaredDomKeyIsForwarded = true;
127
+ void _everyDeclaredDomKeyIsForwarded;
128
+
129
+ const DOM_PASS_THROUGH: ReadonlySet<string> = new Set<string>(DOM_PASS_THROUGH_KEYS);
130
+
131
+ /**
132
+ * `aria-*` is declared on the contract as React's `AriaAttributes`; `data-*`
133
+ * is declared as an open template-literal family (open in HTML too, and the
134
+ * only open family the contract has). Both are matched by prefix so the helper
135
+ * needs no per-attribute list.
136
+ */
137
+ function isOpenDomFamily(key: string): boolean {
138
+ return key.startsWith('data-') || key.startsWith('aria-');
139
+ }
140
+
141
+ /** The subset of `P` this helper forwards, with each key's declared type. */
142
+ export type DomProps<P> = Pick<P, Extract<keyof P, DomPassThroughKey>> & {
143
+ [K in Extract<keyof P, `data-${string}` | `aria-${string}`>]: P[K];
144
+ };
145
+
146
+ /**
147
+ * Keep only what may legitimately become a DOM attribute, and drop everything
148
+ * else — renderer plumbing, and any extra key an author put on the field
149
+ * config or SDUI node.
150
+ *
151
+ * Replaces the bare `{...props}` spread in every field widget that renders a
152
+ * host element:
153
+ *
154
+ * ```tsx
155
+ * // before — forwards whatever it was handed
156
+ * const { inputType, ...domProps } = props as any;
157
+ * return <Input {...domProps} … />;
158
+ *
159
+ * // after
160
+ * return <Input {...toDomProps(props)} … />;
161
+ * ```
162
+ *
163
+ * Semantic props a widget INTERPRETS (`value`, `onChange`, `field`,
164
+ * `readonly`, `error`, …) are read from `props` as before; this function only
165
+ * governs what gets spread.
166
+ */
167
+ export function toDomProps<P extends object>(props: P): DomProps<P> {
168
+ const domProps: Record<string, unknown> = {};
169
+ for (const key of Object.keys(props)) {
170
+ if (DOM_PASS_THROUGH.has(key) || isOpenDomFamily(key)) {
171
+ domProps[key] = (props as Record<string, unknown>)[key];
172
+ }
173
+ }
174
+ return domProps as DomProps<P>;
175
+ }
@@ -1,6 +1,49 @@
1
1
  import type { AriaAttributes, FocusEventHandler, MouseEventHandler } from 'react';
2
2
  import type { DependsOnInput, FieldMetadata } from '@object-ui/types';
3
3
 
4
+ /**
5
+ * DOM pass-through: what a widget's `...props` spread may legitimately put on
6
+ * the element it renders.
7
+ *
8
+ * A NAMED type rather than an inline block of {@link FieldWidgetComponentProps}
9
+ * so the compiler can bind it to its runtime executor in BOTH directions
10
+ * (objectui#3291). `toDomProps` asserts:
11
+ *
12
+ * - every key it forwards is declared on the widget contract — deleting one
13
+ * here without deleting it there is a compile error;
14
+ * - every key of THIS type is one it forwards — adding one here without
15
+ * adding it to `DOM_PASS_THROUGH_KEYS` is a compile error too.
16
+ *
17
+ * The second direction is the one that matters for the failure mode this repo
18
+ * treats as first-class: DECLARED BUT NOT DELIVERED (objectui#3290's
19
+ * `aria-required` that never reached a control, objectui#3222's validation slot
20
+ * nobody produced). Without it, a key added here would type-check, read as
21
+ * supported, and silently never reach the DOM — and the leak test cannot see
22
+ * that class of bug, because it looks for attributes that ARRIVE, not for ones
23
+ * that go missing.
24
+ *
25
+ * `className` and `disabled` are deliberately NOT here. They are DOM-legal and
26
+ * `toDomProps` does forward them, but they are declared on the controlled-input
27
+ * block of {@link FieldWidgetComponentProps} because widgets also INTERPRET
28
+ * them (className is composed with the widget's own classes; disabled is OR-ed
29
+ * with readonly). They are therefore bound in the forward direction only.
30
+ *
31
+ * `AriaAttributes` and the `data-${string}` family are matched by prefix at
32
+ * runtime rather than key-by-key, so they are intersected in separately.
33
+ *
34
+ * Adding a key here is a contract change: say who produces it and who reads it.
35
+ */
36
+ export type FieldWidgetDomProps = {
37
+ id?: string;
38
+ /** react-hook-form's field name, spread in by the form renderer. */
39
+ name?: string;
40
+ autoFocus?: boolean;
41
+ tabIndex?: number;
42
+ onBlur?: FocusEventHandler<HTMLElement>;
43
+ onFocus?: FocusEventHandler<HTMLElement>;
44
+ onClick?: MouseEventHandler<HTMLElement>;
45
+ };
46
+
4
47
  /**
5
48
  * Props every field widget in this package receives at RUNTIME.
6
49
  *
@@ -14,13 +57,27 @@ import type { DependsOnInput, FieldMetadata } from '@object-ui/types';
14
57
  * the widgets in this directory actually implement, against
15
58
  * `@object-ui/types`'s much richer `FieldMetadata`.
16
59
  *
17
- * The two are NOT interchangeable, and the divergence is not cosmetic: the
18
- * spec's contract names the error slot `error`, this one names it
19
- * `errorMessage`, so a widget written to the spec's declared props renders no
20
- * validation message here. That divergence is tracked in objectui#3222 and is
21
- * deliberately NOT resolved here — but it is now *visible*: reading
22
- * `props.error` or `props.required` off this type is a compile error rather
23
- * than a silent `any`.
60
+ * The two are NOT interchangeable, but they no longer disagree on the
61
+ * validation slot: this type used to call it `errorMessage` while the spec
62
+ * calls it `error`, so a widget written to the published contract read
63
+ * `undefined` forever. objectui#3222 resolved that in the direction the
64
+ * contract points — the slot below IS the spec's `error`, and the form
65
+ * renderer now produces it (see `error`'s own doc comment). Reading
66
+ * `props.required` off this type is still a compile error rather than a
67
+ * silent `any`; that key is deliberately NOT lowered here (see below).
68
+ *
69
+ * ## Why `required` is still absent, though the spec declares it
70
+ *
71
+ * The required MARKER is drawn exactly once, by the form renderer's
72
+ * `<FormLabel>` (the `*` with `aria-label="required"`, which the label
73
+ * association folds into the control's accessible name). Handing `required`
74
+ * to widgets would give that marker a second possible author, and the very
75
+ * next AI-written widget draws its own asterisk — the same double-display
76
+ * failure that keeps the validation TEXT out of the widget below. The a11y
77
+ * state a widget genuinely could carry is `aria-required` on the input, and
78
+ * that needs no new key at all: `AriaAttributes` is already part of this type
79
+ * and every widget already forwards it to its control. Tracked separately;
80
+ * do not add `required` here to "align" without that decision.
24
81
  *
25
82
  * ## Why there is no `[key: string]: any` (objectui#3221)
26
83
  *
@@ -40,6 +97,22 @@ import type { DependsOnInput, FieldMetadata } from '@object-ui/types';
40
97
  * is expressed as a template-literal index signature, which — unlike
41
98
  * `[key: string]` — leaves `keyof` finite, so an undeclared prop still fails.
42
99
  *
100
+ * ## Why there is no `schema` (objectui#3233)
101
+ *
102
+ * `schema` used to be declared here as a SECOND carrier for what `field`
103
+ * already means — `SchemaRenderer` handed the SDUI node down as `schema`, the
104
+ * form renderer forwarded `schema={props.field || props.schema || props}`
105
+ * *alongside* `field`, and ~30 widgets resolved their config as
106
+ * `field || schema`. One concept, two keys, two producers: exactly the
107
+ * consumer-side tolerance AGENTS.md #0.1 forbids, and exactly the shape that
108
+ * lets an AI-written widget pick the wrong spelling and still "work" under one
109
+ * host while reading `undefined` under another.
110
+ *
111
+ * v17 converged it at the PRODUCERS. `field` is the only carrier a widget ever
112
+ * sees; the SDUI node → `field` translation happens once, in this package's
113
+ * registry adapter (`withFieldCarrier`), not ~30 times in the widgets. Reading
114
+ * `props.schema` is now a compile error rather than a silent second contract.
115
+ *
43
116
  * Adding a key here is a contract change: say who produces it and who reads it.
44
117
  */
45
118
  export type FieldWidgetComponentProps<T = any> = {
@@ -48,14 +121,38 @@ export type FieldWidgetComponentProps<T = any> = {
48
121
  value: T;
49
122
  onChange: (val: T) => void;
50
123
  /**
51
- * The field's metadata. Deliberately the looser `@object-ui/types` shape
52
- * rather than the spec's, to avoid a circular dependency for now.
124
+ * The field's metadata, and the SINGLE carrier for it (objectui#3233).
125
+ *
126
+ * Deliberately the looser `@object-ui/types` shape rather than the spec's,
127
+ * to avoid a circular dependency for now.
128
+ *
129
+ * Every host produces it: the form renderer's `renderFieldComponent`, the
130
+ * inline-edit hosts (`FieldEditWidget`), `ActionParamDialog` /
131
+ * `BulkActionDialog`, and — since #3233 — the registry adapter that adapts
132
+ * `SchemaRenderer`'s SDUI node onto this contract (`withFieldCarrier` in
133
+ * `packages/fields/src/withFieldCarrier.tsx`). A widget reads `props.field`
134
+ * and nothing else; there is no second key to check.
53
135
  */
54
136
  field: FieldMetadata;
55
137
  readonly?: boolean;
56
138
  disabled?: boolean;
57
139
  className?: string;
58
- errorMessage?: string;
140
+ /**
141
+ * The active validation message for this field, named as
142
+ * `@objectstack/spec/ui`'s `FieldWidgetPropsSchema` names it (objectui#3222).
143
+ *
144
+ * **Producer**: the form renderer, from react-hook-form's
145
+ * `fieldState.error?.message` (`packages/components/src/renderers/form/
146
+ * form.tsx`). Before #3222 nothing in the repo produced it under EITHER
147
+ * spelling, so the seven widgets computing `aria-invalid={!!errorMessage}`
148
+ * were computing it from a permanent `undefined` — `aria-invalid` was never
149
+ * once set and a screen reader was never told the field had failed.
150
+ *
151
+ * **Consumer**: a widget reads this ONLY to drive `aria-invalid` on the
152
+ * control it renders. The message TEXT stays with the form renderer's
153
+ * `<FormMessage/>`; a widget that also renders it double-displays it.
154
+ */
155
+ error?: string;
59
156
  /**
60
157
  * Upload widgets (`file`/`image`) fire this when their in-progress state
61
158
  * flips, so a host can block submit until a presigned upload settles. Other
@@ -65,17 +162,6 @@ export type FieldWidgetComponentProps<T = any> = {
65
162
 
66
163
  /* ── Host plumbing: what a rendering host actually forwards ─────────────── */
67
164
 
68
- /**
69
- * The raw authored node the widget was rendered from. Two hosts supply it:
70
- * `SchemaRenderer` (`<Component schema={schema} {...schema} />`) and the
71
- * form renderer's `renderFieldComponent` (`schema={props.field || ...}`),
72
- * which is why ~25 widgets read `field || schema` for their config.
73
- *
74
- * This is a second carrier for what `field` already means, i.e. a de-facto
75
- * second contract (AGENTS.md #0.1) — declared here so it is at least
76
- * visible; converging the two is tracked separately.
77
- */
78
- schema?: FieldMetadata | Record<string, unknown>;
79
165
  /**
80
166
  * DataSource for widgets that query records (lookup / user / object-ref /
81
167
  * recipient-picker / grid). Injected by the form renderer for the field
@@ -99,9 +185,27 @@ export type FieldWidgetComponentProps<T = any> = {
99
185
  */
100
186
  dependsOn?: DependsOnInput;
101
187
  /**
102
- * Hint shown when a dependency-gated option list is still waiting on its
103
- * controlling field. Forwarded by the form renderer; the option widgets in
104
- * this package currently discard it and render their own message instead.
188
+ * Controlling-field name → its human LABEL, for widgets that have to NAME a
189
+ * sibling field in user-visible copy (the dependent-lookup gate hint).
190
+ *
191
+ * Only the form renderer knows a field's label — the widget sees its own
192
+ * metadata and a `depends_on` list of API names. Without this map the gate
193
+ * sentence interpolated the raw API name into every locale, `en` included
194
+ * ("Select crm_account first"), which is an internal identifier leaking into
195
+ * the UI, not merely an untranslated word (objectstack#5407). This is the
196
+ * lookup-side counterpart of `emptyHint`, which the form already resolves to
197
+ * labels for the fixed-option widgets. A name with no entry falls back to
198
+ * itself, so a host that passes nothing renders exactly what it did before.
199
+ */
200
+ dependsOnLabels?: Record<string, string>;
201
+ /**
202
+ * Hint shown when an option list cannot be filled — typically a
203
+ * dependency-gated list still waiting on its controlling field (#2284).
204
+ * Forwarded by the form renderer, which resolves the controlling fields to
205
+ * their human LABELS. **When supplied it wins**; the option widgets fall back
206
+ * to their own translated copy only when a host computed none (objectui#3231
207
+ * — they used to discard this prop and always render their own hardcoded
208
+ * English). See `OptionsEmptyState`, the single consumer.
105
209
  */
106
210
  emptyHint?: string;
107
211
  /**
@@ -121,17 +225,13 @@ export type FieldWidgetComponentProps<T = any> = {
121
225
  */
122
226
  onCreateNew?: (searchQuery: string) => void;
123
227
 
124
- /* ── DOM pass-through: what `...props` may legitimately reach an input ──── */
125
-
126
- id?: string;
127
- /** react-hook-form's field name, spread in by the form renderer. */
128
- name?: string;
129
- autoFocus?: boolean;
130
- tabIndex?: number;
131
- onBlur?: FocusEventHandler<HTMLElement>;
132
- onFocus?: FocusEventHandler<HTMLElement>;
133
- onClick?: MouseEventHandler<HTMLElement>;
134
- } & AriaAttributes & {
228
+ /* ── DOM pass-through ───────────────────────────────────────────────────── */
229
+ //
230
+ // Lives in the named {@link FieldWidgetDomProps} above, because that is what
231
+ // lets the compiler bind the declaration to `toDomProps` — its runtime
232
+ // executor — in BOTH directions (objectui#3291). Add a DOM key THERE, not
233
+ // here, and the compiler will make you add it to the whitelist too.
234
+ } & FieldWidgetDomProps & AriaAttributes & {
135
235
  /**
136
236
  * Arbitrary `data-*` attributes (test ids, analytics hooks). Open by design,
137
237
  * but a template-literal key — `keyof` stays finite, so this does NOT
@@ -39,6 +39,12 @@ const FIELD_DEFAULTS: Record<string, string> = {
39
39
  'lookup.nextPage': 'Next page',
40
40
  'lookup.jumpToPage': 'Jump to page',
41
41
  'lookup.retry': 'Retry',
42
+ // objectui#3231 — the empty / dependency-gated state of the fixed-option
43
+ // widgets (select, multiselect, radio, checkboxes). Only used when the host
44
+ // supplies no `emptyHint`; the gate sentence shares its key with the form
45
+ // renderer so both cannot drift apart in a locale.
46
+ 'fields.options.empty': 'No options available',
47
+ 'fields.options.selectFirst': 'Select {{fields}} first',
42
48
  // objectstack#3821 — sharing-rule authoring widgets (object-ref /
43
49
  // recipient-picker / filter-condition). The recipient placeholder is keyed
44
50
  // PER TYPE rather than interpolating the enum value into an English
@@ -69,6 +75,60 @@ const FIELD_DEFAULTS: Record<string, string> = {
69
75
  'fields.filterCondition.jsonOnly': 'This criteria can only be edited as JSON',
70
76
  'fields.filterCondition.editAsJson': 'Edit as JSON',
71
77
  'fields.filterCondition.useVisualBuilder': 'Use visual builder',
78
+ // objectui#3342 — the tags widget's input hint. Used only when the field
79
+ // author declared no `placeholder` of their own (author declaration wins).
80
+ 'fields.tags.placeholder': 'Type and press Enter to add…',
81
+ // objectui#3406 — the accessible name of `TextAreaField`'s character
82
+ // counter, rendered only when the field declares `maxLength`. The visible
83
+ // `{n}/{max}` is digits; this sentence is what a screen reader speaks, and
84
+ // the element is `aria-live`, so before the key existed a non-English
85
+ // session heard English on every keystroke. Byte-identical to the literal
86
+ // it replaces, so a no-provider render is unchanged.
87
+ 'fields.textarea.characterCount': 'Character count: {{count}} of {{max}}',
88
+ // objectui#3408 — the NEAR-LIMIT warning, spoken by the counter's status
89
+ // region after the typist pauses and only once they are inside the last 10%
90
+ // (or last 20 characters) of the cap. The sentence above became the field's
91
+ // `aria-describedby` description in the same change; this one is the only
92
+ // thing that is ever announced mid-typing, which is why it says what is
93
+ // LEFT rather than repeating the whole count.
94
+ //
95
+ // Colon form, not "You have {{count}} characters remaining": `count` makes
96
+ // i18next attempt plural lookup (`_one` / `_few` / …) before the base key,
97
+ // and these packs declare the base key only — the same base-key fallback
98
+ // `characterCount` already relies on. A sentence whose grammar does not
99
+ // depend on the number stays correct at 1 without ten plural entries per
100
+ // pack, and matches the sibling key's shape.
101
+ 'fields.textarea.charactersRemaining': 'Characters remaining: {{count}}',
102
+ // objectui#3404 — the shared fullscreen long-text dialog
103
+ // (`FullscreenFieldEditor`, reached from `TextAreaField` and
104
+ // `RichTextField` when `mobile_fullscreen` is set).
105
+ //
106
+ // Deliberately the SAME `form.fullscreen.*` / `common.cancel` keys the
107
+ // built-in branch consumes (`FullscreenTextarea` in
108
+ // `components/src/renderers/form/form.tsx`, objectui#3272) rather than
109
+ // `fields.fullscreen.*` twins. One form-level setting
110
+ // (`ObjectFormSchema.mobile.fullscreenLongText`) projects onto BOTH render
111
+ // paths inside a single form, so a second copy of this copy is guaranteed
112
+ // drift — the shape #3231/#3263 had to undo — and would mean editing ten
113
+ // locale packs to say what they already say. `common.cancel` is already
114
+ // declared above and is reused here for exactly that reason.
115
+ //
116
+ // The values are byte-identical to the English literals they replaced, so
117
+ // a widget rendered with no I18nProvider (standalone/embedded hosts, and
118
+ // every bare-render test in this package) shows what it always did instead
119
+ // of a raw key. Measured before the change: with no provider mounted, bare
120
+ // `useObjectTranslation().t` returns the key itself
121
+ // (`common.cancel` → "common.cancel"), which is why this goes through
122
+ // `createSafeTranslation` like the built-in branch does.
123
+ 'form.fullscreen.title': 'Edit text',
124
+ 'form.fullscreen.description': 'Edit the full text value, then save or cancel your changes.',
125
+ 'form.fullscreen.done': 'Done',
126
+ // Kept as ONE interpolated sentence with a translated generic noun standing
127
+ // in for a missing label — the literal's own shape
128
+ // (`Edit ${label ?? 'text'} fullscreen`). A second, label-less sentence key
129
+ // would need ten new pack entries to say the same thing.
130
+ 'form.fullscreen.toggle': 'Edit {{label}} fullscreen',
131
+ 'form.fullscreen.textFallback': 'text',
72
132
  // objectui#2600 B5 — capability picker scope group headers.
73
133
  'capability.group.platform': 'Platform',
74
134
  'capability.group.org': 'Organization',