@object-ui/fields 17.2.0 → 17.5.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 (212) hide show
  1. package/CHANGELOG.md +1533 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +7 -1
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +19 -1
  6. package/dist/index.cjs +5 -5
  7. package/dist/index.css +633 -0
  8. package/dist/index.d.ts +52 -12
  9. package/dist/index.js +5167 -4759
  10. package/dist/widgets/AddressField.d.ts +4 -12
  11. package/dist/widgets/AvatarField.d.ts +1 -1
  12. package/dist/widgets/CharacterCount.d.ts +56 -0
  13. package/dist/widgets/CheckboxesField.d.ts +1 -1
  14. package/dist/widgets/CodeField.d.ts +1 -1
  15. package/dist/widgets/ColorField.d.ts +1 -1
  16. package/dist/widgets/CurrencyField.d.ts +1 -1
  17. package/dist/widgets/EmailField.d.ts +1 -1
  18. package/dist/widgets/FileField.d.ts +1 -1
  19. package/dist/widgets/FilterConditionField.d.ts +1 -1
  20. package/dist/widgets/FullscreenFieldEditor.d.ts +45 -0
  21. package/dist/widgets/GeolocationField.d.ts +1 -1
  22. package/dist/widgets/GridField.d.ts +12 -2
  23. package/dist/widgets/ImageField.d.ts +1 -1
  24. package/dist/widgets/LocationField.d.ts +1 -1
  25. package/dist/widgets/LookupField.d.ts +1 -1
  26. package/dist/widgets/MultiSelectField.d.ts +1 -1
  27. package/dist/widgets/ObjectField.d.ts +1 -1
  28. package/dist/widgets/ObjectRefField.d.ts +1 -1
  29. package/dist/widgets/OptionsEmptyState.d.ts +59 -0
  30. package/dist/widgets/PercentField.d.ts +1 -1
  31. package/dist/widgets/PhoneField.d.ts +1 -1
  32. package/dist/widgets/QRCodeField.d.ts +1 -1
  33. package/dist/widgets/RadioField.d.ts +1 -1
  34. package/dist/widgets/RatingField.d.ts +1 -1
  35. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  36. package/dist/widgets/RecordPickerDialog.d.ts +49 -7
  37. package/dist/widgets/RichTextField.d.ts +19 -1
  38. package/dist/widgets/SelectField.d.ts +25 -5
  39. package/dist/widgets/TagsField.d.ts +1 -1
  40. package/dist/widgets/TextAreaField.d.ts +44 -5
  41. package/dist/widgets/UrlField.d.ts +1 -1
  42. package/dist/widgets/address-format.d.ts +68 -0
  43. package/dist/widgets/file-size-guard.d.ts +41 -0
  44. package/dist/widgets/nativeDateValue.d.ts +37 -0
  45. package/dist/widgets/toDomProps.d.ts +111 -0
  46. package/dist/widgets/toHostGroupProps.d.ts +91 -0
  47. package/dist/widgets/types.d.ts +128 -33
  48. package/dist/widgets/useRecordQuery.d.ts +7 -1
  49. package/dist/withFieldCarrier.d.ts +56 -0
  50. package/package.json +20 -16
  51. package/src/FieldEditWidget.tsx +17 -1
  52. package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
  53. package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
  54. package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
  55. package/src/__tests__/DateCellRenderer.test.tsx +9 -0
  56. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
  57. package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
  58. package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
  59. package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
  60. package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
  61. package/src/__tests__/cell-truncation.test.tsx +96 -0
  62. package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
  63. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
  64. package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
  65. package/src/__tests__/date-locale-channel.test.tsx +305 -0
  66. package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
  67. package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
  68. package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
  69. package/src/__tests__/group-labelling-declaration.test.ts +148 -0
  70. package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
  71. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
  72. package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
  73. package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
  74. package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
  75. package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
  76. package/src/__tests__/validation-feedback.test.tsx +22 -13
  77. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
  78. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
  79. package/src/__tests__/widget-dom-leak-e2e.test.tsx +357 -0
  80. package/src/__tests__/widget-props-contract.test.tsx +22 -9
  81. package/src/complex-widgets.test.tsx +22 -6
  82. package/src/currency.ts +130 -0
  83. package/src/datetime-widgets.test.tsx +94 -2
  84. package/src/field-type-alias.multiple.test.ts +81 -0
  85. package/src/field-type-alias.ts +58 -2
  86. package/src/index.css +44 -0
  87. package/src/index.tsx +558 -268
  88. package/src/standard-widgets.test.tsx +8 -1
  89. package/src/widgets/AddressField.postalCode.test.tsx +257 -0
  90. package/src/widgets/AddressField.tsx +107 -38
  91. package/src/widgets/AddressField.uniqueIds.test.tsx +93 -0
  92. package/src/widgets/AvatarField.tsx +9 -2
  93. package/src/widgets/BooleanField.tsx +44 -10
  94. package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
  95. package/src/widgets/CharacterCount.tsx +263 -0
  96. package/src/widgets/CheckboxesField.tsx +66 -16
  97. package/src/widgets/CodeField.tsx +9 -2
  98. package/src/widgets/ColorField.tsx +11 -2
  99. package/src/widgets/CurrencyField.tsx +60 -19
  100. package/src/widgets/DateField.tsx +13 -4
  101. package/src/widgets/DateTimeField.tsx +14 -5
  102. package/src/widgets/EmailField.tsx +5 -5
  103. package/src/widgets/FileField.tsx +39 -11
  104. package/src/widgets/FilterConditionField.tsx +11 -0
  105. package/src/widgets/FormulaField.tsx +7 -2
  106. package/src/widgets/FullscreenFieldEditor.tsx +60 -0
  107. package/src/widgets/GeolocationField.tsx +49 -8
  108. package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
  109. package/src/widgets/GridField.test.tsx +226 -0
  110. package/src/widgets/GridField.tsx +115 -11
  111. package/src/widgets/ImageField.maxSize.test.tsx +154 -0
  112. package/src/widgets/ImageField.tsx +63 -6
  113. package/src/widgets/LocationField.tsx +9 -2
  114. package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
  115. package/src/widgets/LookupField.hydration.test.tsx +1 -1
  116. package/src/widgets/LookupField.tsx +49 -10
  117. package/src/widgets/MasterDetailField.tsx +1 -1
  118. package/src/widgets/MultiSelectField.tsx +86 -16
  119. package/src/widgets/NumberField.tsx +3 -3
  120. package/src/widgets/ObjectField.tsx +17 -8
  121. package/src/widgets/ObjectRefField.tsx +10 -0
  122. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
  123. package/src/widgets/OptionsEmptyState.test.tsx +169 -0
  124. package/src/widgets/OptionsEmptyState.tsx +99 -0
  125. package/src/widgets/PasswordField.tsx +3 -3
  126. package/src/widgets/PeoplePicker.tsx +7 -1
  127. package/src/widgets/PercentField.tsx +5 -4
  128. package/src/widgets/PhoneField.tsx +5 -5
  129. package/src/widgets/QRCodeField.tsx +9 -2
  130. package/src/widgets/RadioField.tsx +58 -16
  131. package/src/widgets/RatingField.tsx +32 -4
  132. package/src/widgets/RecipientPickerField.tsx +18 -0
  133. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
  134. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
  135. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
  136. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
  137. package/src/widgets/RecordPickerDialog.tsx +235 -21
  138. package/src/widgets/RichTextField.tsx +151 -24
  139. package/src/widgets/SelectField.tsx +84 -22
  140. package/src/widgets/SliderField.tsx +1 -1
  141. package/src/widgets/SummaryField.tsx +1 -1
  142. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
  143. package/src/widgets/TagsField.placeholder.test.tsx +95 -0
  144. package/src/widgets/TagsField.tsx +23 -2
  145. package/src/widgets/TextAreaField.tsx +171 -93
  146. package/src/widgets/TextField.tsx +3 -3
  147. package/src/widgets/TimeField.tsx +2 -2
  148. package/src/widgets/UrlField.tsx +5 -5
  149. package/src/widgets/UserField.tsx +4 -3
  150. package/src/widgets/VectorField.tsx +1 -1
  151. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
  152. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
  153. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
  154. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
  155. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
  156. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
  157. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
  158. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
  159. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
  160. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
  161. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
  162. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
  163. package/src/widgets/address-format.ts +120 -0
  164. package/src/widgets/file-size-guard.ts +55 -0
  165. package/src/widgets/nativeDateValue.test.ts +115 -0
  166. package/src/widgets/nativeDateValue.ts +112 -0
  167. package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
  168. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
  169. package/src/widgets/toDomProps.ts +204 -0
  170. package/src/widgets/toHostGroupProps.ts +102 -0
  171. package/src/widgets/types.ts +135 -35
  172. package/src/widgets/useFieldTranslation.ts +46 -4
  173. package/src/widgets/useRecordQuery.ts +30 -3
  174. package/src/withFieldCarrier.tsx +75 -0
  175. package/dist/FieldEditWidget.test.d.ts +0 -8
  176. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  177. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  178. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  179. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  180. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  181. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  182. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  183. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  184. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  185. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  186. package/dist/coerce-safe-value.test.d.ts +0 -1
  187. package/dist/complex-widgets.test.d.ts +0 -1
  188. package/dist/datetime-widgets.test.d.ts +0 -1
  189. package/dist/field-type-coverage.test.d.ts +0 -1
  190. package/dist/standard-widgets.test.d.ts +0 -0
  191. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  192. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  193. package/dist/widgets/GridField.test.d.ts +0 -1
  194. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  195. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  196. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  197. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  198. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  199. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  200. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  201. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  202. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  203. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  204. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  205. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  206. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  207. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  208. package/dist/widgets/file-value.test.d.ts +0 -1
  209. package/dist/widgets/personDisplay.test.d.ts +0 -8
  210. package/dist/widgets/recentLookups.test.d.ts +0 -1
  211. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  212. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
package/src/index.tsx CHANGED
@@ -9,11 +9,16 @@
9
9
  import React from 'react';
10
10
  import type { FieldMetadata, SelectOptionMetadata } from '@object-ui/types';
11
11
  import { ComponentRegistry, percentDisplayValue, getRecordDisplayName } from '@object-ui/core';
12
- import { useLocalization } from '@object-ui/i18n';
12
+ import { useLocalization, useDisplayLocale, formatDisplayNumber } from '@object-ui/i18n';
13
13
  import { Badge, Avatar, AvatarImage, AvatarFallback, Button, Checkbox, EmptyValue, cn } from '@object-ui/components';
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 { useRelatedRecordActions } from '@object-ui/react';
18
+ import { withFieldCarrier } from './withFieldCarrier';
19
+ // Pure formatting rule shared with `AddressField`'s readonly branch — no React,
20
+ // so this does not pull the widget out of its lazy chunk (objectui#4037).
21
+ import { formatAddress, type AddressValue } from './widgets/address-format';
17
22
 
18
23
  // Module-level cache so multiple renderers fetching the same lookup ID
19
24
  // only trigger one network call. Keyed by `${objectName}:${id}`.
@@ -284,46 +289,15 @@ function useFieldTranslate(): ((key: string, params?: Record<string, unknown>) =
284
289
  return t as (key: string, params?: Record<string, unknown>) => string;
285
290
  }
286
291
 
287
- import { TextField } from './widgets/TextField';
288
- import { NumberField } from './widgets/NumberField';
289
- import { BooleanField } from './widgets/BooleanField';
290
- import { SelectField } from './widgets/SelectField';
291
- import { DateField } from './widgets/DateField';
292
- import { EmailField } from './widgets/EmailField';
293
- import { PhoneField } from './widgets/PhoneField';
294
- import { UrlField } from './widgets/UrlField';
295
- import { CurrencyField } from './widgets/CurrencyField';
296
- import { TextAreaField } from './widgets/TextAreaField';
297
- import { RichTextField } from './widgets/RichTextField';
298
- import { LookupField } from './widgets/LookupField';
299
- import { CapabilityMultiSelectField } from './widgets/CapabilityMultiSelectField';
300
- import { DateTimeField } from './widgets/DateTimeField';
301
- import { TimeField } from './widgets/TimeField';
302
- import { PercentField } from './widgets/PercentField';
303
- import { PasswordField } from './widgets/PasswordField';
304
- import { FileField } from './widgets/FileField';
305
- import { ImageField } from './widgets/ImageField';
292
+ // Only the two symbols this module actually CALLS are imported eagerly. Every
293
+ // field widget used to be imported here as well, statically, purely to hand a
294
+ // component reference to the docs-demo `createFieldRenderer()` wrapper —
295
+ // removed with it (objectui#3910). The live registration path resolves widgets
296
+ // through the lazy loaders in `fieldWidgetMap` below, so nothing here needs a
297
+ // static reference; the widgets stay publicly available via the `export * from
298
+ // './widgets/…'` block at the end of this file.
306
299
  import { ImageLightbox } from './widgets/ImageLightbox';
307
300
  import { readFileValues } from './widgets/file-value';
308
- import { LocationField } from './widgets/LocationField';
309
- import { FormulaField } from './widgets/FormulaField';
310
- import { SummaryField } from './widgets/SummaryField';
311
- import { AutoNumberField } from './widgets/AutoNumberField';
312
- import { UserField } from './widgets/UserField';
313
- import { ObjectField } from './widgets/ObjectField';
314
- import { VectorField } from './widgets/VectorField';
315
- import { GridField } from './widgets/GridField';
316
- // New widgets according to @objectstack/spec
317
- import { ColorField } from './widgets/ColorField';
318
- import { SliderField } from './widgets/SliderField';
319
- import { RatingField } from './widgets/RatingField';
320
- import { CodeField } from './widgets/CodeField';
321
- import { AvatarField } from './widgets/AvatarField';
322
- import { AddressField } from './widgets/AddressField';
323
- import { GeolocationField } from './widgets/GeolocationField';
324
- import { SignatureField } from './widgets/SignatureField';
325
- import { QRCodeField } from './widgets/QRCodeField';
326
- import { MasterDetailField } from './widgets/MasterDetailField';
327
301
 
328
302
  /**
329
303
  * Cell renderer props
@@ -384,27 +358,56 @@ export function coerceToSafeValue(value: unknown): string | number | boolean | n
384
358
  * USD for unconfigured currency fields was the #1 source of "why is my
385
359
  * RMB amount showing as dollars?" bug reports.
386
360
  *
387
- * Trailing `.00` is dropped when the value is a whole number — Salesforce
388
- * convention: `$1,234.50` keeps cents; `$1,234` does not.
361
+ * Trailing minor units are dropped when the value is a whole number —
362
+ * Salesforce convention: `$1,234.50` keeps cents; `$1,234` does not. Wholeness
363
+ * picks ONE fraction-digit width, never a range: a whole amount shows 0 digits,
364
+ * and a fractional amount shows the width the CURRENCY has — so a real cents
365
+ * value of `.50` renders `.50`, not `.5`, and a yen amount renders `¥1,235`
366
+ * rather than cents the yen does not have.
389
367
  */
390
- import { resolveFieldCurrency } from './currency';
368
+ import { resolveFieldCurrency, currencyFractionDigits } from './currency';
391
369
  export { resolveFieldCurrency };
392
370
 
393
- export function formatCurrency(value: number, currency?: string): string {
371
+ export function formatCurrency(value: number, currency?: string, locale?: string): string {
394
372
  const isWhole = Number.isFinite(value) && value === Math.trunc(value);
395
- const maxFrac = isWhole ? 0 : 2;
373
+ // ONE width for both bounds, not a range (objectui#4332). The symbol branch
374
+ // used to pass `minimumFractionDigits: 0` against a wholeness-switched
375
+ // maximum, which handed Intl the range [0, 2] — and Intl then emits the
376
+ // SHORTEST representation in range, so a genuine cents value of `.50` was
377
+ // printed as `.5`: `$1,234.5` instead of the `$1,234.50` promised above.
378
+ // It was the only branch that did: the no-currency branch reaches
379
+ // `formatNumber`, which sets both bounds to the width it is given, and the
380
+ // bad-currency fallback below has always used `toFixed`. Both already
381
+ // rendered `1,234.50` for the same amount.
382
+ //
383
+ // The non-whole width is the CURRENCY's own ISO 4217 minor-unit count, not a
384
+ // literal 2 (objectui#4361). Passing 2 for every currency on earth OVERRODE
385
+ // what `Intl` already knows: JPY has no minor unit and KWD has three, so a
386
+ // yen amount was printed with cents it does not have (`¥1,234.50`) and a
387
+ // dinar amount one digit short (`KWD 1.50`).
388
+ //
389
+ // The wholeness switch itself is NOT retired — dropping both bounds and
390
+ // letting `Intl` decide would fix the digit count by turning `$1,234` back
391
+ // into `$1,234.00`, which is exactly the convention this function documents
392
+ // and objectui#4033 pinned. It is extended instead: whole amounts drop the
393
+ // fraction for EVERY currency (`KWD 1`, not `KWD 1.000`), fractional amounts
394
+ // take that currency's own count.
395
+ //
396
+ // With no currency in hand there is nothing to derive from, so that branch
397
+ // keeps the historical 2.
398
+ const fracDigits = isWhole ? 0 : currency ? currencyFractionDigits(currency) : 2;
396
399
  if (!currency) {
397
- return formatNumber(value, maxFrac);
400
+ return formatNumber(value, fracDigits, locale);
398
401
  }
399
402
  try {
400
- return new Intl.NumberFormat('en-US', {
401
- style: 'currency',
403
+ return formatDisplayNumber(value, {
404
+ locale,
402
405
  currency,
403
- minimumFractionDigits: 0,
404
- maximumFractionDigits: maxFrac,
405
- }).format(value);
406
+ minimumFractionDigits: fracDigits,
407
+ maximumFractionDigits: fracDigits,
408
+ });
406
409
  } catch {
407
- return `${currency} ${value.toFixed(maxFrac)}`;
410
+ return `${currency} ${value.toFixed(fracDigits)}`;
408
411
  }
409
412
  }
410
413
 
@@ -413,25 +416,26 @@ export function formatCurrency(value: number, currency?: string): string {
413
416
  * E.g., $150,000 → $150K, $1,200,000 → $1.2M
414
417
  * When `currency` is undefined, returns a compact number without symbol.
415
418
  */
416
- export function formatCompactCurrency(value: number, currency?: string): string {
419
+ export function formatCompactCurrency(value: number, currency?: string, locale?: string): string {
417
420
  if (!currency) {
418
421
  try {
419
- const formatted = new Intl.NumberFormat('en-US', {
422
+ const formatted = formatDisplayNumber(value, {
423
+ locale,
420
424
  notation: 'compact',
421
425
  maximumFractionDigits: 1,
422
- }).format(value);
426
+ });
423
427
  return formatted.replace(/\.0(?=[KMBT])/, '');
424
428
  } catch {
425
429
  return String(value);
426
430
  }
427
431
  }
428
432
  try {
429
- const formatted = new Intl.NumberFormat('en-US', {
430
- style: 'currency',
433
+ const formatted = formatDisplayNumber(value, {
434
+ locale,
431
435
  currency,
432
436
  notation: 'compact',
433
437
  maximumFractionDigits: 1,
434
- }).format(value);
438
+ });
435
439
  // Strip trailing ".0" before compact suffix for consistent cross-environment output
436
440
  // e.g. "$150.0K" → "$150K" while keeping "$1.5M" intact
437
441
  return formatted.replace(/\.0(?=[KMBT])/, '');
@@ -445,26 +449,95 @@ export function formatCompactCurrency(value: number, currency?: string): string
445
449
  * Used as a safe fallback when a currency-typed field has no `currency`
446
450
  * configured — we'd rather render `1,234.50` than silently assume USD.
447
451
  */
448
- export function formatNumber(value: number, decimals: number = 2): string {
452
+ export function formatNumber(value: number, decimals: number = 2, locale?: string): string {
449
453
  try {
450
- return new Intl.NumberFormat('en-US', {
454
+ // Deliberately passes NO `scale`: `decimals` here is a display width the
455
+ // caller chose, not a field's declared scale, so the ordinal no-grouping
456
+ // policy must not fire. `formatCurrency`'s no-currency fallback lands here
457
+ // with `decimals: 0` for a whole amount — that is still money and must keep
458
+ // its separators.
459
+ return formatDisplayNumber(value, {
460
+ locale,
451
461
  minimumFractionDigits: decimals,
452
462
  maximumFractionDigits: decimals,
453
- }).format(value);
463
+ });
454
464
  } catch {
455
465
  return value.toFixed(decimals);
456
466
  }
457
467
  }
458
468
 
459
469
  /**
460
- * Format percent value
470
+ * The percent rendering itself, on a value ALREADY in display magnitude (`80`
471
+ * means 80%).
472
+ *
473
+ * Split out from {@link formatPercent} because `PercentCellRenderer`'s
474
+ * whole-percent branch needs this exact rendering under a DIFFERENT scaling
475
+ * policy (see there). Two copies of the expression is precisely the drift
476
+ * `percentDisplayValue`'s doc comment exists to prevent, so there is one copy
477
+ * and the scaling decision is made by the caller.
478
+ */
479
+ function formatPercentBody(displayValue: number, precision: number, locale?: string): string {
480
+ try {
481
+ // `style: 'percentPoints'` renders a value that is ALREADY in percentage
482
+ // points, so there is no `/ 100` here. Going through `Intl` rather than
483
+ // appending a literal '%' is what buys the locale's percent CONVENTION and
484
+ // not merely its separators: German writes `1.235 %` with a no-break space
485
+ // before the sign, English `1,235%` with none, Turkish puts the sign in
486
+ // FRONT. Both bounds are set to `precision` so the width is exactly the one
487
+ // the caller asked for — the same contract `toFixed` gave.
488
+ //
489
+ // ⚠️ NOT `style: 'percent'` (objectui#4590). That style wants a FRACTION, so
490
+ // this used to divide by 100 for `Intl` to multiply straight back — and the
491
+ // round trip is not value-preserving. `Intl` formats from the SHORTEST
492
+ // decimal representation of the double it is handed, and the quotient's is
493
+ // not the authored one: `1.005` is `1.005`, but `1.005 / 100` is
494
+ // `0.010049999999999999`, which percent-scales to `1.0049999999999999` and
495
+ // rounds DOWN — so a stored 1.005 rendered `1.00%` where half-up is `1.01%`.
496
+ // The DIVISION lost the digit, not the rounding, which is why it reproduced
497
+ // in every locale and why 27,577 of 1,200,003 ordinary en-US forms moved
498
+ // (0.005-step grid to 2,000, precisions 0/1/2), every one a last-digit
499
+ // off-by-one. The same artefact reached the top of the double range:
500
+ // `MAX_SAFE_INTEGER` points rendered `…740,990%` for `…740,991%`.
501
+ //
502
+ // The affix is unchanged by the switch: `'percentPoints'` is `Intl`'s
503
+ // `style: 'unit'` / `unit: 'percent'` / `unitDisplay: 'narrow'`, measured
504
+ // byte-identical to `style: 'percent'` across all 171 locale tags in #4576
505
+ // and re-measured on THIS call shape in #4590 — 720 combinations (10 locales
506
+ // x 18 values x 4 precisions), 0 convention diffs, 130 numeral diffs.
507
+ // `formatMeasure` renders through the same option, so a percentage point
508
+ // reads identically in a list cell and in a dashboard measure.
509
+ return formatDisplayNumber(displayValue, {
510
+ locale,
511
+ style: 'percentPoints',
512
+ minimumFractionDigits: precision,
513
+ maximumFractionDigits: precision,
514
+ });
515
+ } catch {
516
+ return `${displayValue.toFixed(precision)}%`;
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Format percent value.
461
522
  * Handles both decimal (0.8 = 80%) and whole number (80 = 80%) inputs.
523
+ *
524
+ * `locale` is the third positional parameter, matching {@link formatNumber} and
525
+ * {@link formatCurrency} — the shape the sibling formatters already use.
526
+ * Callers should pass the tag from `useDisplayLocale()`.
527
+ *
528
+ * Before objectui#4553 this function took no locale and never touched `Intl`:
529
+ * its whole body was `${percentDisplayValue(value).toFixed(precision)}%`, so it
530
+ * rendered in NO locale rather than the machine's — an ASCII decimal mark and
531
+ * never a grouping separator, byte-identical on every machine. That made
532
+ * `1235%` the output everywhere, which is wrong in en-US as well as in German,
533
+ * so the grouping and the locale are fixed together: en output MOVES from
534
+ * `1235%` to `1,235%` at four digits and up, and that move is the fix.
462
535
  */
463
- export function formatPercent(value: number, precision: number = 0): string {
536
+ export function formatPercent(value: number, precision: number = 0, locale?: string): string {
464
537
  // Scale a fraction-stored percent (0.8 → 80%) via the shared core helper, so
465
538
  // the list cell and the dashboard measure formatter (`formatMeasure`) agree.
466
539
  const displayValue = percentDisplayValue(value);
467
- return `${displayValue.toFixed(precision)}%`;
540
+ return formatPercentBody(displayValue, precision, locale);
468
541
  }
469
542
 
470
543
  /**
@@ -548,8 +621,13 @@ export function formatDate(value: string | Date | number, style?: string, option
548
621
  if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
549
622
 
550
623
  if (style === 'short') {
551
- // Compact format for mobile: "Jan 15, '24"
552
- const month = date.toLocaleDateString('en-US', { month: 'short' });
624
+ // Compact format for mobile: "Jan 15, '24" / "1月 15, '24".
625
+ // Only the MONTH token is localized: the surrounding compact shape (day,
626
+ // apostrophe + 2-digit year) is a deliberate fixed layout for narrow
627
+ // cards, not a locale-derived one. The tag comes from `options.locale`
628
+ // like the default branch below — hardcoding `'en-US'` here made this the
629
+ // one branch that ignored a locale its caller had threaded (objectui#4272).
630
+ const month = date.toLocaleDateString(options?.locale, { month: 'short' });
553
631
  const day = date.getDate();
554
632
  const year = String(date.getFullYear()).slice(-2);
555
633
  return `${month} ${day}, '${year}`;
@@ -573,14 +651,22 @@ export function formatDate(value: string | Date | number, style?: string, option
573
651
  }
574
652
 
575
653
  /**
576
- * Format datetime value
654
+ * Format datetime value.
655
+ *
656
+ * `options` mirrors {@link formatDate}'s and is optional, so an existing
657
+ * caller that passes nothing keeps the exact runtime-default behavior it had.
658
+ * Before objectui#4272 the parameter did not exist at all, which meant no
659
+ * caller could localize this function however hard it tried — it always handed
660
+ * `Intl` an `undefined` tag, i.e. the MACHINE's locale, which is neither of
661
+ * the repo's two locale channels. Callers should pass the tag from
662
+ * `useDisplayLocale()`.
577
663
  */
578
- export function formatDateTime(value: string | Date | number): string {
664
+ export function formatDateTime(value: string | Date | number, options?: DateDisplayOptions): string {
579
665
  if (value === null || value === undefined || value === '') return '—';
580
666
  const date = value instanceof Date ? value : new Date(value as any);
581
667
  if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
582
-
583
- return date.toLocaleDateString(undefined, {
668
+
669
+ return date.toLocaleDateString(options?.locale, {
584
670
  year: 'numeric',
585
671
  month: 'short',
586
672
  day: 'numeric',
@@ -589,21 +675,54 @@ export function formatDateTime(value: string | Date | number): string {
589
675
  });
590
676
  }
591
677
 
678
+ /**
679
+ * Single-line cell value with a working ellipsis and a full-text fallback.
680
+ *
681
+ * `truncate` on a bare inline `<span>` never clips — an inline box has no
682
+ * width box for `overflow:hidden` / `text-overflow:ellipsis` to act on, so
683
+ * the value renders at full content width and the tail is silently cut by
684
+ * whatever ancestor happens to clip (the detail card edge), with no ellipsis
685
+ * (objectui#3466; same mechanism as the JSON cell in objectui#2578).
686
+ * Block-level + `max-w-full` gives the span its parent's width to truncate
687
+ * against, and as a flex item its `overflow:hidden` zeroes the automatic
688
+ * min-size so it can shrink below the text width. The `title` keeps the full
689
+ * text reachable on hover — host rows (detail sections) put their inline-edit
690
+ * hint in *their* `title`, so the value's full text must live on the value
691
+ * element itself, where it takes precedence under the cursor.
692
+ */
693
+ function TruncatedText({
694
+ text,
695
+ className,
696
+ }: {
697
+ text: string;
698
+ className?: string;
699
+ }): React.ReactElement {
700
+ return (
701
+ <span className={cn('block max-w-full truncate', className)} title={text}>
702
+ {text}
703
+ </span>
704
+ );
705
+ }
706
+
592
707
  /**
593
708
  * Text field cell renderer
594
709
  */
595
710
  export function TextCellRenderer({ value }: CellRendererProps): React.ReactElement {
596
711
  const safe = coerceToSafeValue(value);
597
712
  if (safe == null || safe === '') return <EmptyValue />;
598
- return <span className="truncate">{String(safe)}</span>;
713
+ return <TruncatedText text={String(safe)} />;
599
714
  }
600
715
 
601
716
  /**
602
717
  * Number field cell renderer
603
718
  */
604
719
  export function NumberCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
720
+ // Hook before the empty-value early return — a value flipping between null
721
+ // and set must not change the hook count between renders (same rule as
722
+ // CurrencyCellRenderer below).
723
+ const locale = useDisplayLocale();
605
724
  if (value == null) return <EmptyValue />;
606
-
725
+
607
726
  const safe = coerceToSafeValue(value);
608
727
  const numField = field as any;
609
728
  // Decimal places come from `scale` (the `s` in a `decimal(p, s)` column),
@@ -614,13 +733,22 @@ export function NumberCellRenderer({ value, field }: CellRendererProps): React.R
614
733
  // scale 2 → "16.00", a field with scale 3 → "3.140"); when it is absent we
615
734
  // keep the minimum at 0 so trailing zeros are trimmed and only cap the
616
735
  // maximum (20 = Intl max) to preserve the value's natural precision.
736
+ //
737
+ // `scale` is also the grouping POLICY input (objectui#4033): a declared
738
+ // `scale: 0` with no currency is a discrete integer — a year, a fiscal
739
+ // period, an ordinal — and those are rendered ungrouped, so a `Field.number`
740
+ // year finally shows `2026` instead of `2,026`. An ABSENT scale keeps
741
+ // grouping: absent means "decimals unknown", not "integer". The policy and
742
+ // its interim status live in `formatDisplayNumber`, not here.
617
743
  const scale = typeof numField.scale === 'number' ? numField.scale : undefined;
618
744
  const num = Number(safe);
619
745
  const formatted = !isNaN(num)
620
- ? new Intl.NumberFormat('en-US', {
746
+ ? formatDisplayNumber(num, {
747
+ locale,
748
+ scale,
621
749
  minimumFractionDigits: scale ?? 0,
622
750
  maximumFractionDigits: scale ?? 20,
623
- }).format(num)
751
+ })
624
752
  : String(safe);
625
753
 
626
754
  return <span className="tabular-nums">{formatted}</span>;
@@ -633,6 +761,7 @@ export function CurrencyCellRenderer({ value, field }: CellRendererProps): React
633
761
  // Hooks before the empty-value early return — a value flipping between
634
762
  // null and set must not change the hook count between renders.
635
763
  const { currency: tenantCurrency } = useLocalization();
764
+ const locale = useDisplayLocale();
636
765
  if (value == null) return <EmptyValue />;
637
766
 
638
767
  const safe = coerceToSafeValue(value);
@@ -643,7 +772,7 @@ export function CurrencyCellRenderer({ value, field }: CellRendererProps): React
643
772
  const currency = resolveFieldCurrency(field as any, tenantCurrency);
644
773
  const num = Number(safe);
645
774
  const formatted = !isNaN(num)
646
- ? formatCurrency(num, currency)
775
+ ? formatCurrency(num, currency, locale)
647
776
  : String(safe);
648
777
 
649
778
  return <span className="tabular-nums font-medium whitespace-nowrap">{formatted}</span>;
@@ -656,8 +785,12 @@ const WHOLE_PERCENT_FIELD_PATTERN = /progress|completion/;
656
785
  * Percent field cell renderer with mini progress bar
657
786
  */
658
787
  export function PercentCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
788
+ // Hook before the empty-value early return — a value flipping between null
789
+ // and set must not change the hook count between renders (same rule as
790
+ // NumberCellRenderer / CurrencyCellRenderer above).
791
+ const locale = useDisplayLocale();
659
792
  if (value == null) return <EmptyValue />;
660
-
793
+
661
794
  const safe = coerceToSafeValue(value);
662
795
  const percentField = field as any;
663
796
  const precision = percentField.precision ?? 0;
@@ -671,13 +804,35 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React.
671
804
  const barValue = isWholePercentField
672
805
  ? numValue
673
806
  : (numValue > -1 && numValue < 1) ? numValue * 100 : numValue;
674
- const formatted = isWholePercentField ? `${numValue.toFixed(precision)}%` : formatPercent(numValue, precision);
807
+ // Both branches render through the same locale-aware body (objectui#4553);
808
+ // they differ ONLY in the scaling policy, which is the whole point of the
809
+ // branch. The whole-percent branch used to be a second bare `toFixed` path,
810
+ // so before this card a `progress` field was ungrouped and unlocalized even
811
+ // where an ordinary percent column would not have been — leaving it behind
812
+ // would have made ONE grid internally inconsistent, which is worse than the
813
+ // uniform defect it had.
814
+ const formatted = isWholePercentField
815
+ ? formatPercentBody(numValue, precision, locale)
816
+ : formatPercent(numValue, precision, locale);
675
817
  const clampedBar = Math.max(0, Math.min(100, barValue));
676
818
 
819
+ // Layout contract (objectstack#5066): THE NUMBER IS THE CONTENT, THE BAR IS
820
+ // DECORATION. The bar used to be `w-16 shrink-0` while the value span was
821
+ // shrinkable, so in a narrow clipping container — a `record:highlights` chip
822
+ // is `basis-[9rem]`/`min-w-[7rem]` and clips with `truncate` — the 64px bar
823
+ // took the space and the value was silently cut mid-digit: a stored `33.33`
824
+ // rendered `33%` in the DOM but read as `3` on screen, with no ellipsis and
825
+ // nothing in the accessible name to signal the loss.
826
+ //
827
+ // So the priority is inverted: the value span is `shrink-0` (never sacrificed)
828
+ // and the bar keeps `w-16` only as its PREFERRED width, free to shrink away
829
+ // under pressure. It is deliberately NOT `flex-1` — growing is not wanted, or
830
+ // every wide grid cell would stretch its bar; `w-16` stays the upper bound and
831
+ // wide containers look exactly as before.
677
832
  return (
678
- <div className="flex items-center gap-2">
833
+ <div className="flex min-w-0 items-center gap-2">
679
834
  <div
680
- className="h-1.5 w-16 rounded-full bg-muted ring-1 ring-inset ring-border/60 overflow-hidden shrink-0"
835
+ className="h-1.5 w-16 min-w-0 shrink rounded-full bg-muted ring-1 ring-inset ring-border/60 overflow-hidden"
681
836
  role="progressbar"
682
837
  aria-valuenow={clampedBar}
683
838
  aria-valuemin={0}
@@ -688,7 +843,7 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React.
688
843
  style={{ width: `${clampedBar}%` }}
689
844
  />
690
845
  </div>
691
- <span className="tabular-nums whitespace-nowrap">{formatted}</span>
846
+ <span className="shrink-0 tabular-nums whitespace-nowrap">{formatted}</span>
692
847
  </div>
693
848
  );
694
849
  }
@@ -748,7 +903,14 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React.
748
903
  * Date field cell renderer
749
904
  */
750
905
  export function DateCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
751
- const { locale } = useLocalization();
906
+ // `useDisplayLocale()`, NOT `useLocalization().locale` (objectui#4468). The
907
+ // raw tenant locale is `undefined` on any workspace that never configured
908
+ // one, and `Intl` reads the MACHINE's locale from `undefined` — so a `zh`
909
+ // console rendered `逾期 6 天` (which resolves through `t`, the ACTIVE UI
910
+ // language) beside `In 3 days` and `6 days ago` (which resolve through
911
+ // `Intl`) on the SAME row. One channel for both halves, or the row
912
+ // disagrees with itself.
913
+ const locale = useDisplayLocale();
752
914
  const t = useFieldTranslate();
753
915
  if (!value) return <EmptyValue />;
754
916
  const safe = coerceToSafeValue(value);
@@ -785,17 +947,26 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
785
947
  * DateTime field cell renderer (Airtable-style with date and time visually separated)
786
948
  */
787
949
  export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactElement {
950
+ // Hook before every early return — a value flipping between null and set
951
+ // must not change the hook count between renders (same rule as the number /
952
+ // currency renderers above). This is the site objectui#4468 caught rendering
953
+ // `8/11/2026 12:00 am` inside an otherwise Chinese grid: both calls passed
954
+ // `undefined`, i.e. the machine's locale, on every session.
955
+ const locale = useDisplayLocale();
788
956
  if (!value) return <EmptyValue />;
789
957
  const safe = coerceToSafeValue(value);
790
958
  const date = safe != null ? new Date(safe as string | number) : null;
791
959
  if (date === null || isNaN(date.getTime())) return <EmptyValue />;
792
960
 
793
- const datePart = date.toLocaleDateString(undefined, {
961
+ const datePart = date.toLocaleDateString(locale, {
794
962
  month: 'numeric',
795
963
  day: 'numeric',
796
964
  year: 'numeric',
797
965
  });
798
- const timePart = date.toLocaleTimeString(undefined, {
966
+ // `hour12` stays declared: this is the compact Airtable-style cell, and the
967
+ // 12-hour face is its design, not a locale artefact. Locales that write no
968
+ // am/pm marker simply ignore it.
969
+ const timePart = date.toLocaleTimeString(locale, {
799
970
  hour: 'numeric',
800
971
  minute: '2-digit',
801
972
  hour12: true,
@@ -1089,10 +1260,13 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
1089
1260
  || SEMANTIC_COLOR_MAP[String(val).toLowerCase().replace(/[\s-]/g, '_')]
1090
1261
  || hashToColor(String(val).toLowerCase().replace(/[\s-]/g, '_'));
1091
1262
  const dotClass = DOT_COLOR_MAP[colorName] || DOT_COLOR_MAP.gray;
1263
+ // max-w-full bounds the (otherwise content-sized) inline-flex box so the
1264
+ // inner truncate can engage; title keeps the full label on hover
1265
+ // (objectui#3466, same class of bug as the badge branch below).
1092
1266
  return (
1093
- <span key={key} className="inline-flex items-center gap-1.5 text-sm">
1267
+ <span key={key} className="inline-flex max-w-full items-center gap-1.5 text-sm" title={label}>
1094
1268
  <span className={cn('h-1.5 w-1.5 rounded-full shrink-0', dotClass)} aria-hidden="true" />
1095
- <span className="truncate">{label}</span>
1269
+ <span className="min-w-0 truncate">{label}</span>
1096
1270
  </span>
1097
1271
  );
1098
1272
  }
@@ -1266,7 +1440,7 @@ export function FileCellRenderer({ value, field }: CellRendererProps): React.Rea
1266
1440
  }
1267
1441
 
1268
1442
  const fileName = value.name || value.original_name || 'File';
1269
- return <span className="text-sm truncate">{fileName}</span>;
1443
+ return <TruncatedText text={String(fileName)} className="text-sm" />;
1270
1444
  }
1271
1445
 
1272
1446
  /**
@@ -1355,6 +1529,89 @@ export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElem
1355
1529
  );
1356
1530
  }
1357
1531
 
1532
+ /**
1533
+ * The referenced record's id inside one lookup value — an `$expand`-ed record
1534
+ * object (`{ id, name, … }`) or the raw foreign key itself. `undefined` when
1535
+ * the value carries no id we could address (e.g. an unresolved external-id
1536
+ * reference), which the link below reads as "not navigable".
1537
+ */
1538
+ function referencedRecordId(item: unknown): string | number | undefined {
1539
+ if (item == null || item === '') return undefined;
1540
+ if (typeof item === 'object') {
1541
+ const id = (item as Record<string, unknown>).id ?? (item as Record<string, unknown>)._id;
1542
+ if (typeof id === 'number') return id;
1543
+ return typeof id === 'string' && id !== '' ? id : undefined;
1544
+ }
1545
+ if (typeof item === 'string' || typeof item === 'number') return item;
1546
+ return undefined;
1547
+ }
1548
+
1549
+ /**
1550
+ * Wrap a lookup's display value in a link to the record it references
1551
+ * (objectui#4336).
1552
+ *
1553
+ * On the record detail page a valued lookup used to render as plain text plus
1554
+ * a copy button — the referenced document's name was right there and there was
1555
+ * no way to reach it, so users copied the number and searched for it from the
1556
+ * list. Related-list cells pointing at a third object were dead in the same
1557
+ * way. Both surfaces resolve through `LookupCellRenderer`, so the affordance
1558
+ * belongs here, once.
1559
+ *
1560
+ * **The URL is not built here.** This package has no router and no business
1561
+ * knowing what a record route looks like; the host publishes its own builder
1562
+ * through `RelatedRecordActionsContext` (`recordHref` / `openRecord`) — the
1563
+ * same one the related list's row navigation uses. No host, or a host that
1564
+ * cannot route to that object, renders the value EXACTLY as before: no anchor,
1565
+ * no styling change, nothing to un-learn (Studio designer, embedded renderers,
1566
+ * standalone grids).
1567
+ *
1568
+ * A real `href` rather than a click handler, so middle-click / ⌘-click / "copy
1569
+ * link address" behave the way a link is supposed to; a plain left click is
1570
+ * handed to the host so navigation stays in-app.
1571
+ */
1572
+ function ReferencedRecordLink({
1573
+ objectName,
1574
+ recordId,
1575
+ className,
1576
+ children,
1577
+ }: {
1578
+ objectName?: string;
1579
+ recordId?: string | number;
1580
+ className?: string;
1581
+ children: React.ReactNode;
1582
+ }): React.ReactElement {
1583
+ const host = useRelatedRecordActions();
1584
+ const navigable = !!objectName && recordId != null && recordId !== '';
1585
+ const href =
1586
+ navigable && host?.recordHref
1587
+ ? host.recordHref(objectName as string, recordId as string | number)
1588
+ : null;
1589
+
1590
+ if (!href) return <>{children}</>;
1591
+
1592
+ return (
1593
+ <a
1594
+ href={href}
1595
+ className={cn('text-primary underline-offset-4 hover:underline', className)}
1596
+ onClick={(e) => {
1597
+ // The rows this cell sits in carry their own click handlers — the
1598
+ // detail row copies the field value, a related-list row opens ITS own
1599
+ // record. Following the reference must not also fire those.
1600
+ e.stopPropagation();
1601
+ // Modifier and non-primary clicks belong to the browser (new tab, new
1602
+ // window) — that is the point of rendering a real href.
1603
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
1604
+ // No SPA handler: let the anchor navigate on its own.
1605
+ if (!host?.openRecord) return;
1606
+ e.preventDefault();
1607
+ host.openRecord(objectName as string, recordId as string | number);
1608
+ }}
1609
+ >
1610
+ {children}
1611
+ </a>
1612
+ );
1613
+ }
1614
+
1358
1615
  /**
1359
1616
  * Lookup/Master-Detail field cell renderer.
1360
1617
  *
@@ -1427,15 +1684,24 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
1427
1684
  // Compute inside try, render outside — constructing JSX in a try/catch
1428
1685
  // doesn't catch its render errors anyway (react-hooks/error-boundaries).
1429
1686
  let parsedDisplay = '';
1687
+ let parsedId: string | number | undefined;
1430
1688
  try {
1431
1689
  const parsed = JSON.parse(s) as Record<string, unknown>;
1432
1690
  if (parsed && typeof parsed === 'object') {
1433
1691
  parsedDisplay =
1434
1692
  resolveLookupRecordName(parsed, refSchema, displayField) ||
1435
1693
  String(parsed.externalId ?? parsed.id ?? parsed._id ?? '');
1694
+ // An external-id reference has no record id yet — stays unlinked.
1695
+ parsedId = referencedRecordId(parsed);
1436
1696
  }
1437
1697
  } catch { /* not JSON — fall through to normal resolution */ }
1438
- if (parsedDisplay) return <span className="truncate">{parsedDisplay}</span>;
1698
+ if (parsedDisplay) {
1699
+ return (
1700
+ <ReferencedRecordLink objectName={referenceTo} recordId={parsedId}>
1701
+ <TruncatedText text={parsedDisplay} />
1702
+ </ReferencedRecordLink>
1703
+ );
1704
+ }
1439
1705
  }
1440
1706
  }
1441
1707
 
@@ -1446,7 +1712,11 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
1446
1712
  const display =
1447
1713
  resolveLookupRecordName(obj, refSchema, displayField) || String(obj.id || obj._id || '');
1448
1714
  if (display) {
1449
- return <span className="truncate">{display}</span>;
1715
+ return (
1716
+ <ReferencedRecordLink objectName={referenceTo} recordId={referencedRecordId(obj)}>
1717
+ <TruncatedText text={display} />
1718
+ </ReferencedRecordLink>
1719
+ );
1450
1720
  }
1451
1721
  }
1452
1722
 
@@ -1484,18 +1754,26 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
1484
1754
  label = r.text;
1485
1755
  muted = r.muted;
1486
1756
  }
1757
+ // Each chip is one referenced record, so each links on its own —
1758
+ // there is no single destination a multi-value cell could point at.
1487
1759
  return (
1488
- <span
1760
+ <ReferencedRecordLink
1489
1761
  key={idx}
1490
- className={cn(
1491
- 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
1492
- muted
1493
- ? 'bg-muted/40 text-muted-foreground'
1494
- : 'bg-gray-50 text-gray-700 dark:bg-gray-800/50 dark:text-gray-200',
1495
- )}
1762
+ objectName={referenceTo}
1763
+ recordId={referencedRecordId(item)}
1764
+ className="text-inherit"
1496
1765
  >
1497
- {label}
1498
- </span>
1766
+ <span
1767
+ className={cn(
1768
+ 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
1769
+ muted
1770
+ ? 'bg-muted/40 text-muted-foreground'
1771
+ : 'bg-gray-50 text-gray-700 dark:bg-gray-800/50 dark:text-gray-200',
1772
+ )}
1773
+ >
1774
+ {label}
1775
+ </span>
1776
+ </ReferencedRecordLink>
1499
1777
  );
1500
1778
  })}
1501
1779
  </div>
@@ -1506,12 +1784,23 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
1506
1784
  const label =
1507
1785
  resolveLookupRecordName(value as Record<string, unknown>, refSchema, displayField) ||
1508
1786
  String((value as any).id || (value as any)._id || '[Object]');
1509
- return <span className="truncate">{label}</span>;
1787
+ return (
1788
+ <ReferencedRecordLink objectName={referenceTo} recordId={referencedRecordId(value)}>
1789
+ <TruncatedText text={label} />
1790
+ </ReferencedRecordLink>
1791
+ );
1510
1792
  }
1511
1793
 
1512
1794
  // Primitive value (e.g. raw ID): try options → resolver → opaque-ID placeholder → raw
1795
+ // The value IS the foreign key, so it addresses the record even when the
1796
+ // display name could not be resolved (the muted placeholder) — a reference
1797
+ // that is present but unnamed is still worth being able to open.
1513
1798
  const { text, muted } = resolveLabel(value);
1514
- return <span className={cn('truncate', muted && 'text-muted-foreground')}>{text}</span>;
1799
+ return (
1800
+ <ReferencedRecordLink objectName={referenceTo} recordId={referencedRecordId(value)}>
1801
+ <TruncatedText text={text} className={muted ? 'text-muted-foreground' : undefined} />
1802
+ </ReferencedRecordLink>
1803
+ );
1515
1804
  }
1516
1805
 
1517
1806
  /**
@@ -1535,7 +1824,7 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme
1535
1824
 
1536
1825
  // Primitive value: just display the ID/username as text
1537
1826
  if (typeof value !== 'object') {
1538
- return <span className="truncate">{String(value)}</span>;
1827
+ return <TruncatedText text={String(value)} />;
1539
1828
  }
1540
1829
 
1541
1830
  if (Array.isArray(value)) {
@@ -1544,11 +1833,7 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme
1544
1833
  {value.slice(0, 3).map((user, idx) => {
1545
1834
  // Primitive user in array
1546
1835
  if (typeof user !== 'object' || user === null) {
1547
- return (
1548
- <span key={idx} className="truncate text-sm">
1549
- {String(user)}
1550
- </span>
1551
- );
1836
+ return <TruncatedText key={idx} text={String(user)} className="text-sm" />;
1552
1837
  }
1553
1838
  const name = user.name || user.username || 'User';
1554
1839
  const initials = name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2);
@@ -1588,7 +1873,7 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme
1588
1873
  {initials}
1589
1874
  </AvatarFallback>
1590
1875
  </Avatar>
1591
- <span className="truncate">{name}</span>
1876
+ <TruncatedText text={String(name)} />
1592
1877
  </div>
1593
1878
  );
1594
1879
  }
@@ -1670,13 +1955,10 @@ export function JsonCellRenderer({ value }: CellRendererProps): React.ReactEleme
1670
1955
  } else {
1671
1956
  text = String(value);
1672
1957
  }
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>;
1958
+ // The original site of the block-level+max-w-full+title pattern
1959
+ // (objectui#2578) — now shared with every single-line value renderer via
1960
+ // TruncatedText (objectui#3466).
1961
+ return <TruncatedText text={text} className="font-mono text-xs text-gray-600" />;
1680
1962
  }
1681
1963
 
1682
1964
  /**
@@ -1770,6 +2052,39 @@ export function LocationCellRenderer({ value }: CellRendererProps): React.ReactE
1770
2052
  return <JsonCellRenderer value={value} field={{} as any} />;
1771
2053
  }
1772
2054
 
2055
+ /**
2056
+ * Renders an `address` value as a formatted single-line postal address instead
2057
+ * of stringified JSON (objectui#4037).
2058
+ *
2059
+ * The detail page's display registry mapped `address` to {@link JsonCellRenderer},
2060
+ * so a populated address read as `{"street":"中策路 1 号","city":"杭州",…}` on the
2061
+ * detail page and in its inline-edit read state — while the very same value
2062
+ * rendered correctly in the create/edit dialog, whose input registry has always
2063
+ * carried `address`. Read side only: nothing about the input widget changes.
2064
+ *
2065
+ * Layout is not invented here. It is {@link formatAddress} — the rule
2066
+ * `AddressField`'s own readonly branch already applied, over the sub-field set
2067
+ * its inputs expose — so a readonly form and a detail page cannot spell one
2068
+ * stored address two ways.
2069
+ *
2070
+ * Anything `formatAddress` cannot recognize falls back to compact JSON rather
2071
+ * than rendering blank: an unknown shape stays visible (today's behaviour) and
2072
+ * is never silently swallowed by the fix.
2073
+ */
2074
+ export function AddressCellRenderer({ value }: CellRendererProps): React.ReactElement {
2075
+ if (value == null || value === '') return <EmptyValue />;
2076
+ // A plain string address (some apps store one) is already display-ready.
2077
+ if (typeof value === 'string') return <TruncatedText text={value} className="text-sm" />;
2078
+ if (typeof value === 'object' && !Array.isArray(value)) {
2079
+ const formatted = formatAddress(value as AddressValue);
2080
+ if (formatted) return <TruncatedText text={formatted} className="text-sm" />;
2081
+ // An object carrying no recognized part: `{}` reads as empty, while
2082
+ // `{ foo: 1 }` keeps its JSON so real data is never hidden.
2083
+ if (Object.keys(value as Record<string, unknown>).length === 0) return <EmptyValue />;
2084
+ }
2085
+ return <JsonCellRenderer value={value} field={{} as any} />;
2086
+ }
2087
+
1773
2088
  /**
1774
2089
  * Get the appropriate cell renderer for a field type
1775
2090
  */
@@ -1826,7 +2141,7 @@ export function getCellRenderer(fieldType: string): React.FC<CellRendererProps>
1826
2141
  secret: () => <span>••••••</span>,
1827
2142
  location: LocationCellRenderer,
1828
2143
  geolocation: LocationCellRenderer,
1829
- address: JsonCellRenderer,
2144
+ address: AddressCellRenderer,
1830
2145
  color: ColorSwatchCellRenderer,
1831
2146
  json: JsonCellRenderer,
1832
2147
  object: JsonCellRenderer,
@@ -2034,64 +2349,16 @@ export function evaluateCondition(condition: any, formData: any): boolean {
2034
2349
  return true;
2035
2350
  }
2036
2351
 
2037
- // Create wrapper renderers for field widgets to work with ComponentDemo
2038
- function createFieldRenderer(FieldWidget: React.ComponentType<any>) {
2039
- const FieldRenderer: React.FC<any> = ({ schema, className, value: initialValue, ...props }) => {
2040
- const [value, setValue] = React.useState(initialValue ?? schema?.value ?? '');
2041
-
2042
- const field = {
2043
- name: schema?.name || 'field',
2044
- label: schema?.label,
2045
- type: schema?.type,
2046
- placeholder: schema?.placeholder,
2047
- required: schema?.required,
2048
- readonly: schema?.readonly || schema?.readOnly,
2049
- help: schema?.help,
2050
- description: schema?.description,
2051
- // `??`, not `||` — an authored default of `false` / `0` / `''` is a value,
2052
- // and `||` silently threw it away (same falsy-as-empty class as cloud#972).
2053
- defaultValue: schema?.defaultValue ?? schema?.value,
2054
- ...schema,
2055
- };
2056
-
2057
- const handleChange = React.useCallback((newValue: any) => {
2058
- setValue(newValue);
2059
- if (props.onChange) {
2060
- props.onChange(newValue);
2061
- }
2062
- }, [props]);
2063
-
2064
- const readonly = schema?.readonly || schema?.readOnly || false;
2065
-
2066
- return (
2067
- <div
2068
- className="grid w-full items-center gap-1.5"
2069
- data-obj-id={schema?.id}
2070
- data-obj-type={schema?.type}
2071
- >
2072
- {schema?.label && (
2073
- <label htmlFor={schema.id} className={schema.required ? "after:content-['*'] after:ml-0.5 after:text-red-500" : ""}>
2074
- {schema.label}
2075
- </label>
2076
- )}
2077
- <FieldWidget
2078
- value={value}
2079
- onChange={handleChange}
2080
- field={field}
2081
- readonly={readonly}
2082
- className={className}
2083
- />
2084
- {schema?.description && (
2085
- <p className="text-sm text-gray-500">{schema.description}</p>
2086
- )}
2087
- </div>
2088
- );
2089
- };
2090
-
2091
- FieldRenderer.displayName = `FieldRenderer(${FieldWidget.displayName || FieldWidget.name || 'Component'})`;
2092
-
2093
- return FieldRenderer;
2094
- }
2352
+ // TOMBSTONE (objectui#3910, ruling B of objectui#3798) — `createFieldRenderer()`
2353
+ // lived here. It wrapped a field widget in synthesized label/description chrome
2354
+ // plus a local `useState` + `onChange`, which let a BARE field node render
2355
+ // standalone under `SchemaRenderer`. Only `registerFields()` (below, also
2356
+ // removed) ever called it, and only the docs site called that — so the chrome
2357
+ // existed nowhere a real application could reach, and the docs demos showed a
2358
+ // rendering no app produces. The catalog's field examples are form-hosted now
2359
+ // (`{ type: 'form', fields: [...] }`), where the real form renderer owns label
2360
+ // and value state. Do not re-add a second, demo-only registration path: one
2361
+ // registration seam is the whole point (objectui#3308).
2095
2362
 
2096
2363
  /**
2097
2364
  * Field widget map for lazy loading
@@ -2268,6 +2535,50 @@ const FIELD_TYPES_SKIP_FALLBACK = new Set([
2268
2535
  'recipient-picker',
2269
2536
  ]);
2270
2537
 
2538
+ /**
2539
+ * Widgets whose labelled surface is NOT a labelable HTML element, so a host's
2540
+ * `<label for>` cannot reach it and the association has to go by IDREF instead
2541
+ * (`ComponentMeta.labelling`, objectui#3961). Two shapes, one declaration:
2542
+ *
2543
+ * - real composites — `address` / `geolocation` render several inputs under one
2544
+ * container, and `checkboxes` / `radio` / `rating` / `multiselect` a set of
2545
+ * choice controls; the host's label names the GROUP, each sub-control keeps
2546
+ * its own name (a sub-label, or the chip's own text content).
2547
+ * - `file` is not composite at all: it has exactly ONE control, the dropzone,
2548
+ * which is a `div[role="button"]` (it is the keyboard path to the hidden file
2549
+ * input). It is here because a `div` cannot be `for`-labelled, not because it
2550
+ * is a group, and it renders NO `role="group"` — the dropzone itself takes the
2551
+ * `aria-labelledby`.
2552
+ *
2553
+ * Measured, not assumed: every entry was verified in a real form to be a widget
2554
+ * whose host label either resolved to nothing (`address` / `geolocation`, whose
2555
+ * sub-input ids overwrote the host id — objectui#3343) or resolved to an element
2556
+ * that cannot carry it (`checkboxes` / `radio` / `rating` / `file` /
2557
+ * `multiselect`). In both shapes the visible group label was, before this
2558
+ * declaration, the accessible name of NOTHING.
2559
+ *
2560
+ * `multiselect` arrived one issue later (objectui#3975) for a coverage reason,
2561
+ * not a mechanism one: #3961's probe covered the six above, and re-running it
2562
+ * over the full widget map afterwards found `multiselect` on the byte-identical
2563
+ * failure shape as `checkboxes` — the host id kept, on the chip row's wrapper
2564
+ * `div`, where a `for` is inert. Same declaration, same container answer.
2565
+ *
2566
+ * A widget NOT listed here takes the single-control path unchanged. That is the
2567
+ * safe default: the host keeps emitting `for`, and a composite that forgot to
2568
+ * declare itself is caught by the label-association tests (objectui#3952) rather
2569
+ * than silently emitting an `aria-labelledby` onto a container with no role.
2570
+ */
2571
+ const FIELD_TYPES_GROUP_LABELLED = new Set([
2572
+ 'address',
2573
+ 'geolocation',
2574
+ 'checkboxes',
2575
+ 'radio',
2576
+ 'rating',
2577
+ 'file',
2578
+ // objectui#3975 — the residual after #3961's six, same shape as `checkboxes`.
2579
+ 'multiselect',
2580
+ ]);
2581
+
2271
2582
  export function registerField(fieldType: string): void {
2272
2583
  const loader = fieldWidgetMap[fieldType];
2273
2584
  if (!loader) {
@@ -2277,11 +2588,18 @@ export function registerField(fieldType: string): void {
2277
2588
 
2278
2589
  // Create lazy component
2279
2590
  const LazyFieldWidget = React.lazy(loader);
2280
-
2281
- // Register with field namespace - NO WRAPPER to allow form renderer to control label/layout
2282
- ComponentRegistry.register(fieldType, LazyFieldWidget, {
2591
+
2592
+ // Register with field namespace. No LAYOUT wrapper — the form renderer owns
2593
+ // label/description/spacing. The only thing wrapped is the metadata carrier:
2594
+ // `withFieldCarrier` maps `SchemaRenderer`'s `schema` node onto `field`
2595
+ // (objectui#3233) and renders nothing of its own.
2596
+ ComponentRegistry.register(fieldType, withFieldCarrier(LazyFieldWidget), {
2283
2597
  namespace: 'field',
2284
2598
  skipFallback: FIELD_TYPES_SKIP_FALLBACK.has(fieldType),
2599
+ // Only the group-labelled widgets carry the key; everything else leaves it
2600
+ // absent, which the form renderer reads as `'control'` (objectui#3961). One
2601
+ // spelling of the default, in one place.
2602
+ ...(FIELD_TYPES_GROUP_LABELLED.has(fieldType) ? { labelling: 'group' as const } : null),
2285
2603
  });
2286
2604
  }
2287
2605
 
@@ -2301,100 +2619,37 @@ export function registerAllFields(): void {
2301
2619
  });
2302
2620
  }
2303
2621
 
2304
- /**
2305
- * Legacy function - kept for backward compatibility
2306
- * @deprecated Use registerAllFields() instead
2307
- */
2308
- export function registerFields() {
2309
- // Basic fields - wrapped for documentation compatibility
2310
- // `text` collides with the display text widget in @object-ui/components.
2311
- // Display semantics ({ type: 'text', content: '...' }) are the dominant
2312
- // usage across the docs/blocks catalog, so we keep this renderer accessible
2313
- // only via the namespaced `field:text` key and let the display widget win
2314
- // the bare `text` lookup.
2315
- ComponentRegistry.register('text', createFieldRenderer(TextField), { namespace: 'field', skipFallback: true });
2316
- // `textarea`/`select` collide with the `ui:*` form-input primitives that own
2317
- // the bare keys; namespaced-only (forms use `field:<type>`). See the shared
2318
- // FIELD_TYPES_SKIP_FALLBACK note above registerField().
2319
- ComponentRegistry.register('textarea', createFieldRenderer(TextAreaField), { namespace: 'field', skipFallback: true });
2320
- ComponentRegistry.register('number', createFieldRenderer(NumberField), { namespace: 'field' });
2321
- ComponentRegistry.register('boolean', createFieldRenderer(BooleanField), { namespace: 'field' });
2322
- ComponentRegistry.register('select', createFieldRenderer(SelectField), { namespace: 'field', skipFallback: true });
2323
- ComponentRegistry.register('date', createFieldRenderer(DateField), { namespace: 'field' });
2324
- ComponentRegistry.register('datetime', createFieldRenderer(DateTimeField), { namespace: 'field' });
2325
- // Namespaced-only: no other renderer legitimately owns the bare 'time' key,
2326
- // but the fallback still triggered noisy "bare-name overwritten" warnings on
2327
- // every re-registration (e.g. hot reload / re-init in a shared registry).
2328
- ComponentRegistry.register('time', createFieldRenderer(TimeField), { namespace: 'field', skipFallback: true });
2329
-
2330
- // Contact fields - wrapped for documentation compatibility
2331
- // `email` collides with the `ui:email` input variant; namespaced-only so the
2332
- // display/input primitive keeps the bare key (forms use `field:email`).
2333
- ComponentRegistry.register('email', createFieldRenderer(EmailField), { namespace: 'field', skipFallback: true });
2334
- ComponentRegistry.register('phone', createFieldRenderer(PhoneField), { namespace: 'field' });
2335
- ComponentRegistry.register('url', createFieldRenderer(UrlField), { namespace: 'field' });
2336
-
2337
- // Specialized fields - wrapped for documentation compatibility
2338
- ComponentRegistry.register('currency', createFieldRenderer(CurrencyField), { namespace: 'field' });
2339
- ComponentRegistry.register('percent', createFieldRenderer(PercentField), { namespace: 'field' });
2340
- // `password` collides with the `ui:password` input variant; namespaced-only.
2341
- ComponentRegistry.register('password', createFieldRenderer(PasswordField), { namespace: 'field', skipFallback: true });
2342
- // `markdown` collides with the markdown DISPLAY renderer (plugin-markdown:markdown),
2343
- // which owns bare `{ type: 'markdown', content }`; the field renderer here is the
2344
- // RichText editor, reached by forms via `field:markdown`. Namespaced-only.
2345
- ComponentRegistry.register('markdown', createFieldRenderer(RichTextField), { namespace: 'field', skipFallback: true });
2346
- // `html` collides with the HTML-rendering display widget. Keep the
2347
- // markdown field, but expose the HTML field only via `field:html` so the
2348
- // display widget remains the default for { type: 'html', content }.
2349
- ComponentRegistry.register('html', createFieldRenderer(RichTextField), { namespace: 'field', skipFallback: true });
2350
- ComponentRegistry.register('lookup', createFieldRenderer(LookupField), { namespace: 'field' });
2351
- // Capability multi-select (ADR-0056 P2) — reached only via the field
2352
- // `widget: 'capability-multiselect'` hint (see MetadataProvider), never a bare
2353
- // field type. Registered WITHOUT createFieldRenderer: it is a fully-controlled
2354
- // widget (value/onChange from RHF), so the wrapper's extra label + local value
2355
- // buffer would only duplicate the form's FormLabel and desync the value.
2356
- ComponentRegistry.register('capability-multiselect', CapabilityMultiSelectField, { namespace: 'field', skipFallback: true });
2357
- // master_detail = child-side FK lookup (single value, typically required). See
2358
- // fieldWidgetMap above for rationale.
2359
- ComponentRegistry.register('master_detail', createFieldRenderer(LookupField), { namespace: 'field' });
2360
-
2361
- // File fields
2362
- ComponentRegistry.register('file', createFieldRenderer(FileField), { namespace: 'field' });
2363
- // `image` collides with the display image widget; namespaced-only.
2364
- ComponentRegistry.register('image', createFieldRenderer(ImageField), { namespace: 'field', skipFallback: true });
2365
-
2366
- // Location field
2367
- ComponentRegistry.register('location', createFieldRenderer(LocationField), { namespace: 'field' });
2368
-
2369
- // Computed/Read-only fields
2370
- ComponentRegistry.register('formula', createFieldRenderer(FormulaField), { namespace: 'field' });
2371
- ComponentRegistry.register('summary', createFieldRenderer(SummaryField), { namespace: 'field' });
2372
- ComponentRegistry.register('auto_number', createFieldRenderer(AutoNumberField), { namespace: 'field' });
2373
-
2374
- // User fields
2375
- ComponentRegistry.register('user', createFieldRenderer(UserField), { namespace: 'field' });
2376
- ComponentRegistry.register('owner', createFieldRenderer(UserField), { namespace: 'field' });
2377
-
2378
- // Complex data types
2379
- ComponentRegistry.register('object', createFieldRenderer(ObjectField), { namespace: 'field' });
2380
- ComponentRegistry.register('vector', createFieldRenderer(VectorField), { namespace: 'field' });
2381
- // `grid` collides with the layout grid component; namespaced-only.
2382
- ComponentRegistry.register('grid', createFieldRenderer(GridField), { namespace: 'field', skipFallback: true });
2383
-
2384
- // NEW: Additional field types from @objectstack/spec
2385
- ComponentRegistry.register('color', createFieldRenderer(ColorField), { namespace: 'field' });
2386
- // `slider` collides with the `ui:slider` display control; namespaced-only.
2387
- ComponentRegistry.register('slider', createFieldRenderer(SliderField), { namespace: 'field', skipFallback: true });
2388
- ComponentRegistry.register('rating', createFieldRenderer(RatingField), { namespace: 'field' });
2389
- ComponentRegistry.register('code', createFieldRenderer(CodeField), { namespace: 'field' });
2390
- // `avatar` collides with the display avatar widget; namespaced-only.
2391
- ComponentRegistry.register('avatar', createFieldRenderer(AvatarField), { namespace: 'field', skipFallback: true });
2392
- // Namespaced-only — see the 'time' registration above for rationale.
2393
- ComponentRegistry.register('address', createFieldRenderer(AddressField), { namespace: 'field', skipFallback: true });
2394
- ComponentRegistry.register('geolocation', createFieldRenderer(GeolocationField), { namespace: 'field' });
2395
- ComponentRegistry.register('signature', createFieldRenderer(SignatureField), { namespace: 'field' });
2396
- ComponentRegistry.register('qrcode', createFieldRenderer(QRCodeField), { namespace: 'field' });
2397
- }
2622
+ // TOMBSTONE (objectui#3910, ruling B of objectui#3798) — `registerFields()` lived
2623
+ // here. It registered every widget wrapped in `createFieldRenderer` (above) under
2624
+ // the same `field:<type>` keys `registerAllFields()` owns, so whoever called it
2625
+ // LAST won the registry. Its only caller was the docs site, which needed the
2626
+ // synthesized label + `onChange` to render a bare field node standalone.
2627
+ //
2628
+ // objectui#3308 ruled "registration gets exactly one path"; the implementation
2629
+ // (PR #3793) then disproved the assumption that this was a redundant duplicate —
2630
+ // it was a demo host adapter, and switching the docs site to `registerAllFields()`
2631
+ // alone would have left the demo inputs inert. Ruling B of objectui#3798 resolved
2632
+ // that by moving the docs catalog's field examples to form hosting
2633
+ // (`{ type: 'form', fields: [...] }`), which deletes the need for the adapter
2634
+ // instead of relocating it: the form renderer already owns label and value state,
2635
+ // so the docs now show what a real application actually renders.
2636
+ //
2637
+ // Do not re-add this or any second registration path. `registerAllFields()` (and
2638
+ // `registerField()` for one type) is the seam; a docs- or test-only wrapper that
2639
+ // re-registers `field:<type>` silently shadows the live entry for every consumer
2640
+ // sharing the registry.
2641
+ //
2642
+ // TOMBSTONE (objectui#3308, ADR-0049 enforce-or-remove) — `field:capability-multiselect`
2643
+ // was registered inside `registerFields()`, and ONLY there. Because the docs site
2644
+ // was that function's sole caller, the key never existed on the live path, so a
2645
+ // field authored with `widget: 'capability-multiselect'` resolved to nothing in
2646
+ // every real app while a comment here advertised it as usable from a record form.
2647
+ // Nothing stamped the hint either: ADR-0056 P1 stamps `permission-facet-link` on
2648
+ // all six `sys_permission_set` facets (`data-objectstack/src/index.ts`
2649
+ // applyFieldWidgetOverrides) and P2 put the capability editor in Studio. The
2650
+ // widget NAME stays retired — do not add it to `fieldWidgetMap`.
2651
+ // `CapabilityMultiSelectField` itself lives on as a plain component, imported and
2652
+ // rendered directly by Studio's `PermissionMatrixEditor` (ADR-0056 P2's design).
2398
2653
 
2399
2654
  export * from './widgets/types';
2400
2655
  // File field value shapes (ObjectStack ADR-0104 D3 wave 2) — the single
@@ -2423,6 +2678,11 @@ export * from './widgets/ObjectRefField';
2423
2678
  export * from './widgets/FilterConditionField';
2424
2679
  export * from './widgets/RecipientPickerField';
2425
2680
  export * from './widgets/RecordPickerDialog';
2681
+ // Shared picker-column derivation (ADR-0085 highlightFields → displayFields →
2682
+ // schema walk) — consumed by LookupField AND by RelatedList's Add-picker so a
2683
+ // lookup picker and a related-list Add dialog of the same object agree on
2684
+ // columns (#3365).
2685
+ export * from './widgets/deriveLookupColumns';
2426
2686
  export * from './widgets/FileField';
2427
2687
  export * from './widgets/ImageField';
2428
2688
  export { ImageCropperDialog } from './widgets/ImageCropperDialog';
@@ -2451,5 +2711,35 @@ export * from './widgets/RadioField';
2451
2711
  export * from './widgets/CheckboxesField';
2452
2712
  export * from './widgets/TagsField';
2453
2713
 
2714
+ // The SDUI-node → `field` adapter every registration here goes through
2715
+ // (objectui#3233). Exported so a widget registered OUTSIDE this repo can reach
2716
+ // the same single-carrier guarantee instead of re-growing a `field || schema`
2717
+ // read of its own.
2718
+ export { withFieldCarrier } from './withFieldCarrier';
2719
+
2720
+ // The whitelist that decides what a widget's `...props` spread may put on a
2721
+ // DOM element (objectui#3291) — the runtime executor of the "DOM pass-through"
2722
+ // block of `FieldWidgetComponentProps`. Exported for the same reason as
2723
+ // `withFieldCarrier` above: a widget authored outside this repo needs to reach
2724
+ // it, or it re-grows the bare spread this closed.
2725
+ export { toDomProps } from './widgets/toDomProps';
2726
+ export type { DomProps } from './widgets/toDomProps';
2727
+
2728
+ // The native date/time control value adapters (objectui#3127). `DateTimeField`
2729
+ // is ISO-canonical on BOTH sides — it takes the record's ISO instant and hands
2730
+ // an ISO instant back, which is also the wire form the platform's `datetime`
2731
+ // value contract requires (`InstantValueSchema`: an ISO-8601 instant with an
2732
+ // explicit zone), so no consumer has to convert at its own serialization
2733
+ // boundary. `ActionParamDialog` used to (objectstack#5061 removed it — the
2734
+ // zone-less wall clock it produced was the one shape the validator rejects).
2735
+ // Exported for the same reason as `toDomProps` above: a widget authored outside
2736
+ // this repo needs the same pair, and a second copy of "what the local wall clock
2737
+ // of an instant is" would drift from this one.
2738
+ export {
2739
+ toDateInputValue,
2740
+ toDateTimeInputValue,
2741
+ fromDateTimeInputValue,
2742
+ } from './widgets/nativeDateValue';
2743
+
2454
2744
  // Initialize registry
2455
2745
  registerAllFields();