@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
@@ -0,0 +1,142 @@
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
+ * `FieldWidgetComponentProps` is a CLOSED contract (objectui#3221).
11
+ *
12
+ * It used to end in `[key: string]: any`, which made every check over it
13
+ * vacuous: a type claiming to have every key can never be reported as missing
14
+ * one (objectstack#4075). Three consequences, one test each below:
15
+ *
16
+ * 1. a key the type does not declare is a compile error, not a silent `any`
17
+ * — which is what let objectui#3222 rename the validation slot to the
18
+ * spec's `error` with the compiler, not grep, proving no call site was
19
+ * missed;
20
+ * 2. a MISSPELLED prop (`readOnly` for `readonly`) is rejected;
21
+ * 3. the pass-through keys hosts genuinely forward still type-check, and
22
+ * still reach the rendered control at runtime — closing the type must not
23
+ * have been paid for by dropping real behaviour.
24
+ *
25
+ * (1) and (2) are compile-time: a violation fails `pnpm --filter
26
+ * @object-ui/fields type-check`, not this run. `@ts-expect-error` inverts
27
+ * that — the line fails to compile if the error it expects ever STOPS
28
+ * happening, i.e. if the index signature comes back.
29
+ */
30
+
31
+ import { describe, it, expect } from 'vitest';
32
+ import { render, screen } from '@testing-library/react';
33
+
34
+ import { TextField } from '../widgets/TextField';
35
+ import { BooleanField } from '../widgets/BooleanField';
36
+ import type { FieldWidgetComponentProps } from '../widgets/types';
37
+ import type { FieldMetadata } from '@object-ui/types';
38
+
39
+ type Assert<T extends true> = T;
40
+ type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
41
+ type Extends<A, B> = [A] extends [B] ? true : false;
42
+
43
+ const field = { name: 'title', label: 'Title', type: 'text' } as unknown as FieldMetadata;
44
+
45
+ describe('FieldWidgetComponentProps is closed (objectui#3221)', () => {
46
+ it('has no `[key: string]` index signature', () => {
47
+ // The single assertion the whole change turns on. While the index
48
+ // signature existed this was `true`, and every check below was vacuous.
49
+ type _NotOpen = Assert<Equal<Extends<string, keyof FieldWidgetComponentProps>, false>>;
50
+ expect(true).toBe(true);
51
+ });
52
+
53
+ it('rejects keys it does not declare', () => {
54
+ const props = {} as FieldWidgetComponentProps<string>;
55
+
56
+ // `required` stays out on purpose (objectui#3222): the required marker has
57
+ // exactly one author, the form renderer's `<FormLabel>`, and handing the
58
+ // flag to widgets invites a second one. Its sibling `error` went the other
59
+ // way — the spec declares it, so the slot below adopted the spec's name and
60
+ // the form renderer now produces it.
61
+ // @ts-expect-error `required` is not part of this contract
62
+ void props.required;
63
+ const message: string | undefined = props.error;
64
+ void message;
65
+
66
+ // …and the class of bug the index signature hid best: a typo.
67
+ // @ts-expect-error the prop is `readonly`, not React's DOM `readOnly`
68
+ void props.readOnly;
69
+ // @ts-expect-error the prop is `onChange`
70
+ void props.onchange;
71
+
72
+ // The second metadata carrier is gone (objectui#3233). `schema` was a
73
+ // DECLARED key here through #3221 — one concept under two spellings, which
74
+ // is how ~30 widgets ended up resolving `field || schema`. v17 converged it
75
+ // at the producers, so reading it is now a compile error rather than a
76
+ // silent second contract for the next widget author to discover.
77
+ // @ts-expect-error `schema` was retired from the widget contract in v17
78
+ void props.schema;
79
+
80
+ expect(true).toBe(true);
81
+ });
82
+
83
+ it('still accepts the pass-through keys hosts actually forward', () => {
84
+ // Closing the type is only correct if the real call sites still compile.
85
+ // Each key here has a named producer: the form renderer, the inline-edit
86
+ // host, or the line-item grid. Deleting one from the type breaks this.
87
+ const passThrough: FieldWidgetComponentProps<string> = {
88
+ value: '',
89
+ onChange: () => {},
90
+ // The single metadata carrier (objectui#3233) — every host produces this
91
+ // one key: the form renderer (`renderFieldComponent`), the inline-edit
92
+ // hosts, and the registry adapter that maps `SchemaRenderer`'s SDUI node
93
+ // onto it.
94
+ field,
95
+ dataSource: {},
96
+ dependentValues: { country: 'cn' },
97
+ dependsOn: ['country'],
98
+ emptyHint: 'Select country first',
99
+ name: 'title',
100
+ // inline-edit hosts / line-item grid
101
+ compact: true,
102
+ autoFocus: true,
103
+ onSelectRecord: () => {},
104
+ onCreateNew: () => {},
105
+ // DOM / a11y
106
+ id: 'title-input',
107
+ 'aria-label': 'Title',
108
+ 'data-testid': 'field-title',
109
+ };
110
+ expect(passThrough.compact).toBe(true);
111
+ });
112
+ });
113
+
114
+ describe('closing the type kept the pass-through behaviour', () => {
115
+ it('forwards a11y and data attributes to the rendered input', () => {
116
+ render(
117
+ <TextField
118
+ value="hello"
119
+ onChange={() => {}}
120
+ field={field}
121
+ aria-label="Title"
122
+ data-testid="title-input"
123
+ />,
124
+ );
125
+ const input = screen.getByTestId('title-input');
126
+ expect(input).toHaveAttribute('aria-label', 'Title');
127
+ expect(input).toHaveValue('hello');
128
+ });
129
+
130
+ it('forwards `disabled` — a declared key, not an index-signature accident', () => {
131
+ render(
132
+ <BooleanField
133
+ value={false}
134
+ onChange={() => {}}
135
+ field={{ name: 'active', label: 'Active', type: 'boolean' } as unknown as FieldMetadata}
136
+ disabled
137
+ data-testid="active-switch"
138
+ />,
139
+ );
140
+ expect(screen.getByTestId('active-switch')).toBeDisabled();
141
+ });
142
+ });
@@ -6,7 +6,7 @@ import { FieldEditWidget } from './FieldEditWidget';
6
6
  import { MasterDetailField } from './widgets/MasterDetailField';
7
7
  import { GridField } from './widgets/GridField';
8
8
  import { FileField } from './widgets/FileField';
9
- import type { FieldWidgetProps } from './widgets/types';
9
+ import type { FieldWidgetComponentProps } from './widgets/types';
10
10
 
11
11
  // ------------- Mocks & Setup -------------
12
12
 
@@ -15,7 +15,7 @@ const mockField = {
15
15
  label: 'Test Field',
16
16
  } as any;
17
17
 
18
- const baseProps: FieldWidgetProps<any> = {
18
+ const baseProps: FieldWidgetComponentProps<any> = {
19
19
  field: mockField,
20
20
  value: undefined,
21
21
  onChange: vi.fn(),
@@ -78,7 +78,7 @@ describe('Complex & Relationship Widgets', () => {
78
78
  reference_field: 'name',
79
79
  } as any;
80
80
 
81
- const dynamicProps: FieldWidgetProps<any> = {
81
+ const dynamicProps: FieldWidgetComponentProps<any> = {
82
82
  ...baseProps,
83
83
  field: dynamicField,
84
84
  dataSource: mockDataSource,
@@ -3,7 +3,7 @@ import { describe, it, expect, vi } from 'vitest';
3
3
  import { DateField } from './widgets/DateField';
4
4
  import { DateTimeField } from './widgets/DateTimeField';
5
5
  import { TimeField } from './widgets/TimeField';
6
- import type { FieldWidgetProps } from './widgets/types';
6
+ import type { FieldWidgetComponentProps } from './widgets/types';
7
7
 
8
8
  const mockField = {
9
9
  name: 'test_field',
@@ -11,7 +11,7 @@ const mockField = {
11
11
  type: 'date',
12
12
  } as any;
13
13
 
14
- const baseProps: FieldWidgetProps<string> = {
14
+ const baseProps: FieldWidgetComponentProps<string> = {
15
15
  field: mockField,
16
16
  value: '',
17
17
  onChange: vi.fn(),
package/src/index.tsx CHANGED
@@ -14,6 +14,7 @@ import { Badge, Avatar, AvatarImage, AvatarFallback, Button, Checkbox, EmptyValu
14
14
  import { Check, X, Copy, Phone as PhoneIcon, MapPin } from 'lucide-react';
15
15
  import { useObjectTranslation } from '@object-ui/react';
16
16
  import { SchemaRendererContext as _SchemaRendererContext } from '@object-ui/react';
17
+ import { withFieldCarrier } from './withFieldCarrier';
17
18
 
18
19
  // Module-level cache so multiple renderers fetching the same lookup ID
19
20
  // only trigger one network call. Keyed by `${objectName}:${id}`.
@@ -589,13 +590,42 @@ export function formatDateTime(value: string | Date | number): string {
589
590
  });
590
591
  }
591
592
 
593
+ /**
594
+ * Single-line cell value with a working ellipsis and a full-text fallback.
595
+ *
596
+ * `truncate` on a bare inline `<span>` never clips — an inline box has no
597
+ * width box for `overflow:hidden` / `text-overflow:ellipsis` to act on, so
598
+ * the value renders at full content width and the tail is silently cut by
599
+ * whatever ancestor happens to clip (the detail card edge), with no ellipsis
600
+ * (objectui#3466; same mechanism as the JSON cell in objectui#2578).
601
+ * Block-level + `max-w-full` gives the span its parent's width to truncate
602
+ * against, and as a flex item its `overflow:hidden` zeroes the automatic
603
+ * min-size so it can shrink below the text width. The `title` keeps the full
604
+ * text reachable on hover — host rows (detail sections) put their inline-edit
605
+ * hint in *their* `title`, so the value's full text must live on the value
606
+ * element itself, where it takes precedence under the cursor.
607
+ */
608
+ function TruncatedText({
609
+ text,
610
+ className,
611
+ }: {
612
+ text: string;
613
+ className?: string;
614
+ }): React.ReactElement {
615
+ return (
616
+ <span className={cn('block max-w-full truncate', className)} title={text}>
617
+ {text}
618
+ </span>
619
+ );
620
+ }
621
+
592
622
  /**
593
623
  * Text field cell renderer
594
624
  */
595
625
  export function TextCellRenderer({ value }: CellRendererProps): React.ReactElement {
596
626
  const safe = coerceToSafeValue(value);
597
627
  if (safe == null || safe === '') return <EmptyValue />;
598
- return <span className="truncate">{String(safe)}</span>;
628
+ return <TruncatedText text={String(safe)} />;
599
629
  }
600
630
 
601
631
  /**
@@ -1089,10 +1119,13 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
1089
1119
  || SEMANTIC_COLOR_MAP[String(val).toLowerCase().replace(/[\s-]/g, '_')]
1090
1120
  || hashToColor(String(val).toLowerCase().replace(/[\s-]/g, '_'));
1091
1121
  const dotClass = DOT_COLOR_MAP[colorName] || DOT_COLOR_MAP.gray;
1122
+ // max-w-full bounds the (otherwise content-sized) inline-flex box so the
1123
+ // inner truncate can engage; title keeps the full label on hover
1124
+ // (objectui#3466, same class of bug as the badge branch below).
1092
1125
  return (
1093
- <span key={key} className="inline-flex items-center gap-1.5 text-sm">
1126
+ <span key={key} className="inline-flex max-w-full items-center gap-1.5 text-sm" title={label}>
1094
1127
  <span className={cn('h-1.5 w-1.5 rounded-full shrink-0', dotClass)} aria-hidden="true" />
1095
- <span className="truncate">{label}</span>
1128
+ <span className="min-w-0 truncate">{label}</span>
1096
1129
  </span>
1097
1130
  );
1098
1131
  }
@@ -1266,7 +1299,7 @@ export function FileCellRenderer({ value, field }: CellRendererProps): React.Rea
1266
1299
  }
1267
1300
 
1268
1301
  const fileName = value.name || value.original_name || 'File';
1269
- return <span className="text-sm truncate">{fileName}</span>;
1302
+ return <TruncatedText text={String(fileName)} className="text-sm" />;
1270
1303
  }
1271
1304
 
1272
1305
  /**
@@ -1435,7 +1468,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
1435
1468
  String(parsed.externalId ?? parsed.id ?? parsed._id ?? '');
1436
1469
  }
1437
1470
  } catch { /* not JSON — fall through to normal resolution */ }
1438
- if (parsedDisplay) return <span className="truncate">{parsedDisplay}</span>;
1471
+ if (parsedDisplay) return <TruncatedText text={parsedDisplay} />;
1439
1472
  }
1440
1473
  }
1441
1474
 
@@ -1446,7 +1479,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
1446
1479
  const display =
1447
1480
  resolveLookupRecordName(obj, refSchema, displayField) || String(obj.id || obj._id || '');
1448
1481
  if (display) {
1449
- return <span className="truncate">{display}</span>;
1482
+ return <TruncatedText text={display} />;
1450
1483
  }
1451
1484
  }
1452
1485
 
@@ -1506,12 +1539,12 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
1506
1539
  const label =
1507
1540
  resolveLookupRecordName(value as Record<string, unknown>, refSchema, displayField) ||
1508
1541
  String((value as any).id || (value as any)._id || '[Object]');
1509
- return <span className="truncate">{label}</span>;
1542
+ return <TruncatedText text={label} />;
1510
1543
  }
1511
1544
 
1512
1545
  // Primitive value (e.g. raw ID): try options → resolver → opaque-ID placeholder → raw
1513
1546
  const { text, muted } = resolveLabel(value);
1514
- return <span className={cn('truncate', muted && 'text-muted-foreground')}>{text}</span>;
1547
+ return <TruncatedText text={text} className={muted ? 'text-muted-foreground' : undefined} />;
1515
1548
  }
1516
1549
 
1517
1550
  /**
@@ -1535,7 +1568,7 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme
1535
1568
 
1536
1569
  // Primitive value: just display the ID/username as text
1537
1570
  if (typeof value !== 'object') {
1538
- return <span className="truncate">{String(value)}</span>;
1571
+ return <TruncatedText text={String(value)} />;
1539
1572
  }
1540
1573
 
1541
1574
  if (Array.isArray(value)) {
@@ -1544,11 +1577,7 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme
1544
1577
  {value.slice(0, 3).map((user, idx) => {
1545
1578
  // Primitive user in array
1546
1579
  if (typeof user !== 'object' || user === null) {
1547
- return (
1548
- <span key={idx} className="truncate text-sm">
1549
- {String(user)}
1550
- </span>
1551
- );
1580
+ return <TruncatedText key={idx} text={String(user)} className="text-sm" />;
1552
1581
  }
1553
1582
  const name = user.name || user.username || 'User';
1554
1583
  const initials = name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2);
@@ -1588,7 +1617,7 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme
1588
1617
  {initials}
1589
1618
  </AvatarFallback>
1590
1619
  </Avatar>
1591
- <span className="truncate">{name}</span>
1620
+ <TruncatedText text={String(name)} />
1592
1621
  </div>
1593
1622
  );
1594
1623
  }
@@ -1670,13 +1699,10 @@ export function JsonCellRenderer({ value }: CellRendererProps): React.ReactEleme
1670
1699
  } else {
1671
1700
  text = String(value);
1672
1701
  }
1673
- // inline-block + max-w-full so `truncate` (overflow-hidden/ellipsis/nowrap)
1674
- // actually clamps to the cell width. On a bare inline <span> truncate never
1675
- // clips — there is no width box — and its `white-space:nowrap` also defeats
1676
- // the parent cell's `break-words`, so a long name-keyed map / address JSON
1677
- // spills into the neighbouring column (objectui#2578). The title keeps the
1678
- // full value on hover.
1679
- return <span className="block max-w-full font-mono text-xs text-gray-600 truncate" title={text}>{text}</span>;
1702
+ // The original site of the block-level+max-w-full+title pattern
1703
+ // (objectui#2578) — now shared with every single-line value renderer via
1704
+ // TruncatedText (objectui#3466).
1705
+ return <TruncatedText text={text} className="font-mono text-xs text-gray-600" />;
1680
1706
  }
1681
1707
 
1682
1708
  /**
@@ -2048,7 +2074,9 @@ function createFieldRenderer(FieldWidget: React.ComponentType<any>) {
2048
2074
  readonly: schema?.readonly || schema?.readOnly,
2049
2075
  help: schema?.help,
2050
2076
  description: schema?.description,
2051
- defaultValue: schema?.defaultValue || schema?.value,
2077
+ // `??`, not `||` — an authored default of `false` / `0` / `''` is a value,
2078
+ // and `||` silently threw it away (same falsy-as-empty class as cloud#972).
2079
+ defaultValue: schema?.defaultValue ?? schema?.value,
2052
2080
  ...schema,
2053
2081
  };
2054
2082
 
@@ -2275,9 +2303,12 @@ export function registerField(fieldType: string): void {
2275
2303
 
2276
2304
  // Create lazy component
2277
2305
  const LazyFieldWidget = React.lazy(loader);
2278
-
2279
- // Register with field namespace - NO WRAPPER to allow form renderer to control label/layout
2280
- ComponentRegistry.register(fieldType, LazyFieldWidget, {
2306
+
2307
+ // Register with field namespace. No LAYOUT wrapper — the form renderer owns
2308
+ // label/description/spacing. The only thing wrapped is the metadata carrier:
2309
+ // `withFieldCarrier` maps `SchemaRenderer`'s `schema` node onto `field`
2310
+ // (objectui#3233) and renders nothing of its own.
2311
+ ComponentRegistry.register(fieldType, withFieldCarrier(LazyFieldWidget), {
2281
2312
  namespace: 'field',
2282
2313
  skipFallback: FIELD_TYPES_SKIP_FALLBACK.has(fieldType),
2283
2314
  });
@@ -2351,7 +2382,7 @@ export function registerFields() {
2351
2382
  // field type. Registered WITHOUT createFieldRenderer: it is a fully-controlled
2352
2383
  // widget (value/onChange from RHF), so the wrapper's extra label + local value
2353
2384
  // buffer would only duplicate the form's FormLabel and desync the value.
2354
- ComponentRegistry.register('capability-multiselect', CapabilityMultiSelectField, { namespace: 'field', skipFallback: true });
2385
+ ComponentRegistry.register('capability-multiselect', withFieldCarrier(CapabilityMultiSelectField), { namespace: 'field', skipFallback: true });
2355
2386
  // master_detail = child-side FK lookup (single value, typically required). See
2356
2387
  // fieldWidgetMap above for rationale.
2357
2388
  ComponentRegistry.register('master_detail', createFieldRenderer(LookupField), { namespace: 'field' });
@@ -2421,6 +2452,11 @@ export * from './widgets/ObjectRefField';
2421
2452
  export * from './widgets/FilterConditionField';
2422
2453
  export * from './widgets/RecipientPickerField';
2423
2454
  export * from './widgets/RecordPickerDialog';
2455
+ // Shared picker-column derivation (ADR-0085 highlightFields → displayFields →
2456
+ // schema walk) — consumed by LookupField AND by RelatedList's Add-picker so a
2457
+ // lookup picker and a related-list Add dialog of the same object agree on
2458
+ // columns (#3365).
2459
+ export * from './widgets/deriveLookupColumns';
2424
2460
  export * from './widgets/FileField';
2425
2461
  export * from './widgets/ImageField';
2426
2462
  export { ImageCropperDialog } from './widgets/ImageCropperDialog';
@@ -2449,5 +2485,19 @@ export * from './widgets/RadioField';
2449
2485
  export * from './widgets/CheckboxesField';
2450
2486
  export * from './widgets/TagsField';
2451
2487
 
2488
+ // The SDUI-node → `field` adapter every registration here goes through
2489
+ // (objectui#3233). Exported so a widget registered OUTSIDE this repo can reach
2490
+ // the same single-carrier guarantee instead of re-growing a `field || schema`
2491
+ // read of its own.
2492
+ export { withFieldCarrier } from './withFieldCarrier';
2493
+
2494
+ // The whitelist that decides what a widget's `...props` spread may put on a
2495
+ // DOM element (objectui#3291) — the runtime executor of the "DOM pass-through"
2496
+ // block of `FieldWidgetComponentProps`. Exported for the same reason as
2497
+ // `withFieldCarrier` above: a widget authored outside this repo needs to reach
2498
+ // it, or it re-grows the bare spread this closed.
2499
+ export { toDomProps } from './widgets/toDomProps';
2500
+ export type { DomProps } from './widgets/toDomProps';
2501
+
2452
2502
  // Initialize registry
2453
2503
  registerAllFields();
@@ -1,6 +1,7 @@
1
- import React from 'react';
1
+ import React, { useId } from 'react';
2
2
  import { Input, Label, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
+ import { toDomProps } from './toDomProps';
4
5
 
5
6
  /**
6
7
  * Address data structure
@@ -17,8 +18,20 @@ export interface AddressValue {
17
18
  * Address field widget - provides a structured address input
18
19
  * Supports street, city, state, zip code, and country
19
20
  */
20
- export function AddressField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<AddressValue>) {
21
+ export function AddressField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<AddressValue>) {
21
22
  const address = value || {};
23
+ // DOM pass-through (objectui#3318): the whitelist spread goes onto the FIRST
24
+ // sub-input (street) — one carrier for the form renderer's aria-describedby;
25
+ // the composite's validation state goes onto EVERY focusable sub-input via
26
+ // `aria-invalid={!!error}` (a form-level failure means the whole address is
27
+ // missing/invalid, and whichever sub-input the user reaches must announce it).
28
+ const domProps = toDomProps(props);
29
+ // Sub-input ids (objectui#3343): `useId()` prefix + sub-field name — the
30
+ // `groupId` paradigm of RadioField / CheckboxesField. Hardcoded literals
31
+ // ("street", "city", …) collide as soon as a form renders two address
32
+ // fields, and every label's htmlFor then resolves to the FIRST match.
33
+ const groupId = useId();
34
+ const subId = (name: keyof AddressValue) => `${groupId}-${name}`;
22
35
 
23
36
  const handleFieldChange = (fieldName: keyof AddressValue, fieldValue: string) => {
24
37
  onChange({
@@ -45,66 +58,72 @@ export function AddressField({ value, onChange, field, readonly, ...props }: Fie
45
58
  return (
46
59
  <div className="space-y-3">
47
60
  <div>
48
- <Label htmlFor="street" className="text-xs">Street Address</Label>
61
+ <Label htmlFor={subId('street')} className="text-xs">Street Address</Label>
49
62
  <Input
50
- id="street"
63
+ {...domProps}
64
+ id={subId('street')}
51
65
  type="text"
52
66
  value={address.street || ''}
53
67
  onChange={(e) => handleFieldChange('street', e.target.value)}
54
68
  placeholder="123 Main St"
55
69
  disabled={readonly || props.disabled}
56
70
  className={props.className}
71
+ aria-invalid={!!error}
57
72
  />
58
73
  </div>
59
74
 
60
75
  <div className="grid grid-cols-2 gap-3">
61
76
  <div>
62
- <Label htmlFor="city" className="text-xs">City</Label>
77
+ <Label htmlFor={subId('city')} className="text-xs">City</Label>
63
78
  <Input
64
- id="city"
79
+ id={subId('city')}
65
80
  type="text"
66
81
  value={address.city || ''}
67
82
  onChange={(e) => handleFieldChange('city', e.target.value)}
68
83
  placeholder="San Francisco"
69
84
  disabled={readonly || props.disabled}
85
+ aria-invalid={!!error}
70
86
  />
71
87
  </div>
72
88
 
73
89
  <div>
74
- <Label htmlFor="state" className="text-xs">State / Province</Label>
90
+ <Label htmlFor={subId('state')} className="text-xs">State / Province</Label>
75
91
  <Input
76
- id="state"
92
+ id={subId('state')}
77
93
  type="text"
78
94
  value={address.state || ''}
79
95
  onChange={(e) => handleFieldChange('state', e.target.value)}
80
96
  placeholder="CA"
81
97
  disabled={readonly || props.disabled}
98
+ aria-invalid={!!error}
82
99
  />
83
100
  </div>
84
101
  </div>
85
102
 
86
103
  <div className="grid grid-cols-2 gap-3">
87
104
  <div>
88
- <Label htmlFor="zipCode" className="text-xs">ZIP / Postal Code</Label>
105
+ <Label htmlFor={subId('zipCode')} className="text-xs">ZIP / Postal Code</Label>
89
106
  <Input
90
- id="zipCode"
107
+ id={subId('zipCode')}
91
108
  type="text"
92
109
  value={address.zipCode || ''}
93
110
  onChange={(e) => handleFieldChange('zipCode', e.target.value)}
94
111
  placeholder="94102"
95
112
  disabled={readonly || props.disabled}
113
+ aria-invalid={!!error}
96
114
  />
97
115
  </div>
98
116
 
99
117
  <div>
100
- <Label htmlFor="country" className="text-xs">Country</Label>
118
+ <Label htmlFor={subId('country')} className="text-xs">Country</Label>
101
119
  <Input
102
- id="country"
120
+ id={subId('country')}
103
121
  type="text"
104
122
  value={address.country || ''}
105
123
  onChange={(e) => handleFieldChange('country', e.target.value)}
106
124
  placeholder="United States"
107
125
  disabled={readonly || props.disabled}
126
+ aria-invalid={!!error}
108
127
  />
109
128
  </div>
110
129
  </div>
@@ -0,0 +1,92 @@
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
+ * Unique sub-input ids for the composite `address` widget (objectui#3343).
11
+ *
12
+ * The widget used to hardcode literal ids ("street", "city", "state",
13
+ * "zipCode", "country") on its sub-inputs. Two address fields in one form —
14
+ * e.g. billing + shipping — then produced duplicate DOM ids, and every
15
+ * `Label htmlFor` resolved to the FIRST match in the document: each sub-label
16
+ * of the second field clicked/announced the first field's input.
17
+ *
18
+ * The fix follows the `groupId` paradigm of RadioField / CheckboxesField:
19
+ * a `useId()` prefix + the sub-field name. These tests pin it by rendering
20
+ * TWO instances in one form:
21
+ *
22
+ * 1. every `[id]` in the document is globally unique (red again if anyone
23
+ * reverts to hardcoded literals — both instances would emit id="street");
24
+ * 2. each sub-label of the SECOND instance resolves — via the exact
25
+ * mechanism browsers use for label activation, `for` → getElementById —
26
+ * to an input inside its OWN instance, and focusing it lands there.
27
+ */
28
+ import { describe, it, expect, vi } from 'vitest';
29
+ import React from 'react';
30
+ import { render, within } from '@testing-library/react';
31
+ import '@testing-library/jest-dom';
32
+ import { AddressField } from './AddressField';
33
+
34
+ const field = { name: 'address', type: 'address' } as any;
35
+
36
+ const SUB_LABELS = [
37
+ 'Street Address',
38
+ 'City',
39
+ 'State / Province',
40
+ 'ZIP / Postal Code',
41
+ 'Country',
42
+ ];
43
+
44
+ function renderTwoInOneForm() {
45
+ return render(
46
+ <form>
47
+ <div data-testid="address-a">
48
+ <AddressField value={{}} onChange={vi.fn()} field={field} />
49
+ </div>
50
+ <div data-testid="address-b">
51
+ <AddressField value={{}} onChange={vi.fn()} field={field} />
52
+ </div>
53
+ </form>,
54
+ );
55
+ }
56
+
57
+ describe('AddressField — unique sub-input ids (objectui#3343)', () => {
58
+ it('two address fields in one form produce globally unique DOM ids', () => {
59
+ const { baseElement } = renderTwoInOneForm();
60
+ const ids = Array.from(baseElement.querySelectorAll('[id]')).map((el) => el.id);
61
+ // 5 sub-inputs per instance — both instances must actually render ids.
62
+ expect(ids.length).toBeGreaterThanOrEqual(10);
63
+ const duplicates = ids.filter((id, i) => ids.indexOf(id) !== i);
64
+ expect(duplicates).toEqual([]);
65
+ });
66
+
67
+ it("each sub-label of the SECOND field focuses that field's own input", () => {
68
+ const { getByTestId } = renderTwoInOneForm();
69
+ const first = getByTestId('address-a');
70
+ const second = getByTestId('address-b');
71
+
72
+ for (const text of SUB_LABELS) {
73
+ const label = within(second).getByText(text);
74
+ const forId = label.getAttribute('for');
75
+ expect(forId, `label "${text}" must carry htmlFor`).toBeTruthy();
76
+
77
+ // Exactly what the browser does on label click / SR announcement:
78
+ // resolve `for` against the document.
79
+ const control = document.getElementById(forId!);
80
+ expect(control, `htmlFor of "${text}" must resolve`).not.toBeNull();
81
+ expect(control!.tagName).toBe('INPUT');
82
+ expect(
83
+ second.contains(control),
84
+ `"${text}" must resolve into its OWN field, not the first one`,
85
+ ).toBe(true);
86
+ expect(first.contains(control)).toBe(false);
87
+
88
+ (control as HTMLInputElement).focus();
89
+ expect(control).toHaveFocus();
90
+ }
91
+ });
92
+ });
@@ -1,12 +1,12 @@
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
  * AutoNumberField - Read-only auto-generated field
7
7
  * Values are automatically generated by the system and cannot be edited
8
8
  */
9
- export function AutoNumberField({ value, field, ...props }: FieldWidgetProps<string | number>) {
9
+ export function AutoNumberField({ value, field, ...props }: FieldWidgetComponentProps<string | number>) {
10
10
  if (value == null) {
11
11
  return <EmptyValue className={props.className} />;
12
12
  }