@object-ui/fields 6.2.3 → 7.1.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 (97) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/dist/complex-widgets.test.d.ts +1 -0
  3. package/dist/currency.d.ts +13 -0
  4. package/dist/datetime-widgets.test.d.ts +1 -0
  5. package/dist/field-type-coverage.test.d.ts +1 -0
  6. package/dist/index.cjs +4 -3
  7. package/dist/index.d.ts +300 -2
  8. package/dist/index.js +2460 -1274
  9. package/dist/standard-widgets.test.d.ts +0 -0
  10. package/dist/{packages/fields/src/widgets → widgets}/AddressField.d.ts +2 -1
  11. package/dist/{packages/fields/src/widgets → widgets}/AutoNumberField.d.ts +2 -1
  12. package/dist/{packages/fields/src/widgets → widgets}/AvatarField.d.ts +2 -1
  13. package/dist/{packages/fields/src/widgets → widgets}/BooleanField.d.ts +2 -1
  14. package/dist/widgets/CheckboxesField.d.ts +8 -0
  15. package/dist/{packages/fields/src/widgets → widgets}/CodeField.d.ts +2 -1
  16. package/dist/{packages/fields/src/widgets → widgets}/ColorField.d.ts +2 -1
  17. package/dist/{packages/fields/src/widgets → widgets}/CurrencyField.d.ts +2 -1
  18. package/dist/{packages/fields/src/widgets → widgets}/DateField.d.ts +2 -1
  19. package/dist/{packages/fields/src/widgets → widgets}/DateTimeField.d.ts +2 -1
  20. package/dist/{packages/fields/src/widgets → widgets}/EmailField.d.ts +2 -1
  21. package/dist/{packages/fields/src/widgets → widgets}/FileField.d.ts +2 -1
  22. package/dist/{packages/fields/src/widgets → widgets}/FormulaField.d.ts +2 -1
  23. package/dist/{packages/fields/src/widgets → widgets}/GeolocationField.d.ts +2 -1
  24. package/dist/widgets/GridField.d.ts +123 -0
  25. package/dist/widgets/GridField.test.d.ts +1 -0
  26. package/dist/{packages/fields/src/widgets → widgets}/ImageField.d.ts +2 -1
  27. package/dist/{packages/fields/src/widgets → widgets}/LocationField.d.ts +2 -1
  28. package/dist/{packages/fields/src/widgets → widgets}/LookupField.d.ts +2 -1
  29. package/dist/widgets/MarkdownContent.d.ts +9 -0
  30. package/dist/{packages/fields/src/widgets → widgets}/MasterDetailField.d.ts +2 -1
  31. package/dist/widgets/MultiSelectField.d.ts +9 -0
  32. package/dist/{packages/fields/src/widgets → widgets}/NumberField.d.ts +2 -1
  33. package/dist/{packages/fields/src/widgets → widgets}/ObjectField.d.ts +2 -1
  34. package/dist/{packages/fields/src/widgets → widgets}/PasswordField.d.ts +2 -1
  35. package/dist/{packages/fields/src/widgets → widgets}/PercentField.d.ts +2 -1
  36. package/dist/{packages/fields/src/widgets → widgets}/PhoneField.d.ts +2 -1
  37. package/dist/{packages/fields/src/widgets → widgets}/QRCodeField.d.ts +2 -1
  38. package/dist/widgets/RadioField.d.ts +8 -0
  39. package/dist/{packages/fields/src/widgets → widgets}/RatingField.d.ts +2 -1
  40. package/dist/{packages/fields/src/widgets → widgets}/RecordPickerDialog.d.ts +8 -2
  41. package/dist/{packages/fields/src/widgets → widgets}/RichTextField.d.ts +2 -1
  42. package/dist/{packages/fields/src/widgets → widgets}/SelectField.d.ts +2 -1
  43. package/dist/{packages/fields/src/widgets → widgets}/SignatureField.d.ts +2 -1
  44. package/dist/{packages/fields/src/widgets → widgets}/SliderField.d.ts +2 -1
  45. package/dist/{packages/fields/src/widgets → widgets}/SummaryField.d.ts +2 -1
  46. package/dist/widgets/TagsField.d.ts +8 -0
  47. package/dist/{packages/fields/src/widgets → widgets}/TextAreaField.d.ts +2 -1
  48. package/dist/{packages/fields/src/widgets → widgets}/TextField.d.ts +2 -1
  49. package/dist/{packages/fields/src/widgets → widgets}/TimeField.d.ts +2 -1
  50. package/dist/{packages/fields/src/widgets → widgets}/UrlField.d.ts +2 -1
  51. package/dist/{packages/fields/src/widgets → widgets}/UserField.d.ts +2 -1
  52. package/dist/{packages/fields/src/widgets → widgets}/VectorField.d.ts +2 -1
  53. package/dist/widgets/deriveLookupColumns.d.ts +36 -0
  54. package/dist/widgets/deriveLookupColumns.test.d.ts +1 -0
  55. package/dist/widgets/recentLookups.d.ts +13 -0
  56. package/dist/widgets/recentLookups.test.d.ts +1 -0
  57. package/dist/{packages/fields/src/widgets → widgets}/types.d.ts +1 -1
  58. package/package.json +14 -11
  59. package/src/__tests__/registry-collision.test.ts +91 -0
  60. package/src/__tests__/resolveFieldCurrency.test.ts +20 -0
  61. package/src/coerce-safe-value.test.ts +34 -0
  62. package/src/complex-widgets.test.tsx +58 -7
  63. package/src/currency.ts +14 -0
  64. package/src/field-type-coverage.test.ts +65 -0
  65. package/src/index.tsx +372 -57
  66. package/src/widgets/CheckboxesField.tsx +53 -0
  67. package/src/widgets/CurrencyField.tsx +5 -1
  68. package/src/widgets/GridField.test.tsx +309 -0
  69. package/src/widgets/GridField.tsx +907 -33
  70. package/src/widgets/LookupField.tsx +258 -107
  71. package/src/widgets/MarkdownContent.tsx +19 -0
  72. package/src/widgets/MultiSelectField.tsx +59 -0
  73. package/src/widgets/PercentField.tsx +19 -7
  74. package/src/widgets/RadioField.tsx +41 -0
  75. package/src/widgets/RecordPickerDialog.tsx +22 -18
  76. package/src/widgets/SelectField.tsx +23 -2
  77. package/src/widgets/TagsField.tsx +67 -0
  78. package/src/widgets/deriveLookupColumns.test.ts +74 -0
  79. package/src/widgets/deriveLookupColumns.ts +104 -0
  80. package/src/widgets/recentLookups.test.ts +45 -0
  81. package/src/widgets/recentLookups.ts +47 -0
  82. package/src/widgets/useFieldTranslation.ts +30 -0
  83. package/dist/packages/fields/src/fields-edge-cases.stories.d.ts +0 -45
  84. package/dist/packages/fields/src/fields.stories.d.ts +0 -56
  85. package/dist/packages/fields/src/index.d.ts +0 -286
  86. package/dist/packages/fields/src/record-picker-dialog.stories.d.ts +0 -25
  87. package/dist/packages/fields/src/widgets/GridField.d.ts +0 -7
  88. package/src/fields-edge-cases.stories.tsx +0 -407
  89. package/src/fields.stories.tsx +0 -566
  90. package/src/record-picker-dialog.stories.tsx +0 -160
  91. /package/dist/{packages/fields/src/__tests__/validation-feedback.test.d.ts → __tests__/registry-collision.test.d.ts} +0 -0
  92. /package/dist/{packages/fields/src/complex-widgets.test.d.ts → __tests__/resolveFieldCurrency.test.d.ts} +0 -0
  93. /package/dist/{packages/fields/src/standard-widgets.test.d.ts → __tests__/validation-feedback.test.d.ts} +0 -0
  94. /package/dist/{packages/fields/src/datetime-widgets.test.d.ts → coerce-safe-value.test.d.ts} +0 -0
  95. /package/dist/{packages/fields/src/widgets → widgets}/ImageCropperDialog.d.ts +0 -0
  96. /package/dist/{packages/fields/src/widgets → widgets}/_cell-renderer-bridge.d.ts +0 -0
  97. /package/dist/{packages/fields/src/widgets → widgets}/useFieldTranslation.d.ts +0 -0
package/src/index.tsx CHANGED
@@ -8,9 +8,10 @@
8
8
 
9
9
  import React from 'react';
10
10
  import type { FieldMetadata, SelectOptionMetadata } from '@object-ui/types';
11
- import { ComponentRegistry } from '@object-ui/core';
11
+ import { ComponentRegistry, percentDisplayValue } from '@object-ui/core';
12
+ import { useLocalization } from '@object-ui/i18n';
12
13
  import { Badge, Avatar, AvatarFallback, Button, Checkbox, EmptyValue, cn } from '@object-ui/components';
13
- import { Check, X, Copy, Phone as PhoneIcon } from 'lucide-react';
14
+ import { Check, X, Copy, Phone as PhoneIcon, MapPin } from 'lucide-react';
14
15
  import { useObjectTranslation } from '@object-ui/react';
15
16
  import { SchemaRendererContext as _SchemaRendererContext } from '@object-ui/react';
16
17
 
@@ -222,13 +223,23 @@ export interface CellRendererProps {
222
223
  */
223
224
  export function coerceToSafeValue(value: unknown): string | number | boolean | null | undefined {
224
225
  if (value == null) return value as null | undefined;
225
- if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
226
+ if (typeof value === 'number' || typeof value === 'boolean') return value;
227
+ if (typeof value === 'string') {
228
+ // A reference/expanded value can arrive as a JSON-encoded object string —
229
+ // e.g. an unresolved external-id reference '{"externalId":"Website Relaunch"}'.
230
+ // Parse and extract a human label instead of leaking raw JSON into the cell.
231
+ const s = value.trim();
232
+ if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('[') && s.endsWith(']'))) {
233
+ try { return coerceToSafeValue(JSON.parse(s)); } catch { /* not JSON — fall through */ }
234
+ }
235
+ return value;
236
+ }
226
237
  if (value instanceof Date) return value.toISOString();
227
238
  if (Array.isArray(value)) {
228
239
  return value.map((v) => {
229
240
  if (v != null && typeof v === 'object') {
230
241
  const obj = v as Record<string, unknown>;
231
- return String(obj.name || obj.label || obj.id || obj._id || '[Object]');
242
+ return String(obj.name || obj.label || obj.externalId || obj.id || obj._id || '[Object]');
232
243
  }
233
244
  return String(v);
234
245
  }).join(', ');
@@ -241,8 +252,8 @@ export function coerceToSafeValue(value: unknown): string | number | boolean | n
241
252
  if ('$oid' in obj) return String(obj.$oid);
242
253
  // MongoDB date wrapper: { $date: "2024-01-01T00:00:00Z" }
243
254
  if ('$date' in obj) return String(obj.$date);
244
- // Expanded reference / general object: extract name/label/id
245
- return String(obj.name || obj.label || obj.id || obj._id || '[Object]');
255
+ // Expanded reference / general object: extract name/label/externalId/id
256
+ return String(obj.name || obj.label || obj.externalId || obj.id || obj._id || '[Object]');
246
257
  }
247
258
  return String(value);
248
259
  }
@@ -256,6 +267,9 @@ export function coerceToSafeValue(value: unknown): string | number | boolean | n
256
267
  * Trailing `.00` is dropped when the value is a whole number — Salesforce
257
268
  * convention: `$1,234.50` keeps cents; `$1,234` does not.
258
269
  */
270
+ import { resolveFieldCurrency } from './currency';
271
+ export { resolveFieldCurrency };
272
+
259
273
  export function formatCurrency(value: number, currency?: string): string {
260
274
  const isWhole = Number.isFinite(value) && value === Math.trunc(value);
261
275
  const maxFrac = isWhole ? 0 : 2;
@@ -327,8 +341,9 @@ export function formatNumber(value: number, decimals: number = 2): string {
327
341
  * Handles both decimal (0.8 = 80%) and whole number (80 = 80%) inputs.
328
342
  */
329
343
  export function formatPercent(value: number, precision: number = 0): string {
330
- // If value is between -1 and 1 (exclusive), treat as decimal (e.g. 0.8 → 80%)
331
- const displayValue = (value > -1 && value < 1) ? value * 100 : value;
344
+ // Scale a fraction-stored percent (0.8 → 80%) via the shared core helper, so
345
+ // the list cell and the dashboard measure formatter (`formatMeasure`) agree.
346
+ const displayValue = percentDisplayValue(value);
332
347
  return `${displayValue.toFixed(precision)}%`;
333
348
  }
334
349
 
@@ -454,18 +469,12 @@ export function CurrencyCellRenderer({ value, field }: CellRendererProps): React
454
469
  if (value == null) return <EmptyValue />;
455
470
 
456
471
  const safe = coerceToSafeValue(value);
457
- const currencyField = field as any;
458
- // Honor `currency` (legacy/grid configs), `defaultCurrency` (canonical
459
- // top-level), and `currencyConfig.defaultCurrency` (the nested shape
460
- // emitted by `@objectstack/spec` Field.currency() with currencyConfig).
461
- // When none is supplied, render as a plain formatted number —
462
- // silently assuming USD for unconfigured currency fields was
463
- // misleading for non-USD orgs (e.g. RMB amounts displayed as $).
464
- const currency: string | undefined =
465
- currencyField.currency ||
466
- currencyField.defaultCurrency ||
467
- currencyField.currencyConfig?.defaultCurrency ||
468
- undefined;
472
+ // Resolve the display currency via the shared precedence: field `currency` →
473
+ // `currencyConfig.defaultCurrency` → the tenant default (ADR-0053). When none
474
+ // is known, render a plain number — never a guessed symbol (silently assuming
475
+ // USD mis-displays non-USD orgs, e.g. RMB amounts shown as $).
476
+ const { currency: tenantCurrency } = useLocalization();
477
+ const currency = resolveFieldCurrency(field as any, tenantCurrency);
469
478
  const num = Number(safe);
470
479
  const formatted = !isNaN(num)
471
480
  ? formatCurrency(num, currency)
@@ -769,8 +778,52 @@ function hashToColor(value: string): string {
769
778
  return BADGE_FALLBACK_PALETTE[idx];
770
779
  }
771
780
 
781
+ /**
782
+ * Map a hex color (e.g. '#8B5CF6') to the nearest named palette color the
783
+ * badge/dot maps understand. Object field options almost always declare colors
784
+ * as HEX, so without this the explicit author color is ignored and a semantic/
785
+ * hash heuristic takes over (e.g. a purple 'In Review' rendered alarming-red).
786
+ * Low-saturation hexes resolve to 'gray'; otherwise bucket by hue.
787
+ */
788
+ function hexToPaletteName(hex: string): string | undefined {
789
+ const m = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex.trim());
790
+ if (!m) return undefined;
791
+ let h = m[1];
792
+ if (h.length === 3) h = h.split('').map((c) => c + c).join('');
793
+ const r = parseInt(h.slice(0, 2), 16) / 255;
794
+ const g = parseInt(h.slice(2, 4), 16) / 255;
795
+ const b = parseInt(h.slice(4, 6), 16) / 255;
796
+ const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
797
+ const l = (max + min) / 2;
798
+ const sat = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
799
+ if (sat < 0.22) return 'gray';
800
+ let hue: number;
801
+ if (max === r) hue = (((g - b) / d) % 6 + 6) % 6;
802
+ else if (max === g) hue = (b - r) / d + 2;
803
+ else hue = (r - g) / d + 4;
804
+ hue *= 60;
805
+ if (hue >= 345 || hue < 15) return 'red';
806
+ if (hue < 30) return 'orange';
807
+ if (hue < 60) return 'yellow';
808
+ if (hue < 170) return 'green';
809
+ if (hue < 238) return 'blue';
810
+ if (hue < 250) return 'indigo';
811
+ if (hue < 295) return 'purple';
812
+ return 'pink';
813
+ }
814
+
815
+ /** Normalize an option color to a named palette key: pass known names through,
816
+ * resolve hex to the nearest palette color, else undefined. */
817
+ function resolveColorName(color?: string): string | undefined {
818
+ if (!color) return undefined;
819
+ if (BADGE_COLOR_MAP[color]) return color;
820
+ if (color.charAt(0) === '#') return hexToPaletteName(color);
821
+ return undefined;
822
+ }
823
+
772
824
  export function getBadgeColorClasses(color?: string, val?: unknown): string {
773
- if (color && BADGE_COLOR_MAP[color]) return BADGE_COLOR_MAP[color];
825
+ const named = resolveColorName(color);
826
+ if (named && BADGE_COLOR_MAP[named]) return BADGE_COLOR_MAP[named];
774
827
  if (val == null || val === '') return 'bg-muted text-muted-foreground border-border';
775
828
  const key = String(val).toLowerCase().replace(/[\s-]/g, '_');
776
829
  const semantic = SEMANTIC_COLOR_MAP[key];
@@ -790,7 +843,8 @@ export function getBadgeColorClasses(color?: string, val?: unknown): string {
790
843
  * supplied so the caller can fall back to its own default.
791
844
  */
792
845
  export function getSemanticColorName(color?: string, val?: unknown): string | undefined {
793
- if (color && BADGE_COLOR_MAP[color]) return color;
846
+ const named = resolveColorName(color);
847
+ if (named && BADGE_COLOR_MAP[named]) return named;
794
848
  if (val == null || val === '') return undefined;
795
849
  const key = String(val).toLowerCase().replace(/[\s-]/g, '_');
796
850
  const semantic = SEMANTIC_COLOR_MAP[key];
@@ -862,7 +916,7 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
862
916
  if (appearance === 'dot') {
863
917
  // Resolve a real CSS color for the dot. Prefer explicit option color,
864
918
  // then semantic mapping for the value, then deterministic palette.
865
- const colorName = option?.color
919
+ const colorName = resolveColorName(option?.color)
866
920
  || SEMANTIC_COLOR_MAP[String(val).toLowerCase().replace(/[\s-]/g, '_')]
867
921
  || hashToColor(String(val).toLowerCase().replace(/[\s-]/g, '_'));
868
922
  const dotClass = DOT_COLOR_MAP[colorName] || DOT_COLOR_MAP.gray;
@@ -1044,31 +1098,44 @@ export function FileCellRenderer({ value, field }: CellRendererProps): React.Rea
1044
1098
  */
1045
1099
  export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElement {
1046
1100
  if (!value) return <EmptyValue />;
1047
-
1101
+
1102
+ // An image value may be a plain URL string, an object ({ url | src | href }),
1103
+ // or an array of either. Normalize so a string-URL field (common — e.g. a
1104
+ // `cover` seeded with a CDN link) renders a thumbnail instead of a broken
1105
+ // <img src=""> placeholder.
1106
+ const urlOf = (v: any): string =>
1107
+ typeof v === 'string' ? v : (v?.url || v?.src || v?.href || v?.thumbnailUrl || '');
1108
+ const nameOf = (v: any, fallback: string): string =>
1109
+ (v && typeof v === 'object' && (v.name || v.original_name)) || fallback;
1110
+
1048
1111
  if (Array.isArray(value)) {
1112
+ const imgs = value.map((v) => ({ url: urlOf(v), name: nameOf(v, 'Image') })).filter((i) => i.url);
1113
+ if (imgs.length === 0) return <EmptyValue />;
1049
1114
  return (
1050
1115
  <div className="flex -space-x-2">
1051
- {value.slice(0, 3).map((img, idx) => (
1116
+ {imgs.slice(0, 3).map((img, idx) => (
1052
1117
  <img
1053
1118
  key={idx}
1054
- src={img.url || ''}
1119
+ src={img.url}
1055
1120
  alt={img.name || `Image ${idx + 1}`}
1056
1121
  className="size-8 rounded-md border-2 border-white object-cover"
1057
1122
  />
1058
1123
  ))}
1059
- {value.length > 3 && (
1124
+ {imgs.length > 3 && (
1060
1125
  <div className="size-8 rounded-md border-2 border-white bg-gray-100 flex items-center justify-center text-xs font-medium text-gray-600">
1061
- +{value.length - 3}
1126
+ +{imgs.length - 3}
1062
1127
  </div>
1063
1128
  )}
1064
1129
  </div>
1065
1130
  );
1066
1131
  }
1067
-
1132
+
1133
+ const url = urlOf(value);
1134
+ if (!url) return <EmptyValue />;
1068
1135
  return (
1069
1136
  <img
1070
- src={value.url || ''}
1071
- alt={value.name || 'Image'}
1137
+ src={url}
1138
+ alt={nameOf(value, 'Image')}
1072
1139
  className="size-10 rounded-md object-cover"
1073
1140
  />
1074
1141
  );
@@ -1112,6 +1179,24 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
1112
1179
 
1113
1180
  if (value == null || value === '') return <EmptyValue />;
1114
1181
 
1182
+ // A reference can arrive as a JSON-encoded object string — e.g. an
1183
+ // unresolved external-id reference '{"externalId":"Website Relaunch"}'.
1184
+ // Parse it and render a label instead of leaking raw JSON into the cell.
1185
+ if (typeof value === 'string') {
1186
+ const s = value.trim();
1187
+ if (s.startsWith('{') && s.endsWith('}')) {
1188
+ try {
1189
+ const parsed = JSON.parse(s) as Record<string, unknown>;
1190
+ if (parsed && typeof parsed === 'object') {
1191
+ const display =
1192
+ pickRecordDisplayName(parsed) ||
1193
+ String(parsed.externalId ?? parsed.id ?? parsed._id ?? '');
1194
+ if (display) return <span className="truncate">{display}</span>;
1195
+ }
1196
+ } catch { /* not JSON — fall through to normal resolution */ }
1197
+ }
1198
+ }
1199
+
1115
1200
  // Server-side $expand returns the related record as a nested object
1116
1201
  // (e.g. { id, name }). Render its display name directly — no fetch needed.
1117
1202
  if (!Array.isArray(value) && typeof value === 'object') {
@@ -1323,6 +1408,117 @@ export function resolveCellRendererType(fieldOrType: string | { type?: string; f
1323
1408
  return fieldOrType.type || 'text';
1324
1409
  }
1325
1410
 
1411
+ /**
1412
+ * Renders structured/embedded values (json, object, composite, record,
1413
+ * address, geolocation) as compact, readable JSON. Objects and arrays are
1414
+ * stringified; primitives fall through to their string form.
1415
+ */
1416
+ export function JsonCellRenderer({ value }: CellRendererProps): React.ReactElement {
1417
+ if (value == null || value === '') return <EmptyValue />;
1418
+ let text: string;
1419
+ if (typeof value === 'object') {
1420
+ try {
1421
+ text = JSON.stringify(value);
1422
+ } catch {
1423
+ text = String(value);
1424
+ }
1425
+ } else {
1426
+ text = String(value);
1427
+ }
1428
+ return <span className="font-mono text-xs text-gray-600 truncate" title={text}>{text}</span>;
1429
+ }
1430
+
1431
+ /**
1432
+ * Renders a `color` value as a swatch alongside its hex/string value.
1433
+ */
1434
+ export function ColorSwatchCellRenderer({ value }: CellRendererProps): React.ReactElement {
1435
+ if (value == null || value === '') return <EmptyValue />;
1436
+ const color = String(value);
1437
+ return (
1438
+ <span className="inline-flex items-center gap-1.5 text-sm">
1439
+ <span
1440
+ className="h-3.5 w-3.5 rounded border border-black/10 shrink-0"
1441
+ style={{ backgroundColor: color }}
1442
+ aria-hidden="true"
1443
+ />
1444
+ <span className="font-mono text-xs">{color}</span>
1445
+ </span>
1446
+ );
1447
+ }
1448
+
1449
+ const LazyMarkdownContent = React.lazy(() => import('./widgets/MarkdownContent'));
1450
+
1451
+ /**
1452
+ * Renders `markdown` / `richtext` values as formatted GFM markdown (lazy-loaded,
1453
+ * sanitized) instead of the raw markup string.
1454
+ */
1455
+ export function MarkdownCellRenderer({ value }: CellRendererProps): React.ReactElement {
1456
+ if (value == null || value === '') return <EmptyValue />;
1457
+ return (
1458
+ <React.Suspense fallback={<span className="text-sm text-muted-foreground">{String(value).slice(0, 80)}</span>}>
1459
+ <LazyMarkdownContent value={String(value)} />
1460
+ </React.Suspense>
1461
+ );
1462
+ }
1463
+
1464
+ /**
1465
+ * Minimal HTML sanitizer for display: drops <script>/<style>/<iframe> blocks,
1466
+ * inline event handlers, and javascript: URLs. Defense-in-depth — stored HTML
1467
+ * is authored by users with write access, but is never trusted blindly.
1468
+ */
1469
+ function sanitizeHtml(html: string): string {
1470
+ return html
1471
+ .replace(/<\s*(script|style|iframe|object|embed)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
1472
+ .replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
1473
+ .replace(/(href|src)\s*=\s*("javascript:[^"]*"|'javascript:[^']*')/gi, '$1="#"');
1474
+ }
1475
+
1476
+ /**
1477
+ * Renders an `html` value as sanitized, formatted HTML instead of raw markup.
1478
+ */
1479
+ export function HtmlCellRenderer({ value }: CellRendererProps): React.ReactElement {
1480
+ if (value == null || value === '') return <EmptyValue />;
1481
+ return (
1482
+ <div
1483
+ className="prose prose-sm max-w-none dark:prose-invert break-words"
1484
+ dangerouslySetInnerHTML={{ __html: sanitizeHtml(String(value)) }}
1485
+ />
1486
+ );
1487
+ }
1488
+
1489
+ /**
1490
+ * Renders a `location`/`geolocation` value as readable coordinates with a pin.
1491
+ * Accepts `{ lat, lng }` / `{ latitude, longitude }`, a `"lat,lng"` string,
1492
+ * or a `[lat, lng]` array. Falls back to compact JSON for anything else.
1493
+ */
1494
+ export function LocationCellRenderer({ value }: CellRendererProps): React.ReactElement {
1495
+ if (value == null || value === '') return <EmptyValue />;
1496
+ let lat: number | undefined;
1497
+ let lng: number | undefined;
1498
+ if (typeof value === 'object' && !Array.isArray(value)) {
1499
+ const v = value as Record<string, any>;
1500
+ lat = typeof v.lat === 'number' ? v.lat : typeof v.latitude === 'number' ? v.latitude : undefined;
1501
+ lng = typeof v.lng === 'number' ? v.lng : typeof v.lon === 'number' ? v.lon : typeof v.longitude === 'number' ? v.longitude : undefined;
1502
+ } else if (Array.isArray(value) && value.length === 2) {
1503
+ lat = Number(value[0]);
1504
+ lng = Number(value[1]);
1505
+ } else if (typeof value === 'string') {
1506
+ const parts = value.split(',').map((s) => parseFloat(s.trim()));
1507
+ if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
1508
+ [lat, lng] = parts;
1509
+ }
1510
+ }
1511
+ if (typeof lat === 'number' && typeof lng === 'number' && !isNaN(lat) && !isNaN(lng)) {
1512
+ return (
1513
+ <span className="inline-flex items-center gap-1 text-sm tabular-nums">
1514
+ <MapPin className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
1515
+ {lat.toFixed(4)}, {lng.toFixed(4)}
1516
+ </span>
1517
+ );
1518
+ }
1519
+ return <JsonCellRenderer value={value} field={{} as any} />;
1520
+ }
1521
+
1326
1522
  /**
1327
1523
  * Get the appropriate cell renderer for a field type
1328
1524
  */
@@ -1336,32 +1532,61 @@ export function getCellRenderer(fieldType: string): React.FC<CellRendererProps>
1336
1532
  const standardMap: Record<string, React.FC<CellRendererProps>> = {
1337
1533
  text: TextCellRenderer,
1338
1534
  textarea: TextCellRenderer,
1339
- markdown: TextCellRenderer,
1340
- html: TextCellRenderer,
1535
+ markdown: MarkdownCellRenderer,
1536
+ html: HtmlCellRenderer,
1537
+ richtext: MarkdownCellRenderer,
1538
+ code: TextCellRenderer,
1539
+ qrcode: TextCellRenderer,
1341
1540
  number: NumberCellRenderer,
1342
1541
  currency: CurrencyCellRenderer,
1343
1542
  percent: PercentCellRenderer,
1543
+ progress: PercentCellRenderer,
1544
+ slider: NumberCellRenderer,
1545
+ rating: NumberCellRenderer,
1344
1546
  boolean: BooleanCellRenderer,
1547
+ toggle: BooleanCellRenderer,
1345
1548
  date: DateCellRenderer,
1346
1549
  datetime: DateTimeCellRenderer,
1347
1550
  time: TextCellRenderer,
1348
1551
  select: SelectCellRenderer,
1349
1552
  status: SelectCellRenderer,
1553
+ multiselect: SelectCellRenderer,
1554
+ radio: SelectCellRenderer,
1555
+ checkboxes: SelectCellRenderer,
1556
+ tags: SelectCellRenderer,
1350
1557
  lookup: LookupCellRenderer,
1351
1558
  master_detail: LookupCellRenderer,
1559
+ tree: LookupCellRenderer,
1352
1560
  email: EmailCellRenderer,
1353
1561
  url: UrlCellRenderer,
1354
1562
  phone: PhoneCellRenderer,
1355
1563
  file: FileCellRenderer,
1564
+ video: FileCellRenderer,
1565
+ audio: FileCellRenderer,
1356
1566
  image: ImageCellRenderer,
1567
+ avatar: ImageCellRenderer,
1568
+ signature: ImageCellRenderer,
1357
1569
  formula: FormulaCellRenderer,
1358
1570
  summary: FormulaCellRenderer,
1359
1571
  auto_number: TextCellRenderer,
1360
1572
  user: UserCellRenderer,
1361
1573
  owner: UserCellRenderer,
1362
1574
  password: () => <span>••••••</span>,
1363
- location: TextCellRenderer, // Default fallback
1364
- object: () => <span className="text-gray-500 italic">[Object]</span>,
1575
+ secret: () => <span>••••••</span>,
1576
+ location: LocationCellRenderer,
1577
+ geolocation: LocationCellRenderer,
1578
+ address: JsonCellRenderer,
1579
+ color: ColorSwatchCellRenderer,
1580
+ json: JsonCellRenderer,
1581
+ object: JsonCellRenderer,
1582
+ composite: JsonCellRenderer,
1583
+ record: JsonCellRenderer,
1584
+ repeater: ({ value }: CellRendererProps) => {
1585
+ const n = Array.isArray(value) ? value.length : 0;
1586
+ return n > 0
1587
+ ? <span className="text-gray-500 italic">{n} 项</span>
1588
+ : <EmptyValue />;
1589
+ },
1365
1590
  vector: () => <span className="text-gray-500 italic">[Vector]</span>,
1366
1591
  grid: () => <span className="text-gray-500 italic">[Grid]</span>,
1367
1592
  };
@@ -1402,38 +1627,66 @@ export function mapFieldTypeToFormType(fieldType: string): string {
1402
1627
  textarea: 'field:textarea',
1403
1628
  markdown: 'field:markdown', // Markdown editor (fallback to textarea)
1404
1629
  html: 'field:html', // Rich text editor (fallback to textarea)
1405
-
1630
+ richtext: 'field:richtext', // WYSIWYG rich-text editor
1631
+ secret: 'field:password', // encrypted-at-rest value — mask input like a password
1632
+
1406
1633
  // Numeric fields
1407
1634
  number: 'field:number',
1408
1635
  currency: 'field:currency',
1409
1636
  percent: 'field:percent',
1410
-
1637
+ slider: 'field:slider',
1638
+ progress: 'field:slider', // bounded 0..100 progress — edit via slider
1639
+ rating: 'field:rating',
1640
+
1411
1641
  // Date/Time fields
1412
1642
  date: 'field:date',
1413
1643
  datetime: 'field:datetime',
1414
1644
  time: 'field:time',
1415
-
1645
+
1416
1646
  // Boolean
1417
1647
  boolean: 'field:boolean',
1418
-
1648
+ toggle: 'field:boolean', // toggle is a boolean rendered as a switch
1649
+
1419
1650
  // Selection fields
1420
1651
  select: 'field:select',
1652
+ multiselect: 'field:multiselect',
1653
+ radio: 'field:radio',
1654
+ checkboxes: 'field:checkboxes',
1655
+ tags: 'field:tags',
1421
1656
  lookup: 'field:lookup',
1422
1657
  master_detail: 'field:master_detail',
1423
-
1658
+ tree: 'field:lookup', // hierarchical reference — pick the parent via a lookup
1659
+
1424
1660
  // Contact fields
1425
1661
  email: 'field:email',
1426
1662
  phone: 'field:phone',
1427
1663
  url: 'field:url',
1428
-
1429
- // File fields
1664
+
1665
+ // File / media fields
1430
1666
  file: 'field:file',
1431
1667
  image: 'field:image',
1432
-
1433
- // Special fields
1668
+ avatar: 'field:avatar',
1669
+ video: 'field:file', // uploads as a file
1670
+ audio: 'field:file', // uploads as a file
1671
+ signature: 'field:signature',
1672
+
1673
+ // Special / enhanced fields
1434
1674
  password: 'field:password',
1435
1675
  location: 'field:location', // Location/map field (fallback to input)
1436
-
1676
+ geolocation: 'field:geolocation',
1677
+ address: 'field:address',
1678
+ color: 'field:color',
1679
+ code: 'field:code',
1680
+ json: 'field:code', // JSON edited in the code editor
1681
+ qrcode: 'field:qrcode',
1682
+ vector: 'field:vector',
1683
+
1684
+ // Embedded structured values (stored as JSON on the row)
1685
+ object: 'field:object',
1686
+ composite: 'field:object', // embedded object
1687
+ record: 'field:object', // name-keyed map
1688
+ repeater: 'field:grid', // embedded array of rows
1689
+
1437
1690
  // Auto-generated/computed fields (typically read-only)
1438
1691
  formula: 'field:formula',
1439
1692
  summary: 'field:summary',
@@ -1665,12 +1918,19 @@ const fieldWidgetMap: Record<string, () => Promise<{ default: React.ComponentTyp
1665
1918
  'phone': () => import('./widgets/PhoneField').then(m => ({ default: m.PhoneField })),
1666
1919
  'url': () => import('./widgets/UrlField').then(m => ({ default: m.UrlField })),
1667
1920
 
1921
+ // Selection fields (multi-value / option groups)
1922
+ 'multiselect': () => import('./widgets/MultiSelectField').then(m => ({ default: m.MultiSelectField })),
1923
+ 'radio': () => import('./widgets/RadioField').then(m => ({ default: m.RadioField })),
1924
+ 'checkboxes': () => import('./widgets/CheckboxesField').then(m => ({ default: m.CheckboxesField })),
1925
+ 'tags': () => import('./widgets/TagsField').then(m => ({ default: m.TagsField })),
1926
+
1668
1927
  // Specialized fields
1669
1928
  'currency': () => import('./widgets/CurrencyField').then(m => ({ default: m.CurrencyField })),
1670
1929
  'percent': () => import('./widgets/PercentField').then(m => ({ default: m.PercentField })),
1671
1930
  'password': () => import('./widgets/PasswordField').then(m => ({ default: m.PasswordField })),
1672
1931
  'markdown': () => import('./widgets/RichTextField').then(m => ({ default: m.RichTextField })),
1673
1932
  'html': () => import('./widgets/RichTextField').then(m => ({ default: m.RichTextField })),
1933
+ 'richtext': () => import('./widgets/RichTextField').then(m => ({ default: m.RichTextField })),
1674
1934
  'lookup': () => import('./widgets/LookupField').then(m => ({ default: m.LookupField })),
1675
1935
  // master_detail represents the child-side FK to its parent. In create/edit forms it
1676
1936
  // must render as a single-value lookup picker (it is typically NOT NULL). The legacy
@@ -1719,6 +1979,33 @@ const fieldWidgetMap: Record<string, () => Promise<{ default: React.ComponentTyp
1719
1979
  * // Register only the text field
1720
1980
  * registerField('text');
1721
1981
  */
1982
+ // Field types whose short name collides with a display/ui/view/plugin component
1983
+ // registered elsewhere (e.g. the display widgets and form-input primitives in
1984
+ // @object-ui/components, or the markdown display plugin). These remain
1985
+ // accessible via the namespaced `field:<type>` key — which is how forms resolve
1986
+ // them (see form.tsx renderFieldComponent + mapFieldTypeToFormType) — but must
1987
+ // not overwrite the bare `<type>` fallback, which the display/ui primitive owns
1988
+ // and which page schemas expect (e.g. `{ type: 'markdown', content }` → the
1989
+ // markdown renderer, not the RichText editor). Without skipFallback each of
1990
+ // these logged a "bare-name fallback is being overwritten" warning at boot.
1991
+ const FIELD_TYPES_SKIP_FALLBACK = new Set([
1992
+ // Display widgets (text/html/image/avatar/grid live in @object-ui/components
1993
+ // or @object-ui/layout as the bare-name owners).
1994
+ 'text',
1995
+ 'html',
1996
+ 'image',
1997
+ 'avatar',
1998
+ 'grid',
1999
+ // Form-input primitives owned by `ui:*` in @object-ui/components.
2000
+ 'textarea',
2001
+ 'select',
2002
+ 'email',
2003
+ 'password',
2004
+ 'slider',
2005
+ // Display renderer owned by `plugin-markdown:markdown`.
2006
+ 'markdown',
2007
+ ]);
2008
+
1722
2009
  export function registerField(fieldType: string): void {
1723
2010
  const loader = fieldWidgetMap[fieldType];
1724
2011
  if (!loader) {
@@ -1730,7 +2017,10 @@ export function registerField(fieldType: string): void {
1730
2017
  const LazyFieldWidget = React.lazy(loader);
1731
2018
 
1732
2019
  // Register with field namespace - NO WRAPPER to allow form renderer to control label/layout
1733
- ComponentRegistry.register(fieldType, LazyFieldWidget, { namespace: 'field' });
2020
+ ComponentRegistry.register(fieldType, LazyFieldWidget, {
2021
+ namespace: 'field',
2022
+ skipFallback: FIELD_TYPES_SKIP_FALLBACK.has(fieldType),
2023
+ });
1734
2024
  }
1735
2025
 
1736
2026
  /**
@@ -1755,26 +2045,43 @@ export function registerAllFields(): void {
1755
2045
  */
1756
2046
  export function registerFields() {
1757
2047
  // Basic fields - wrapped for documentation compatibility
1758
- ComponentRegistry.register('text', createFieldRenderer(TextField), { namespace: 'field' });
1759
- ComponentRegistry.register('textarea', createFieldRenderer(TextAreaField), { namespace: 'field' });
2048
+ // `text` collides with the display text widget in @object-ui/components.
2049
+ // Display semantics ({ type: 'text', content: '...' }) are the dominant
2050
+ // usage across the docs/blocks catalog, so we keep this renderer accessible
2051
+ // only via the namespaced `field:text` key and let the display widget win
2052
+ // the bare `text` lookup.
2053
+ ComponentRegistry.register('text', createFieldRenderer(TextField), { namespace: 'field', skipFallback: true });
2054
+ // `textarea`/`select` collide with the `ui:*` form-input primitives that own
2055
+ // the bare keys; namespaced-only (forms use `field:<type>`). See the shared
2056
+ // FIELD_TYPES_SKIP_FALLBACK note above registerField().
2057
+ ComponentRegistry.register('textarea', createFieldRenderer(TextAreaField), { namespace: 'field', skipFallback: true });
1760
2058
  ComponentRegistry.register('number', createFieldRenderer(NumberField), { namespace: 'field' });
1761
2059
  ComponentRegistry.register('boolean', createFieldRenderer(BooleanField), { namespace: 'field' });
1762
- ComponentRegistry.register('select', createFieldRenderer(SelectField), { namespace: 'field' });
2060
+ ComponentRegistry.register('select', createFieldRenderer(SelectField), { namespace: 'field', skipFallback: true });
1763
2061
  ComponentRegistry.register('date', createFieldRenderer(DateField), { namespace: 'field' });
1764
2062
  ComponentRegistry.register('datetime', createFieldRenderer(DateTimeField), { namespace: 'field' });
1765
2063
  ComponentRegistry.register('time', createFieldRenderer(TimeField), { namespace: 'field' });
1766
2064
 
1767
2065
  // Contact fields - wrapped for documentation compatibility
1768
- ComponentRegistry.register('email', createFieldRenderer(EmailField), { namespace: 'field' });
2066
+ // `email` collides with the `ui:email` input variant; namespaced-only so the
2067
+ // display/input primitive keeps the bare key (forms use `field:email`).
2068
+ ComponentRegistry.register('email', createFieldRenderer(EmailField), { namespace: 'field', skipFallback: true });
1769
2069
  ComponentRegistry.register('phone', createFieldRenderer(PhoneField), { namespace: 'field' });
1770
2070
  ComponentRegistry.register('url', createFieldRenderer(UrlField), { namespace: 'field' });
1771
2071
 
1772
2072
  // Specialized fields - wrapped for documentation compatibility
1773
2073
  ComponentRegistry.register('currency', createFieldRenderer(CurrencyField), { namespace: 'field' });
1774
2074
  ComponentRegistry.register('percent', createFieldRenderer(PercentField), { namespace: 'field' });
1775
- ComponentRegistry.register('password', createFieldRenderer(PasswordField), { namespace: 'field' });
1776
- ComponentRegistry.register('markdown', createFieldRenderer(RichTextField), { namespace: 'field' });
1777
- ComponentRegistry.register('html', createFieldRenderer(RichTextField), { namespace: 'field' });
2075
+ // `password` collides with the `ui:password` input variant; namespaced-only.
2076
+ ComponentRegistry.register('password', createFieldRenderer(PasswordField), { namespace: 'field', skipFallback: true });
2077
+ // `markdown` collides with the markdown DISPLAY renderer (plugin-markdown:markdown),
2078
+ // which owns bare `{ type: 'markdown', content }`; the field renderer here is the
2079
+ // RichText editor, reached by forms via `field:markdown`. Namespaced-only.
2080
+ ComponentRegistry.register('markdown', createFieldRenderer(RichTextField), { namespace: 'field', skipFallback: true });
2081
+ // `html` collides with the HTML-rendering display widget. Keep the
2082
+ // markdown field, but expose the HTML field only via `field:html` so the
2083
+ // display widget remains the default for { type: 'html', content }.
2084
+ ComponentRegistry.register('html', createFieldRenderer(RichTextField), { namespace: 'field', skipFallback: true });
1778
2085
  ComponentRegistry.register('lookup', createFieldRenderer(LookupField), { namespace: 'field' });
1779
2086
  // master_detail = child-side FK lookup (single value, typically required). See
1780
2087
  // fieldWidgetMap above for rationale.
@@ -1782,7 +2089,8 @@ export function registerFields() {
1782
2089
 
1783
2090
  // File fields
1784
2091
  ComponentRegistry.register('file', createFieldRenderer(FileField), { namespace: 'field' });
1785
- ComponentRegistry.register('image', createFieldRenderer(ImageField), { namespace: 'field' });
2092
+ // `image` collides with the display image widget; namespaced-only.
2093
+ ComponentRegistry.register('image', createFieldRenderer(ImageField), { namespace: 'field', skipFallback: true });
1786
2094
 
1787
2095
  // Location field
1788
2096
  ComponentRegistry.register('location', createFieldRenderer(LocationField), { namespace: 'field' });
@@ -1799,14 +2107,17 @@ export function registerFields() {
1799
2107
  // Complex data types
1800
2108
  ComponentRegistry.register('object', createFieldRenderer(ObjectField), { namespace: 'field' });
1801
2109
  ComponentRegistry.register('vector', createFieldRenderer(VectorField), { namespace: 'field' });
1802
- ComponentRegistry.register('grid', createFieldRenderer(GridField), { namespace: 'field' });
2110
+ // `grid` collides with the layout grid component; namespaced-only.
2111
+ ComponentRegistry.register('grid', createFieldRenderer(GridField), { namespace: 'field', skipFallback: true });
1803
2112
 
1804
2113
  // NEW: Additional field types from @objectstack/spec
1805
2114
  ComponentRegistry.register('color', createFieldRenderer(ColorField), { namespace: 'field' });
1806
- ComponentRegistry.register('slider', createFieldRenderer(SliderField), { namespace: 'field' });
2115
+ // `slider` collides with the `ui:slider` display control; namespaced-only.
2116
+ ComponentRegistry.register('slider', createFieldRenderer(SliderField), { namespace: 'field', skipFallback: true });
1807
2117
  ComponentRegistry.register('rating', createFieldRenderer(RatingField), { namespace: 'field' });
1808
2118
  ComponentRegistry.register('code', createFieldRenderer(CodeField), { namespace: 'field' });
1809
- ComponentRegistry.register('avatar', createFieldRenderer(AvatarField), { namespace: 'field' });
2119
+ // `avatar` collides with the display avatar widget; namespaced-only.
2120
+ ComponentRegistry.register('avatar', createFieldRenderer(AvatarField), { namespace: 'field', skipFallback: true });
1810
2121
  ComponentRegistry.register('address', createFieldRenderer(AddressField), { namespace: 'field' });
1811
2122
  ComponentRegistry.register('geolocation', createFieldRenderer(GeolocationField), { namespace: 'field' });
1812
2123
  ComponentRegistry.register('signature', createFieldRenderer(SignatureField), { namespace: 'field' });
@@ -1854,6 +2165,10 @@ export * from './widgets/GeolocationField';
1854
2165
  export * from './widgets/SignatureField';
1855
2166
  export * from './widgets/QRCodeField';
1856
2167
  export * from './widgets/MasterDetailField';
2168
+ export * from './widgets/MultiSelectField';
2169
+ export * from './widgets/RadioField';
2170
+ export * from './widgets/CheckboxesField';
2171
+ export * from './widgets/TagsField';
1857
2172
 
1858
2173
  // Initialize registry
1859
2174
  registerAllFields();