@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,169 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * The SDUI path delivers field metadata on ONE key — objectui#3233.
11
+ *
12
+ * `SchemaRenderer` hands every registered component the authored node as
13
+ * `schema`. That key is the universal SDUI contract (`element:*`, `page:*`,
14
+ * `object-grid`, `ReportRenderer` …) and is NOT retired. What was retired is
15
+ * `schema` on the *field-widget* contract: a widget used to receive the node
16
+ * under `schema` here and the metadata under `field` from the form renderer,
17
+ * which is why ~30 widgets resolved their config as `field || schema`.
18
+ *
19
+ * The translation now happens once, at the registration seam
20
+ * (`withFieldCarrier`), so downstream `field` is the only carrier. That is only
21
+ * correct if the node reaches the widget UNCHANGED — "swap the key, don't drop
22
+ * the cargo". The first test proves it by OBJECT IDENTITY: a spy sits where the
23
+ * registry entry sits, records the object `SchemaRenderer` passed as `schema`,
24
+ * and the widget's `field` must be that exact object. (Identity against the
25
+ * authored literal would be wrong to assert — `SchemaRenderer` shallow-copies
26
+ * the node while evaluating expressions, so the object it delivers is its own.)
27
+ *
28
+ * The last test pins that the REAL registration path goes through the adapter
29
+ * at all: an adapter nobody applied would leave every SDUI-hosted widget
30
+ * reading `undefined`, which is exactly the silent failure this convergence is
31
+ * accused of risking.
32
+ *
33
+ * The sibling half of the invariant — the form path through
34
+ * `renderFieldComponent` — is pinned in `packages/components/src/renderers/
35
+ * form/__tests__/form-field-carrier.test.tsx`.
36
+ */
37
+
38
+ import React, { Suspense } from 'react';
39
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
40
+ import { render, screen, cleanup } from '@testing-library/react';
41
+ import { ComponentRegistry } from '@object-ui/core';
42
+ import { SchemaRenderer } from '@object-ui/react';
43
+
44
+ import { withFieldCarrier } from '../withFieldCarrier';
45
+ import { registerField } from '../index';
46
+ // Module scope, not a hook: `registerField('boolean')` registers
47
+ // `React.lazy(() => import('./widgets/BooleanField'))`, and under a saturated
48
+ // transform pipeline a first `import()` can outlive RTL's 1000ms budget. The
49
+ // specifier matches `fieldWidgetMap`'s exactly, so ESM's cache makes that
50
+ // factory resolve immediately (AGENTS.md 测试纪律 / objectui#3010).
51
+ import '../widgets/BooleanField';
52
+
53
+ /**
54
+ * Capture holder, not bare module variables: `react-hooks/globals` forbids
55
+ * REASSIGNING an outer binding from a component body, which is what a probe
56
+ * has to do. Mutating a property of a stable object is the repo's usual shape
57
+ * for this (see plugin-detail's `RelatedList` probes).
58
+ */
59
+ const captured: { props: Record<string, any> | null; node: unknown } = {
60
+ props: null,
61
+ node: null,
62
+ };
63
+
64
+ function CarrierProbe(props: any) {
65
+ captured.props = props;
66
+ return <div data-testid="carrier-probe">{props.field?.label ?? 'NO FIELD'}</div>;
67
+ }
68
+
69
+ const AdaptedProbe = withFieldCarrier(CarrierProbe);
70
+
71
+ /**
72
+ * Sits exactly where the registry entry sits, so it sees the host's props
73
+ * verbatim, then forwards them to the adapter under test. This is what makes
74
+ * the identity assertion possible: `captured.node` and the widget's `field`
75
+ * are observed within a single render pass.
76
+ */
77
+ function CarrierSpy(props: any) {
78
+ captured.node = props.schema;
79
+ return <AdaptedProbe {...props} />;
80
+ }
81
+
82
+ beforeEach(() => {
83
+ captured.props = null;
84
+ captured.node = null;
85
+ ComponentRegistry.register('carrierprobe', CarrierSpy, {
86
+ namespace: 'field',
87
+ skipFallback: true,
88
+ });
89
+ });
90
+
91
+ afterEach(() => {
92
+ cleanup();
93
+ vi.restoreAllMocks();
94
+ });
95
+
96
+ describe('SDUI path — the authored node arrives as `field` (objectui#3233)', () => {
97
+ it('hands the widget the very object it used to read through `schema`', () => {
98
+ const node = {
99
+ type: 'field:carrierprobe',
100
+ name: 'city',
101
+ label: 'City',
102
+ placeholder: 'Where?',
103
+ options: [{ label: 'Beijing', value: 'bj' }],
104
+ };
105
+
106
+ render(<SchemaRenderer schema={node as any} />);
107
+
108
+ expect(screen.getByTestId('carrier-probe')).toHaveTextContent('City');
109
+ expect(captured.props).not.toBeNull();
110
+ expect(captured.node).toBeTruthy();
111
+ // The payload-equivalence proof: same object, by reference. A
112
+ // reconstruction would still render "City" while quietly dropping whatever
113
+ // the copy did not know to carry.
114
+ expect(captured.props!.field).toBe(captured.node);
115
+ // …and it really is the whole authored node, not a narrowed projection.
116
+ expect(captured.props!.field.name).toBe('city');
117
+ expect(captured.props!.field.placeholder).toBe('Where?');
118
+ expect(captured.props!.field.options).toEqual([{ label: 'Beijing', value: 'bj' }]);
119
+ });
120
+
121
+ it('consumes `schema` rather than forwarding it to the widget', () => {
122
+ // Key ABSENCE, not `undefined`. Two reasons it must not be forwarded: a
123
+ // widget could resolve `field || schema` again (the second contract
124
+ // survives in practice while the type claims it is gone), and widgets
125
+ // spread their leftover props onto DOM nodes, where an object-valued
126
+ // `schema` attribute is a React warning.
127
+ render(
128
+ <SchemaRenderer schema={{ type: 'field:carrierprobe', name: 'city', label: 'City' } as any} />,
129
+ );
130
+
131
+ expect(captured.props).not.toBeNull();
132
+ expect('schema' in captured.props!).toBe(false);
133
+ });
134
+
135
+ it('lets an explicit `field` win when a host supplies both', () => {
136
+ // The form renderer passes `field` (resolved metadata) and never `schema`.
137
+ // If some host ever passed both, the resolved metadata is the better
138
+ // answer — the raw node is the fallback, not the override.
139
+ const node = { type: 'field:carrierprobe', name: 'from-node', label: 'From node' };
140
+ const metadata = { name: 'from-field', label: 'From field' };
141
+
142
+ render(<AdaptedProbe schema={node} field={metadata} />);
143
+
144
+ expect(captured.props!.field).toBe(metadata);
145
+ });
146
+
147
+ it('applies the adapter on the REAL registration path (`registerField`)', async () => {
148
+ // The seam is only worth anything if `registerField` goes through it.
149
+ // `BooleanField` reads `widget` / `name` off its metadata carrier and
150
+ // nowhere else: `widget: 'checkbox'` selects the Checkbox over the Switch,
151
+ // and `name` becomes the control's id. Both are node keys, so seeing them
152
+ // honoured here proves the node reached `field` end to end — through the
153
+ // real registry entry, the real SchemaRenderer, and the real widget.
154
+ registerField('boolean');
155
+
156
+ render(
157
+ <Suspense fallback={<div>loading</div>}>
158
+ <SchemaRenderer
159
+ schema={
160
+ { type: 'field:boolean', name: 'is_active', label: 'Active', widget: 'checkbox' } as any
161
+ }
162
+ />
163
+ </Suspense>,
164
+ );
165
+
166
+ const control = await screen.findByRole('checkbox');
167
+ expect(control).toHaveAttribute('id', 'is_active');
168
+ });
169
+ });
@@ -21,10 +21,10 @@
21
21
  *
22
22
  * - `FieldWidgetProps` is now `FieldWidgetComponentProps`. The spec owns that
23
23
  * name for the DECLARED widget-plugin props contract; this package's is the
24
- * React interface its widgets implement. The pins record the divergence that
25
- * made re-exporting impossible — and, since objectui#3221 closed the type,
26
- * that the divergence is now *visible* rather than swallowed by a
27
- * `[key: string]: any` index signature.
24
+ * React interface its widgets implement. The pins record what still makes
25
+ * re-exporting impossible (`field` is the far richer `FieldMetadata` here)
26
+ * and, since objectui#3222, what no longer does: the validation slot is the
27
+ * spec's `error` on both sides.
28
28
  *
29
29
  * The other direction — "the spec must not start owning the NEW names" — is not
30
30
  * asserted here because `scripts/check-spec-symbol-derivation.mjs` already
@@ -80,26 +80,40 @@ describe('FieldWidgetComponentProps is the RENDERED layer of the spec contract',
80
80
  // reason is up for re-triage (the plain name could come back).
81
81
  type _SpecIsReal = Assert<Equal<IsAny<SpecFieldWidgetProps>, false>>;
82
82
 
83
- // The divergence that makes a re-export impossible AND is a live defect
84
- // worth naming: the declared contract calls the validation-message slot
85
- // `error`; every widget in this package reads `errorMessage`. A widget
86
- // written against the spec's declared props renders no message here, and
87
- // nothing reports it. Recorded, not fixed, by the rename — objectui#3222.
83
+ // The validation-message slot no longer diverges. objectui#3161 could only
84
+ // RECORD that the declared contract called it `error` while every widget
85
+ // here read `errorMessage`; objectui#3222 resolved it in the direction the
86
+ // contract points — this package adopted `error`, and the form renderer
87
+ // started producing it, so the name and the delivery landed together.
88
+ // Both sides are pinned: the spec still owns the name, and this package
89
+ // still spells it the same way.
88
90
  type _SpecNamesItError = Assert<HasKey<SpecFieldWidgetProps, 'error'>>;
89
- type _LocalNamesItErrorMessage = Assert<HasKey<FieldWidgetComponentProps, 'errorMessage'>>;
91
+ type _LocalNamesItErrorToo = Assert<HasKey<FieldWidgetComponentProps, 'error'>>;
92
+ type _SameOptionalStringSlot = Assert<
93
+ Equal<FieldWidgetComponentProps['error'], SpecFieldWidgetProps['error']>
94
+ >;
95
+ // The old name is gone, not kept as an alias. An alias would be exactly the
96
+ // lenient second dialect AGENTS.md #0.1 forbids — and the reason a missed
97
+ // call site would have gone quiet again.
98
+ type _OldNameRetired = Assert<Equal<HasKey<FieldWidgetComponentProps, 'errorMessage'>, false>>;
90
99
 
91
100
  // …and the reason nobody noticed used to sit right here: three pins
92
101
  // asserting that `[key: string]: any` was still present, and that
93
102
  // `props.required` / `props.error` therefore read as `any`. objectui#3221
94
103
  // removed that index signature, so those pins have gone red on purpose and
95
104
  // are gone with it. What replaces them is the inverse claim — the type is
96
- // now CLOSED, so the two keys the spec declares and this one does not can
97
- // finally be reported as missing. That is what makes objectui#3222 (the
98
- // `error` / `errorMessage` divergence) decidable by the compiler instead of
99
- // by a symbol guard.
105
+ // now CLOSED, so a key the spec declares and this one does not can finally
106
+ // be reported as missing. That is what made objectui#3222's rename
107
+ // decidable by the compiler instead of by a symbol guard.
100
108
  type _NoStringIndexSignature = Assert<Equal<Extends<string, keyof FieldWidgetComponentProps>, false>>;
109
+ // `required` is the one key that stayed behind, and deliberately so
110
+ // (objectui#3222): the required marker has a single author — the form
111
+ // renderer's `<FormLabel>` — and lowering the flag into widget props gives
112
+ // it a second, i.e. the double-display failure that also keeps the
113
+ // validation TEXT out of the widget. The a11y state a widget could
114
+ // legitimately carry is `aria-required`, and `AriaAttributes` already
115
+ // supplies that key with no contract change at all.
101
116
  type _RequiredIsAbsent = Assert<Equal<HasKey<FieldWidgetComponentProps, 'required'>, false>>;
102
- type _ErrorIsAbsent = Assert<Equal<HasKey<FieldWidgetComponentProps, 'error'>, false>>;
103
117
 
104
118
  // `data-*` stays open (it is open in HTML too), but as a template-literal
105
119
  // key — the distinction that keeps `keyof` finite above. A pin, because
@@ -10,7 +10,16 @@
10
10
  * P3.2 Field Widget Polish - Validation Feedback
11
11
  *
12
12
  * Tests that field widgets consistently handle validation feedback:
13
- * errorMessage prop, aria-invalid attribute, and disabled states.
13
+ * `error` prop, aria-invalid attribute, and disabled states.
14
+ *
15
+ * The slot is `error` — the name `@objectstack/spec/ui`'s
16
+ * `FieldWidgetPropsSchema` gives it — since objectui#3222. These are the
17
+ * CONSUMER half only: they prove a widget handed a message marks its control
18
+ * invalid. That was true before #3222 too, under the name `errorMessage`, and
19
+ * it did not help anyone, because no host ever passed the prop. The PRODUCER
20
+ * half is `form-error-delivery.test.tsx` in `@object-ui/components` plus
21
+ * `widget-aria-invalid-e2e.test.tsx` next to this file; a rename alone would
22
+ * have swapped one dead key for another.
14
23
  */
15
24
 
16
25
  import { describe, it, expect, vi } from 'vitest';
@@ -32,23 +41,23 @@ const noop = vi.fn();
32
41
 
33
42
  describe('P3.2 Validation Feedback', () => {
34
43
  // ---------------------------------------------------------------
35
- // aria-invalid on errorMessage
44
+ // aria-invalid on `error`
36
45
  // ---------------------------------------------------------------
37
46
  describe('aria-invalid attribute', () => {
38
- it('EmailField sets aria-invalid when errorMessage provided', () => {
47
+ it('EmailField sets aria-invalid when `error` provided', () => {
39
48
  render(
40
49
  <EmailField
41
50
  value=""
42
51
  onChange={noop}
43
52
  field={{ type: 'email' } as any}
44
- errorMessage="Invalid email"
53
+ error="Invalid email"
45
54
  />
46
55
  );
47
56
  const input = screen.getByRole('textbox');
48
57
  expect(input).toHaveAttribute('aria-invalid', 'true');
49
58
  });
50
59
 
51
- it('EmailField does not set aria-invalid without errorMessage', () => {
60
+ it('EmailField does not set aria-invalid without `error`', () => {
52
61
  render(
53
62
  <EmailField
54
63
  value=""
@@ -60,52 +69,52 @@ describe('P3.2 Validation Feedback', () => {
60
69
  expect(input).not.toHaveAttribute('aria-invalid', 'true');
61
70
  });
62
71
 
63
- it('UrlField sets aria-invalid when errorMessage provided', () => {
72
+ it('UrlField sets aria-invalid when `error` provided', () => {
64
73
  render(
65
74
  <UrlField
66
75
  value=""
67
76
  onChange={noop}
68
77
  field={{ type: 'url' } as any}
69
- errorMessage="Invalid URL"
78
+ error="Invalid URL"
70
79
  />
71
80
  );
72
81
  const input = screen.getByDisplayValue('');
73
82
  expect(input).toHaveAttribute('aria-invalid', 'true');
74
83
  });
75
84
 
76
- it('PhoneField sets aria-invalid when errorMessage provided', () => {
85
+ it('PhoneField sets aria-invalid when `error` provided', () => {
77
86
  render(
78
87
  <PhoneField
79
88
  value=""
80
89
  onChange={noop}
81
90
  field={{ type: 'phone' } as any}
82
- errorMessage="Invalid phone"
91
+ error="Invalid phone"
83
92
  />
84
93
  );
85
94
  const input = screen.getByRole('textbox');
86
95
  expect(input).toHaveAttribute('aria-invalid', 'true');
87
96
  });
88
97
 
89
- it('TextAreaField sets aria-invalid when errorMessage provided', () => {
98
+ it('TextAreaField sets aria-invalid when `error` provided', () => {
90
99
  render(
91
100
  <TextAreaField
92
101
  value=""
93
102
  onChange={noop}
94
103
  field={{ type: 'textarea' } as any}
95
- errorMessage="Required"
104
+ error="Required"
96
105
  />
97
106
  );
98
107
  const textarea = screen.getByRole('textbox');
99
108
  expect(textarea).toHaveAttribute('aria-invalid', 'true');
100
109
  });
101
110
 
102
- it('CurrencyField sets aria-invalid when errorMessage provided', () => {
111
+ it('CurrencyField sets aria-invalid when `error` provided', () => {
103
112
  render(
104
113
  <CurrencyField
105
114
  value={0}
106
115
  onChange={noop}
107
116
  field={{ type: 'currency' } as any}
108
- errorMessage="Must be positive"
117
+ error="Must be positive"
109
118
  />
110
119
  );
111
120
  const input = screen.getByRole('spinbutton');
@@ -0,0 +1,265 @@
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
+ * End-to-end: a REAL widget, inside the REAL form renderer, announces an
11
+ * invalid field to assistive tech (objectui#3222).
12
+ *
13
+ * The two halves of this contract have unit tests of their own — the widgets
14
+ * mark themselves invalid when handed an `error`
15
+ * (`validation-feedback.test.tsx`), and the form renderer produces one
16
+ * (`form-error-delivery.test.tsx` in `@object-ui/components`). Neither alone
17
+ * would have caught the actual defect, because the defect was precisely that
18
+ * the two halves never met: the widgets read `errorMessage`, the spec's
19
+ * published contract said `error`, and NOTHING in `packages/` or `apps/`
20
+ * produced either. Seven widgets computed `aria-invalid={!!errorMessage}` from
21
+ * a value that was `undefined` forever, so `aria-invalid` had never once been
22
+ * set on a failing field.
23
+ *
24
+ * So this file joins them: real react-hook-form validation, real widget, one
25
+ * assertion per widget that the control is `aria-invalid="true"` after the
26
+ * failure and `"false"` before it. `aria-invalid="false"` up front is the load
27
+ * bearing half of each pair — `<FormControl>` is a Radix `Slot` that hands its
28
+ * child a correct `aria-invalid`, and the widget's own attribute is written
29
+ * AFTER the props spread, so it wins. A widget computing it from a prop nobody
30
+ * passes does not merely miss the signal, it OVERWRITES the correct one with
31
+ * `false`.
32
+ *
33
+ * The widgets are registered raw rather than through `registerAllFields()`,
34
+ * which wraps every loader in `React.lazy`: an unbounded module load inside a
35
+ * bounded `findBy`/`waitFor` window is the repo's known flake generator
36
+ * (AGENTS.md 测试纪律, objectui#3010). Same component, no Suspense boundary.
37
+ */
38
+
39
+ import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
40
+ import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
41
+ import '@testing-library/jest-dom';
42
+ import { ComponentRegistry } from '@object-ui/core';
43
+ // Module scope: pulls in the form renderer's registration side effect.
44
+ import '@object-ui/components';
45
+
46
+ import { EmailField } from '../widgets/EmailField';
47
+ import { PhoneField } from '../widgets/PhoneField';
48
+ import { UrlField } from '../widgets/UrlField';
49
+ import { TextAreaField } from '../widgets/TextAreaField';
50
+ import { CurrencyField } from '../widgets/CurrencyField';
51
+ import { PercentField } from '../widgets/PercentField';
52
+ import { RichTextField } from '../widgets/RichTextField';
53
+ import { TextField } from '../widgets/TextField';
54
+ import { SelectField } from '../widgets/SelectField';
55
+
56
+ /**
57
+ * The widgets that read the validation slot, by their form-path key: the seven
58
+ * from objectui#3222, plus `select` (objectui#3306) — the widget whose control
59
+ * is a Radix trigger rather than a native input, where every aria-* used to be
60
+ * swallowed by the non-DOM `Select.Root`.
61
+ */
62
+ const WIDGETS = [
63
+ ['email', EmailField],
64
+ ['phone', PhoneField],
65
+ ['url', UrlField],
66
+ ['textarea', TextAreaField],
67
+ ['currency', CurrencyField],
68
+ ['percent', PercentField],
69
+ ['markdown', RichTextField],
70
+ ['select', SelectField],
71
+ ] as const;
72
+
73
+ beforeAll(() => {
74
+ for (const [type, Widget] of WIDGETS) {
75
+ ComponentRegistry.register(type, Widget as any, { namespace: 'field', skipFallback: true });
76
+ }
77
+ }, 30000);
78
+
79
+ beforeEach(() => {
80
+ if (!(Element.prototype as any).scrollIntoView) {
81
+ (Element.prototype as any).scrollIntoView = () => {};
82
+ }
83
+ });
84
+
85
+ afterEach(() => {
86
+ cleanup();
87
+ vi.restoreAllMocks();
88
+ });
89
+
90
+ function renderForm(field: any, defaultValue: unknown) {
91
+ const Form = ComponentRegistry.get('form')!;
92
+ return render(
93
+ <Form
94
+ schema={{
95
+ type: 'form',
96
+ mode: 'create',
97
+ showSubmit: true,
98
+ showCancel: false,
99
+ submitLabel: 'Create',
100
+ defaultValues: { [field.name]: defaultValue },
101
+ fields: [field],
102
+ onSubmit: () => {},
103
+ }}
104
+ />,
105
+ );
106
+ }
107
+
108
+ /**
109
+ * The control the widget rendered, located through the form row rather than by
110
+ * role — the seven widgets render `input[type=email|tel|url|number]` and
111
+ * `textarea` between them, and the point of the assertion is the ATTRIBUTE,
112
+ * not which element type carries it.
113
+ */
114
+ function controlOf(name: string): Element {
115
+ const control = document.querySelector(
116
+ `[data-field="${name}"] input, [data-field="${name}"] textarea`,
117
+ );
118
+ if (!control) throw new Error(`no control rendered for field "${name}"`);
119
+ return control;
120
+ }
121
+
122
+ const submit = () => fireEvent.click(screen.getByRole('button', { name: /create/i }));
123
+
124
+ describe('field widgets announce an invalid field to AT (objectui#3222)', () => {
125
+ // `field:textarea` because the bare `textarea` key is a BUILTIN_FIELD_TYPE in
126
+ // the form renderer and never reaches the registry — it is the prefixed id
127
+ // (`mapFieldTypeToFormType`) that resolves to this package's widget.
128
+ it.each([
129
+ ['email', 'email', ''],
130
+ ['phone', 'phone', ''],
131
+ ['url', 'url', ''],
132
+ ['field:textarea', 'notes', ''],
133
+ ['currency', 'amount', null],
134
+ ['percent', 'ratio', null],
135
+ ['markdown', 'body', ''],
136
+ ] as const)(
137
+ 'a required %s field is aria-invalid only AFTER validation fails',
138
+ async (type, name, emptyValue) => {
139
+ renderForm({ name, label: name, type, required: true }, emptyValue);
140
+
141
+ // Before the submit there is no error, and the widget must say so —
142
+ // this is the half that a Slot-provided `aria-invalid` cannot fake.
143
+ expect(controlOf(name)).toHaveAttribute('aria-invalid', 'false');
144
+
145
+ submit();
146
+
147
+ await waitFor(() => {
148
+ expect(controlOf(name)).toHaveAttribute('aria-invalid', 'true');
149
+ });
150
+ },
151
+ );
152
+
153
+ it('the message itself is rendered once, by the form — not by the widget', async () => {
154
+ // Responsibility split (the ruling's scope item 3): the widget consumes
155
+ // `error` for `aria-invalid` ONLY. `<FormMessage/>` owns the text; a widget
156
+ // that printed it too would show the user the same sentence twice.
157
+ renderForm({ name: 'email', label: 'Email', type: 'email', required: true }, '');
158
+
159
+ submit();
160
+
161
+ await waitFor(() => expect(screen.getAllByText('Email is required')).toHaveLength(1));
162
+ // And the message is NOT inside the control the widget rendered.
163
+ expect(controlOf('email')).not.toHaveTextContent('Email is required');
164
+ });
165
+
166
+ it('a widget that does NOT read `error` is still marked invalid, by the Slot', async () => {
167
+ // The counterpart that makes the diagnosis precise: `TextField` never
168
+ // computed its own `aria-invalid`, so `<FormControl>`'s Slot value reached
169
+ // its input untouched and it announced invalid fields correctly all along.
170
+ // Only the seven widgets that DID compute one were broken — they wrote
171
+ // theirs after the props spread, so `!!undefined` won.
172
+ //
173
+ // Pinned because the obvious "cleanup" here is to stop forwarding `error`
174
+ // to widgets that do not declare a use for it. That cannot be known (a
175
+ // third-party widget is exactly the case the contract exists for), and
176
+ // withholding it would recreate declared-≠-delivered one widget at a time.
177
+ ComponentRegistry.register('plaintext', TextField as any, { namespace: 'field', skipFallback: true });
178
+ renderForm({ name: 'title', label: 'Title', type: 'plaintext', required: true }, '');
179
+
180
+ submit();
181
+
182
+ await waitFor(() => expect(controlOf('title')).toHaveAttribute('aria-invalid', 'true'));
183
+ });
184
+
185
+ it('clears the invalid state when the field becomes valid again', async () => {
186
+ renderForm({ name: 'email', label: 'Email', type: 'email', required: true }, '');
187
+
188
+ submit();
189
+ await waitFor(() => expect(controlOf('email')).toHaveAttribute('aria-invalid', 'true'));
190
+
191
+ fireEvent.change(controlOf('email'), { target: { value: 'a@example.com' } });
192
+
193
+ await waitFor(() => expect(controlOf('email')).toHaveAttribute('aria-invalid', 'false'));
194
+ });
195
+ });
196
+
197
+ describe('field:select announces validation state on its TRIGGER (objectui#3306)', () => {
198
+ // Radix `Select.Root` renders no DOM element and silently drops every prop
199
+ // it does not recognise. The widget used to spread its DOM pass-through onto
200
+ // Root, so `aria-invalid` / `aria-describedby` / `aria-required` all
201
+ // vanished: a failed required select showed the red message while assistive
202
+ // tech was told nothing. The real control is the focusable
203
+ // `<button role="combobox">` the trigger renders — that is where every
204
+ // assertion below reads from, never a wrapper.
205
+ //
206
+ // `field:select` (not bare `select`) because `select` is a
207
+ // BUILTIN_FIELD_TYPE in the form renderer and never reaches the registry —
208
+ // same reason as `field:textarea` above.
209
+ const OPTIONS = [
210
+ { label: 'Alpha', value: 'alpha' },
211
+ { label: 'Beta', value: 'beta' },
212
+ ];
213
+
214
+ const selectField = {
215
+ name: 'choice',
216
+ label: 'Choice',
217
+ type: 'field:select',
218
+ required: true,
219
+ options: OPTIONS,
220
+ };
221
+
222
+ /** The trigger button, by role — fails loudly if it stops being a combobox. */
223
+ function trigger(): Element {
224
+ const el = document.querySelector('[data-field="choice"] [role="combobox"]');
225
+ if (!el) throw new Error('no select trigger rendered for field "choice"');
226
+ return el;
227
+ }
228
+
229
+ it('a required select is aria-invalid only AFTER validation fails, on the trigger', async () => {
230
+ renderForm(selectField, null);
231
+
232
+ // The explicit "false" half is load-bearing, same as the widgets above: a
233
+ // valid field SAYS it is valid rather than staying mute.
234
+ expect(trigger()).toHaveAttribute('aria-invalid', 'false');
235
+
236
+ submit();
237
+
238
+ // Prove the failure actually rendered before reading aria off it — an
239
+ // error variant that silently produces no error tests nothing.
240
+ await waitFor(() =>
241
+ expect(screen.getAllByText('Choice is required')).toHaveLength(1),
242
+ );
243
+ expect(trigger()).toHaveAttribute('aria-invalid', 'true');
244
+ });
245
+
246
+ it('carries aria-required on the trigger — the state channel of objectui#3290', () => {
247
+ renderForm(selectField, null);
248
+
249
+ // Root would swallow it; the trigger is "the control" for a Radix select.
250
+ expect(trigger()).toHaveAttribute('aria-required', 'true');
251
+ });
252
+
253
+ it('aria-describedby on the trigger references the rendered error message', async () => {
254
+ renderForm(selectField, null);
255
+
256
+ submit();
257
+
258
+ const message = await screen.findByText('Choice is required');
259
+ // The association is only real if the id chain closes: the trigger must
260
+ // point at the exact element carrying the message text.
261
+ expect(message.id).not.toBe('');
262
+ const describedBy = trigger().getAttribute('aria-describedby') ?? '';
263
+ expect(describedBy.split(/\s+/)).toContain(message.id);
264
+ });
265
+ });