@object-ui/fields 17.3.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 (156) hide show
  1. package/CHANGELOG.md +941 -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 +4 -4
  7. package/dist/index.css +633 -0
  8. package/dist/index.d.ts +48 -12
  9. package/dist/index.js +5026 -4933
  10. package/dist/widgets/AddressField.d.ts +3 -11
  11. package/dist/widgets/FullscreenFieldEditor.d.ts +42 -152
  12. package/dist/widgets/GridField.d.ts +12 -2
  13. package/dist/widgets/OptionsEmptyState.d.ts +15 -1
  14. package/dist/widgets/RecordPickerDialog.d.ts +32 -6
  15. package/dist/widgets/SelectField.d.ts +25 -5
  16. package/dist/widgets/address-format.d.ts +68 -0
  17. package/dist/widgets/file-size-guard.d.ts +41 -0
  18. package/dist/widgets/nativeDateValue.d.ts +37 -0
  19. package/dist/widgets/toDomProps.d.ts +30 -10
  20. package/dist/widgets/toHostGroupProps.d.ts +91 -0
  21. package/dist/widgets/useRecordQuery.d.ts +7 -1
  22. package/package.json +17 -13
  23. package/src/FieldEditWidget.tsx +17 -1
  24. package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
  25. package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
  26. package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
  27. package/src/__tests__/DateCellRenderer.test.tsx +9 -0
  28. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
  29. package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
  30. package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
  31. package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
  32. package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
  33. package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
  34. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
  35. package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
  36. package/src/__tests__/date-locale-channel.test.tsx +305 -0
  37. package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
  38. package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
  39. package/src/__tests__/group-labelling-declaration.test.ts +148 -0
  40. package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
  41. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
  42. package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
  43. package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
  44. package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
  45. package/src/__tests__/widget-dom-leak-e2e.test.tsx +29 -270
  46. package/src/complex-widgets.test.tsx +22 -6
  47. package/src/currency.ts +130 -0
  48. package/src/datetime-widgets.test.tsx +94 -2
  49. package/src/field-type-alias.multiple.test.ts +81 -0
  50. package/src/field-type-alias.ts +58 -2
  51. package/src/index.css +44 -0
  52. package/src/index.tsx +489 -247
  53. package/src/standard-widgets.test.tsx +8 -1
  54. package/src/widgets/AddressField.postalCode.test.tsx +257 -0
  55. package/src/widgets/AddressField.tsx +79 -29
  56. package/src/widgets/AddressField.uniqueIds.test.tsx +2 -1
  57. package/src/widgets/BooleanField.tsx +41 -7
  58. package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
  59. package/src/widgets/CheckboxesField.tsx +35 -2
  60. package/src/widgets/CurrencyField.tsx +55 -15
  61. package/src/widgets/DateField.tsx +11 -2
  62. package/src/widgets/DateTimeField.tsx +12 -3
  63. package/src/widgets/FileField.tsx +27 -9
  64. package/src/widgets/FormulaField.tsx +6 -1
  65. package/src/widgets/FullscreenFieldEditor.tsx +47 -258
  66. package/src/widgets/GeolocationField.tsx +29 -3
  67. package/src/widgets/GridField.test.tsx +226 -0
  68. package/src/widgets/GridField.tsx +114 -10
  69. package/src/widgets/ImageField.maxSize.test.tsx +154 -0
  70. package/src/widgets/ImageField.tsx +54 -4
  71. package/src/widgets/LookupField.hydration.test.tsx +1 -1
  72. package/src/widgets/LookupField.tsx +8 -4
  73. package/src/widgets/MultiSelectField.tsx +56 -2
  74. package/src/widgets/OptionsEmptyState.tsx +16 -0
  75. package/src/widgets/RadioField.tsx +35 -3
  76. package/src/widgets/RatingField.tsx +15 -2
  77. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
  78. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
  79. package/src/widgets/RecordPickerDialog.tsx +64 -15
  80. package/src/widgets/RichTextField.tsx +1 -1
  81. package/src/widgets/SelectField.tsx +36 -5
  82. package/src/widgets/UserField.tsx +3 -2
  83. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
  84. package/src/widgets/address-format.ts +120 -0
  85. package/src/widgets/file-size-guard.ts +55 -0
  86. package/src/widgets/nativeDateValue.test.ts +115 -0
  87. package/src/widgets/nativeDateValue.ts +112 -0
  88. package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
  89. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
  90. package/src/widgets/toDomProps.ts +48 -19
  91. package/src/widgets/toHostGroupProps.ts +102 -0
  92. package/src/widgets/useFieldTranslation.ts +15 -33
  93. package/src/widgets/useRecordQuery.ts +30 -3
  94. package/dist/FieldEditWidget.test.d.ts +0 -8
  95. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  96. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  97. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  98. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  99. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  100. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  101. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  102. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  103. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  104. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  105. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  106. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  107. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  108. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  109. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  110. package/dist/coerce-safe-value.test.d.ts +0 -1
  111. package/dist/complex-widgets.test.d.ts +0 -1
  112. package/dist/datetime-widgets.test.d.ts +0 -1
  113. package/dist/field-type-coverage.test.d.ts +0 -1
  114. package/dist/standard-widgets.test.d.ts +0 -0
  115. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  116. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  117. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  118. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  119. package/dist/widgets/GridField.test.d.ts +0 -1
  120. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  121. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  122. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
  123. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  124. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  125. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  126. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  127. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  128. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  129. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  130. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  131. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  132. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
  133. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
  134. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  135. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  136. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  137. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  138. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  139. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  140. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  141. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  142. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  143. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  144. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  145. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  146. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  147. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  148. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  149. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  150. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  151. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  152. package/dist/widgets/file-value.test.d.ts +0 -1
  153. package/dist/widgets/personDisplay.test.d.ts +0 -8
  154. package/dist/widgets/recentLookups.test.d.ts +0 -1
  155. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  156. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
package/src/index.tsx CHANGED
@@ -9,12 +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';
17
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';
18
22
 
19
23
  // Module-level cache so multiple renderers fetching the same lookup ID
20
24
  // only trigger one network call. Keyed by `${objectName}:${id}`.
@@ -285,46 +289,15 @@ function useFieldTranslate(): ((key: string, params?: Record<string, unknown>) =
285
289
  return t as (key: string, params?: Record<string, unknown>) => string;
286
290
  }
287
291
 
288
- import { TextField } from './widgets/TextField';
289
- import { NumberField } from './widgets/NumberField';
290
- import { BooleanField } from './widgets/BooleanField';
291
- import { SelectField } from './widgets/SelectField';
292
- import { DateField } from './widgets/DateField';
293
- import { EmailField } from './widgets/EmailField';
294
- import { PhoneField } from './widgets/PhoneField';
295
- import { UrlField } from './widgets/UrlField';
296
- import { CurrencyField } from './widgets/CurrencyField';
297
- import { TextAreaField } from './widgets/TextAreaField';
298
- import { RichTextField } from './widgets/RichTextField';
299
- import { LookupField } from './widgets/LookupField';
300
- import { CapabilityMultiSelectField } from './widgets/CapabilityMultiSelectField';
301
- import { DateTimeField } from './widgets/DateTimeField';
302
- import { TimeField } from './widgets/TimeField';
303
- import { PercentField } from './widgets/PercentField';
304
- import { PasswordField } from './widgets/PasswordField';
305
- import { FileField } from './widgets/FileField';
306
- 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.
307
299
  import { ImageLightbox } from './widgets/ImageLightbox';
308
300
  import { readFileValues } from './widgets/file-value';
309
- import { LocationField } from './widgets/LocationField';
310
- import { FormulaField } from './widgets/FormulaField';
311
- import { SummaryField } from './widgets/SummaryField';
312
- import { AutoNumberField } from './widgets/AutoNumberField';
313
- import { UserField } from './widgets/UserField';
314
- import { ObjectField } from './widgets/ObjectField';
315
- import { VectorField } from './widgets/VectorField';
316
- import { GridField } from './widgets/GridField';
317
- // New widgets according to @objectstack/spec
318
- import { ColorField } from './widgets/ColorField';
319
- import { SliderField } from './widgets/SliderField';
320
- import { RatingField } from './widgets/RatingField';
321
- import { CodeField } from './widgets/CodeField';
322
- import { AvatarField } from './widgets/AvatarField';
323
- import { AddressField } from './widgets/AddressField';
324
- import { GeolocationField } from './widgets/GeolocationField';
325
- import { SignatureField } from './widgets/SignatureField';
326
- import { QRCodeField } from './widgets/QRCodeField';
327
- import { MasterDetailField } from './widgets/MasterDetailField';
328
301
 
329
302
  /**
330
303
  * Cell renderer props
@@ -385,27 +358,56 @@ export function coerceToSafeValue(value: unknown): string | number | boolean | n
385
358
  * USD for unconfigured currency fields was the #1 source of "why is my
386
359
  * RMB amount showing as dollars?" bug reports.
387
360
  *
388
- * Trailing `.00` is dropped when the value is a whole number — Salesforce
389
- * 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.
390
367
  */
391
- import { resolveFieldCurrency } from './currency';
368
+ import { resolveFieldCurrency, currencyFractionDigits } from './currency';
392
369
  export { resolveFieldCurrency };
393
370
 
394
- export function formatCurrency(value: number, currency?: string): string {
371
+ export function formatCurrency(value: number, currency?: string, locale?: string): string {
395
372
  const isWhole = Number.isFinite(value) && value === Math.trunc(value);
396
- 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;
397
399
  if (!currency) {
398
- return formatNumber(value, maxFrac);
400
+ return formatNumber(value, fracDigits, locale);
399
401
  }
400
402
  try {
401
- return new Intl.NumberFormat('en-US', {
402
- style: 'currency',
403
+ return formatDisplayNumber(value, {
404
+ locale,
403
405
  currency,
404
- minimumFractionDigits: 0,
405
- maximumFractionDigits: maxFrac,
406
- }).format(value);
406
+ minimumFractionDigits: fracDigits,
407
+ maximumFractionDigits: fracDigits,
408
+ });
407
409
  } catch {
408
- return `${currency} ${value.toFixed(maxFrac)}`;
410
+ return `${currency} ${value.toFixed(fracDigits)}`;
409
411
  }
410
412
  }
411
413
 
@@ -414,25 +416,26 @@ export function formatCurrency(value: number, currency?: string): string {
414
416
  * E.g., $150,000 → $150K, $1,200,000 → $1.2M
415
417
  * When `currency` is undefined, returns a compact number without symbol.
416
418
  */
417
- export function formatCompactCurrency(value: number, currency?: string): string {
419
+ export function formatCompactCurrency(value: number, currency?: string, locale?: string): string {
418
420
  if (!currency) {
419
421
  try {
420
- const formatted = new Intl.NumberFormat('en-US', {
422
+ const formatted = formatDisplayNumber(value, {
423
+ locale,
421
424
  notation: 'compact',
422
425
  maximumFractionDigits: 1,
423
- }).format(value);
426
+ });
424
427
  return formatted.replace(/\.0(?=[KMBT])/, '');
425
428
  } catch {
426
429
  return String(value);
427
430
  }
428
431
  }
429
432
  try {
430
- const formatted = new Intl.NumberFormat('en-US', {
431
- style: 'currency',
433
+ const formatted = formatDisplayNumber(value, {
434
+ locale,
432
435
  currency,
433
436
  notation: 'compact',
434
437
  maximumFractionDigits: 1,
435
- }).format(value);
438
+ });
436
439
  // Strip trailing ".0" before compact suffix for consistent cross-environment output
437
440
  // e.g. "$150.0K" → "$150K" while keeping "$1.5M" intact
438
441
  return formatted.replace(/\.0(?=[KMBT])/, '');
@@ -446,26 +449,95 @@ export function formatCompactCurrency(value: number, currency?: string): string
446
449
  * Used as a safe fallback when a currency-typed field has no `currency`
447
450
  * configured — we'd rather render `1,234.50` than silently assume USD.
448
451
  */
449
- export function formatNumber(value: number, decimals: number = 2): string {
452
+ export function formatNumber(value: number, decimals: number = 2, locale?: string): string {
450
453
  try {
451
- 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,
452
461
  minimumFractionDigits: decimals,
453
462
  maximumFractionDigits: decimals,
454
- }).format(value);
463
+ });
455
464
  } catch {
456
465
  return value.toFixed(decimals);
457
466
  }
458
467
  }
459
468
 
460
469
  /**
461
- * 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.
462
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.
463
535
  */
464
- export function formatPercent(value: number, precision: number = 0): string {
536
+ export function formatPercent(value: number, precision: number = 0, locale?: string): string {
465
537
  // Scale a fraction-stored percent (0.8 → 80%) via the shared core helper, so
466
538
  // the list cell and the dashboard measure formatter (`formatMeasure`) agree.
467
539
  const displayValue = percentDisplayValue(value);
468
- return `${displayValue.toFixed(precision)}%`;
540
+ return formatPercentBody(displayValue, precision, locale);
469
541
  }
470
542
 
471
543
  /**
@@ -549,8 +621,13 @@ export function formatDate(value: string | Date | number, style?: string, option
549
621
  if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
550
622
 
551
623
  if (style === 'short') {
552
- // Compact format for mobile: "Jan 15, '24"
553
- 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' });
554
631
  const day = date.getDate();
555
632
  const year = String(date.getFullYear()).slice(-2);
556
633
  return `${month} ${day}, '${year}`;
@@ -574,14 +651,22 @@ export function formatDate(value: string | Date | number, style?: string, option
574
651
  }
575
652
 
576
653
  /**
577
- * 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()`.
578
663
  */
579
- export function formatDateTime(value: string | Date | number): string {
664
+ export function formatDateTime(value: string | Date | number, options?: DateDisplayOptions): string {
580
665
  if (value === null || value === undefined || value === '') return '—';
581
666
  const date = value instanceof Date ? value : new Date(value as any);
582
667
  if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
583
-
584
- return date.toLocaleDateString(undefined, {
668
+
669
+ return date.toLocaleDateString(options?.locale, {
585
670
  year: 'numeric',
586
671
  month: 'short',
587
672
  day: 'numeric',
@@ -632,8 +717,12 @@ export function TextCellRenderer({ value }: CellRendererProps): React.ReactEleme
632
717
  * Number field cell renderer
633
718
  */
634
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();
635
724
  if (value == null) return <EmptyValue />;
636
-
725
+
637
726
  const safe = coerceToSafeValue(value);
638
727
  const numField = field as any;
639
728
  // Decimal places come from `scale` (the `s` in a `decimal(p, s)` column),
@@ -644,13 +733,22 @@ export function NumberCellRenderer({ value, field }: CellRendererProps): React.R
644
733
  // scale 2 → "16.00", a field with scale 3 → "3.140"); when it is absent we
645
734
  // keep the minimum at 0 so trailing zeros are trimmed and only cap the
646
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.
647
743
  const scale = typeof numField.scale === 'number' ? numField.scale : undefined;
648
744
  const num = Number(safe);
649
745
  const formatted = !isNaN(num)
650
- ? new Intl.NumberFormat('en-US', {
746
+ ? formatDisplayNumber(num, {
747
+ locale,
748
+ scale,
651
749
  minimumFractionDigits: scale ?? 0,
652
750
  maximumFractionDigits: scale ?? 20,
653
- }).format(num)
751
+ })
654
752
  : String(safe);
655
753
 
656
754
  return <span className="tabular-nums">{formatted}</span>;
@@ -663,6 +761,7 @@ export function CurrencyCellRenderer({ value, field }: CellRendererProps): React
663
761
  // Hooks before the empty-value early return — a value flipping between
664
762
  // null and set must not change the hook count between renders.
665
763
  const { currency: tenantCurrency } = useLocalization();
764
+ const locale = useDisplayLocale();
666
765
  if (value == null) return <EmptyValue />;
667
766
 
668
767
  const safe = coerceToSafeValue(value);
@@ -673,7 +772,7 @@ export function CurrencyCellRenderer({ value, field }: CellRendererProps): React
673
772
  const currency = resolveFieldCurrency(field as any, tenantCurrency);
674
773
  const num = Number(safe);
675
774
  const formatted = !isNaN(num)
676
- ? formatCurrency(num, currency)
775
+ ? formatCurrency(num, currency, locale)
677
776
  : String(safe);
678
777
 
679
778
  return <span className="tabular-nums font-medium whitespace-nowrap">{formatted}</span>;
@@ -686,8 +785,12 @@ const WHOLE_PERCENT_FIELD_PATTERN = /progress|completion/;
686
785
  * Percent field cell renderer with mini progress bar
687
786
  */
688
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();
689
792
  if (value == null) return <EmptyValue />;
690
-
793
+
691
794
  const safe = coerceToSafeValue(value);
692
795
  const percentField = field as any;
693
796
  const precision = percentField.precision ?? 0;
@@ -701,13 +804,35 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React.
701
804
  const barValue = isWholePercentField
702
805
  ? numValue
703
806
  : (numValue > -1 && numValue < 1) ? numValue * 100 : numValue;
704
- 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);
705
817
  const clampedBar = Math.max(0, Math.min(100, barValue));
706
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.
707
832
  return (
708
- <div className="flex items-center gap-2">
833
+ <div className="flex min-w-0 items-center gap-2">
709
834
  <div
710
- 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"
711
836
  role="progressbar"
712
837
  aria-valuenow={clampedBar}
713
838
  aria-valuemin={0}
@@ -718,7 +843,7 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React.
718
843
  style={{ width: `${clampedBar}%` }}
719
844
  />
720
845
  </div>
721
- <span className="tabular-nums whitespace-nowrap">{formatted}</span>
846
+ <span className="shrink-0 tabular-nums whitespace-nowrap">{formatted}</span>
722
847
  </div>
723
848
  );
724
849
  }
@@ -778,7 +903,14 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React.
778
903
  * Date field cell renderer
779
904
  */
780
905
  export function DateCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
781
- 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();
782
914
  const t = useFieldTranslate();
783
915
  if (!value) return <EmptyValue />;
784
916
  const safe = coerceToSafeValue(value);
@@ -815,17 +947,26 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
815
947
  * DateTime field cell renderer (Airtable-style with date and time visually separated)
816
948
  */
817
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();
818
956
  if (!value) return <EmptyValue />;
819
957
  const safe = coerceToSafeValue(value);
820
958
  const date = safe != null ? new Date(safe as string | number) : null;
821
959
  if (date === null || isNaN(date.getTime())) return <EmptyValue />;
822
960
 
823
- const datePart = date.toLocaleDateString(undefined, {
961
+ const datePart = date.toLocaleDateString(locale, {
824
962
  month: 'numeric',
825
963
  day: 'numeric',
826
964
  year: 'numeric',
827
965
  });
828
- 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, {
829
970
  hour: 'numeric',
830
971
  minute: '2-digit',
831
972
  hour12: true,
@@ -1388,6 +1529,89 @@ export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElem
1388
1529
  );
1389
1530
  }
1390
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
+
1391
1615
  /**
1392
1616
  * Lookup/Master-Detail field cell renderer.
1393
1617
  *
@@ -1460,15 +1684,24 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
1460
1684
  // Compute inside try, render outside — constructing JSX in a try/catch
1461
1685
  // doesn't catch its render errors anyway (react-hooks/error-boundaries).
1462
1686
  let parsedDisplay = '';
1687
+ let parsedId: string | number | undefined;
1463
1688
  try {
1464
1689
  const parsed = JSON.parse(s) as Record<string, unknown>;
1465
1690
  if (parsed && typeof parsed === 'object') {
1466
1691
  parsedDisplay =
1467
1692
  resolveLookupRecordName(parsed, refSchema, displayField) ||
1468
1693
  String(parsed.externalId ?? parsed.id ?? parsed._id ?? '');
1694
+ // An external-id reference has no record id yet — stays unlinked.
1695
+ parsedId = referencedRecordId(parsed);
1469
1696
  }
1470
1697
  } catch { /* not JSON — fall through to normal resolution */ }
1471
- if (parsedDisplay) return <TruncatedText text={parsedDisplay} />;
1698
+ if (parsedDisplay) {
1699
+ return (
1700
+ <ReferencedRecordLink objectName={referenceTo} recordId={parsedId}>
1701
+ <TruncatedText text={parsedDisplay} />
1702
+ </ReferencedRecordLink>
1703
+ );
1704
+ }
1472
1705
  }
1473
1706
  }
1474
1707
 
@@ -1479,7 +1712,11 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
1479
1712
  const display =
1480
1713
  resolveLookupRecordName(obj, refSchema, displayField) || String(obj.id || obj._id || '');
1481
1714
  if (display) {
1482
- return <TruncatedText text={display} />;
1715
+ return (
1716
+ <ReferencedRecordLink objectName={referenceTo} recordId={referencedRecordId(obj)}>
1717
+ <TruncatedText text={display} />
1718
+ </ReferencedRecordLink>
1719
+ );
1483
1720
  }
1484
1721
  }
1485
1722
 
@@ -1517,18 +1754,26 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
1517
1754
  label = r.text;
1518
1755
  muted = r.muted;
1519
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.
1520
1759
  return (
1521
- <span
1760
+ <ReferencedRecordLink
1522
1761
  key={idx}
1523
- className={cn(
1524
- 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
1525
- muted
1526
- ? 'bg-muted/40 text-muted-foreground'
1527
- : 'bg-gray-50 text-gray-700 dark:bg-gray-800/50 dark:text-gray-200',
1528
- )}
1762
+ objectName={referenceTo}
1763
+ recordId={referencedRecordId(item)}
1764
+ className="text-inherit"
1529
1765
  >
1530
- {label}
1531
- </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>
1532
1777
  );
1533
1778
  })}
1534
1779
  </div>
@@ -1539,12 +1784,23 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
1539
1784
  const label =
1540
1785
  resolveLookupRecordName(value as Record<string, unknown>, refSchema, displayField) ||
1541
1786
  String((value as any).id || (value as any)._id || '[Object]');
1542
- return <TruncatedText text={label} />;
1787
+ return (
1788
+ <ReferencedRecordLink objectName={referenceTo} recordId={referencedRecordId(value)}>
1789
+ <TruncatedText text={label} />
1790
+ </ReferencedRecordLink>
1791
+ );
1543
1792
  }
1544
1793
 
1545
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.
1546
1798
  const { text, muted } = resolveLabel(value);
1547
- return <TruncatedText text={text} className={muted ? 'text-muted-foreground' : undefined} />;
1799
+ return (
1800
+ <ReferencedRecordLink objectName={referenceTo} recordId={referencedRecordId(value)}>
1801
+ <TruncatedText text={text} className={muted ? 'text-muted-foreground' : undefined} />
1802
+ </ReferencedRecordLink>
1803
+ );
1548
1804
  }
1549
1805
 
1550
1806
  /**
@@ -1796,6 +2052,39 @@ export function LocationCellRenderer({ value }: CellRendererProps): React.ReactE
1796
2052
  return <JsonCellRenderer value={value} field={{} as any} />;
1797
2053
  }
1798
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
+
1799
2088
  /**
1800
2089
  * Get the appropriate cell renderer for a field type
1801
2090
  */
@@ -1852,7 +2141,7 @@ export function getCellRenderer(fieldType: string): React.FC<CellRendererProps>
1852
2141
  secret: () => <span>••••••</span>,
1853
2142
  location: LocationCellRenderer,
1854
2143
  geolocation: LocationCellRenderer,
1855
- address: JsonCellRenderer,
2144
+ address: AddressCellRenderer,
1856
2145
  color: ColorSwatchCellRenderer,
1857
2146
  json: JsonCellRenderer,
1858
2147
  object: JsonCellRenderer,
@@ -2060,64 +2349,16 @@ export function evaluateCondition(condition: any, formData: any): boolean {
2060
2349
  return true;
2061
2350
  }
2062
2351
 
2063
- // Create wrapper renderers for field widgets to work with ComponentDemo
2064
- function createFieldRenderer(FieldWidget: React.ComponentType<any>) {
2065
- const FieldRenderer: React.FC<any> = ({ schema, className, value: initialValue, ...props }) => {
2066
- const [value, setValue] = React.useState(initialValue ?? schema?.value ?? '');
2067
-
2068
- const field = {
2069
- name: schema?.name || 'field',
2070
- label: schema?.label,
2071
- type: schema?.type,
2072
- placeholder: schema?.placeholder,
2073
- required: schema?.required,
2074
- readonly: schema?.readonly || schema?.readOnly,
2075
- help: schema?.help,
2076
- description: schema?.description,
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,
2080
- ...schema,
2081
- };
2082
-
2083
- const handleChange = React.useCallback((newValue: any) => {
2084
- setValue(newValue);
2085
- if (props.onChange) {
2086
- props.onChange(newValue);
2087
- }
2088
- }, [props]);
2089
-
2090
- const readonly = schema?.readonly || schema?.readOnly || false;
2091
-
2092
- return (
2093
- <div
2094
- className="grid w-full items-center gap-1.5"
2095
- data-obj-id={schema?.id}
2096
- data-obj-type={schema?.type}
2097
- >
2098
- {schema?.label && (
2099
- <label htmlFor={schema.id} className={schema.required ? "after:content-['*'] after:ml-0.5 after:text-red-500" : ""}>
2100
- {schema.label}
2101
- </label>
2102
- )}
2103
- <FieldWidget
2104
- value={value}
2105
- onChange={handleChange}
2106
- field={field}
2107
- readonly={readonly}
2108
- className={className}
2109
- />
2110
- {schema?.description && (
2111
- <p className="text-sm text-gray-500">{schema.description}</p>
2112
- )}
2113
- </div>
2114
- );
2115
- };
2116
-
2117
- FieldRenderer.displayName = `FieldRenderer(${FieldWidget.displayName || FieldWidget.name || 'Component'})`;
2118
-
2119
- return FieldRenderer;
2120
- }
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).
2121
2362
 
2122
2363
  /**
2123
2364
  * Field widget map for lazy loading
@@ -2294,6 +2535,50 @@ const FIELD_TYPES_SKIP_FALLBACK = new Set([
2294
2535
  'recipient-picker',
2295
2536
  ]);
2296
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
+
2297
2582
  export function registerField(fieldType: string): void {
2298
2583
  const loader = fieldWidgetMap[fieldType];
2299
2584
  if (!loader) {
@@ -2311,6 +2596,10 @@ export function registerField(fieldType: string): void {
2311
2596
  ComponentRegistry.register(fieldType, withFieldCarrier(LazyFieldWidget), {
2312
2597
  namespace: 'field',
2313
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),
2314
2603
  });
2315
2604
  }
2316
2605
 
@@ -2330,100 +2619,37 @@ export function registerAllFields(): void {
2330
2619
  });
2331
2620
  }
2332
2621
 
2333
- /**
2334
- * Legacy function - kept for backward compatibility
2335
- * @deprecated Use registerAllFields() instead
2336
- */
2337
- export function registerFields() {
2338
- // Basic fields - wrapped for documentation compatibility
2339
- // `text` collides with the display text widget in @object-ui/components.
2340
- // Display semantics ({ type: 'text', content: '...' }) are the dominant
2341
- // usage across the docs/blocks catalog, so we keep this renderer accessible
2342
- // only via the namespaced `field:text` key and let the display widget win
2343
- // the bare `text` lookup.
2344
- ComponentRegistry.register('text', createFieldRenderer(TextField), { namespace: 'field', skipFallback: true });
2345
- // `textarea`/`select` collide with the `ui:*` form-input primitives that own
2346
- // the bare keys; namespaced-only (forms use `field:<type>`). See the shared
2347
- // FIELD_TYPES_SKIP_FALLBACK note above registerField().
2348
- ComponentRegistry.register('textarea', createFieldRenderer(TextAreaField), { namespace: 'field', skipFallback: true });
2349
- ComponentRegistry.register('number', createFieldRenderer(NumberField), { namespace: 'field' });
2350
- ComponentRegistry.register('boolean', createFieldRenderer(BooleanField), { namespace: 'field' });
2351
- ComponentRegistry.register('select', createFieldRenderer(SelectField), { namespace: 'field', skipFallback: true });
2352
- ComponentRegistry.register('date', createFieldRenderer(DateField), { namespace: 'field' });
2353
- ComponentRegistry.register('datetime', createFieldRenderer(DateTimeField), { namespace: 'field' });
2354
- // Namespaced-only: no other renderer legitimately owns the bare 'time' key,
2355
- // but the fallback still triggered noisy "bare-name overwritten" warnings on
2356
- // every re-registration (e.g. hot reload / re-init in a shared registry).
2357
- ComponentRegistry.register('time', createFieldRenderer(TimeField), { namespace: 'field', skipFallback: true });
2358
-
2359
- // Contact fields - wrapped for documentation compatibility
2360
- // `email` collides with the `ui:email` input variant; namespaced-only so the
2361
- // display/input primitive keeps the bare key (forms use `field:email`).
2362
- ComponentRegistry.register('email', createFieldRenderer(EmailField), { namespace: 'field', skipFallback: true });
2363
- ComponentRegistry.register('phone', createFieldRenderer(PhoneField), { namespace: 'field' });
2364
- ComponentRegistry.register('url', createFieldRenderer(UrlField), { namespace: 'field' });
2365
-
2366
- // Specialized fields - wrapped for documentation compatibility
2367
- ComponentRegistry.register('currency', createFieldRenderer(CurrencyField), { namespace: 'field' });
2368
- ComponentRegistry.register('percent', createFieldRenderer(PercentField), { namespace: 'field' });
2369
- // `password` collides with the `ui:password` input variant; namespaced-only.
2370
- ComponentRegistry.register('password', createFieldRenderer(PasswordField), { namespace: 'field', skipFallback: true });
2371
- // `markdown` collides with the markdown DISPLAY renderer (plugin-markdown:markdown),
2372
- // which owns bare `{ type: 'markdown', content }`; the field renderer here is the
2373
- // RichText editor, reached by forms via `field:markdown`. Namespaced-only.
2374
- ComponentRegistry.register('markdown', createFieldRenderer(RichTextField), { namespace: 'field', skipFallback: true });
2375
- // `html` collides with the HTML-rendering display widget. Keep the
2376
- // markdown field, but expose the HTML field only via `field:html` so the
2377
- // display widget remains the default for { type: 'html', content }.
2378
- ComponentRegistry.register('html', createFieldRenderer(RichTextField), { namespace: 'field', skipFallback: true });
2379
- ComponentRegistry.register('lookup', createFieldRenderer(LookupField), { namespace: 'field' });
2380
- // Capability multi-select (ADR-0056 P2) — reached only via the field
2381
- // `widget: 'capability-multiselect'` hint (see MetadataProvider), never a bare
2382
- // field type. Registered WITHOUT createFieldRenderer: it is a fully-controlled
2383
- // widget (value/onChange from RHF), so the wrapper's extra label + local value
2384
- // buffer would only duplicate the form's FormLabel and desync the value.
2385
- ComponentRegistry.register('capability-multiselect', withFieldCarrier(CapabilityMultiSelectField), { namespace: 'field', skipFallback: true });
2386
- // master_detail = child-side FK lookup (single value, typically required). See
2387
- // fieldWidgetMap above for rationale.
2388
- ComponentRegistry.register('master_detail', createFieldRenderer(LookupField), { namespace: 'field' });
2389
-
2390
- // File fields
2391
- ComponentRegistry.register('file', createFieldRenderer(FileField), { namespace: 'field' });
2392
- // `image` collides with the display image widget; namespaced-only.
2393
- ComponentRegistry.register('image', createFieldRenderer(ImageField), { namespace: 'field', skipFallback: true });
2394
-
2395
- // Location field
2396
- ComponentRegistry.register('location', createFieldRenderer(LocationField), { namespace: 'field' });
2397
-
2398
- // Computed/Read-only fields
2399
- ComponentRegistry.register('formula', createFieldRenderer(FormulaField), { namespace: 'field' });
2400
- ComponentRegistry.register('summary', createFieldRenderer(SummaryField), { namespace: 'field' });
2401
- ComponentRegistry.register('auto_number', createFieldRenderer(AutoNumberField), { namespace: 'field' });
2402
-
2403
- // User fields
2404
- ComponentRegistry.register('user', createFieldRenderer(UserField), { namespace: 'field' });
2405
- ComponentRegistry.register('owner', createFieldRenderer(UserField), { namespace: 'field' });
2406
-
2407
- // Complex data types
2408
- ComponentRegistry.register('object', createFieldRenderer(ObjectField), { namespace: 'field' });
2409
- ComponentRegistry.register('vector', createFieldRenderer(VectorField), { namespace: 'field' });
2410
- // `grid` collides with the layout grid component; namespaced-only.
2411
- ComponentRegistry.register('grid', createFieldRenderer(GridField), { namespace: 'field', skipFallback: true });
2412
-
2413
- // NEW: Additional field types from @objectstack/spec
2414
- ComponentRegistry.register('color', createFieldRenderer(ColorField), { namespace: 'field' });
2415
- // `slider` collides with the `ui:slider` display control; namespaced-only.
2416
- ComponentRegistry.register('slider', createFieldRenderer(SliderField), { namespace: 'field', skipFallback: true });
2417
- ComponentRegistry.register('rating', createFieldRenderer(RatingField), { namespace: 'field' });
2418
- ComponentRegistry.register('code', createFieldRenderer(CodeField), { namespace: 'field' });
2419
- // `avatar` collides with the display avatar widget; namespaced-only.
2420
- ComponentRegistry.register('avatar', createFieldRenderer(AvatarField), { namespace: 'field', skipFallback: true });
2421
- // Namespaced-only — see the 'time' registration above for rationale.
2422
- ComponentRegistry.register('address', createFieldRenderer(AddressField), { namespace: 'field', skipFallback: true });
2423
- ComponentRegistry.register('geolocation', createFieldRenderer(GeolocationField), { namespace: 'field' });
2424
- ComponentRegistry.register('signature', createFieldRenderer(SignatureField), { namespace: 'field' });
2425
- ComponentRegistry.register('qrcode', createFieldRenderer(QRCodeField), { namespace: 'field' });
2426
- }
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).
2427
2653
 
2428
2654
  export * from './widgets/types';
2429
2655
  // File field value shapes (ObjectStack ADR-0104 D3 wave 2) — the single
@@ -2499,5 +2725,21 @@ export { withFieldCarrier } from './withFieldCarrier';
2499
2725
  export { toDomProps } from './widgets/toDomProps';
2500
2726
  export type { DomProps } from './widgets/toDomProps';
2501
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
+
2502
2744
  // Initialize registry
2503
2745
  registerAllFields();