@object-ui/fields 3.1.5 → 3.3.1

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 (183) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/README.md +24 -0
  3. package/dist/LookupField-CiIjeL_i.cjs +1 -0
  4. package/dist/LookupField-De-GqsfT.js +903 -0
  5. package/dist/chunk-D-qHiVGv.cjs +1 -0
  6. package/dist/index.cjs +3 -3
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.js +704 -690
  9. package/dist/packages/fields/src/widgets/useFieldTranslation.d.ts +5 -0
  10. package/dist/rolldown-runtime-CAFD8bLK.js +11 -0
  11. package/dist/useFieldTranslation-BYPwtuNZ.cjs +1 -0
  12. package/dist/useFieldTranslation-Bv3oUVgb.js +9 -0
  13. package/package.json +35 -9
  14. package/src/__tests__/cell-renderers.test.tsx +39 -18
  15. package/src/__tests__/datetime-cell.test.tsx +2 -2
  16. package/src/__tests__/datetime-timezone.test.tsx +9 -9
  17. package/src/__tests__/validation-feedback.test.tsx +8 -8
  18. package/src/datetime-widgets.test.tsx +1 -1
  19. package/src/index.tsx +54 -28
  20. package/src/record-picker.test.tsx +30 -23
  21. package/src/remaining-widgets.test.tsx +1 -1
  22. package/src/widgets/AddressField.tsx +4 -3
  23. package/src/widgets/AutoNumberField.tsx +5 -3
  24. package/src/widgets/CodeField.tsx +2 -2
  25. package/src/widgets/ColorField.tsx +2 -2
  26. package/src/widgets/CurrencyField.tsx +2 -2
  27. package/src/widgets/DateField.tsx +2 -2
  28. package/src/widgets/DateTimeField.tsx +2 -2
  29. package/src/widgets/EmailField.tsx +2 -2
  30. package/src/widgets/FileField.tsx +2 -2
  31. package/src/widgets/FormulaField.tsx +14 -12
  32. package/src/widgets/GeolocationField.tsx +4 -3
  33. package/src/widgets/GridField.tsx +3 -3
  34. package/src/widgets/ImageField.tsx +2 -2
  35. package/src/widgets/LocationField.tsx +2 -2
  36. package/src/widgets/LookupField.tsx +8 -8
  37. package/src/widgets/NumberField.tsx +2 -2
  38. package/src/widgets/ObjectField.tsx +2 -2
  39. package/src/widgets/PercentField.tsx +2 -2
  40. package/src/widgets/PhoneField.tsx +2 -2
  41. package/src/widgets/QRCodeField.tsx +2 -2
  42. package/src/widgets/RichTextField.tsx +2 -2
  43. package/src/widgets/SelectField.tsx +12 -8
  44. package/src/widgets/SummaryField.tsx +12 -10
  45. package/src/widgets/TextAreaField.tsx +2 -2
  46. package/src/widgets/TextField.tsx +2 -2
  47. package/src/widgets/TimeField.tsx +2 -2
  48. package/src/widgets/UrlField.tsx +2 -2
  49. package/src/widgets/UserField.tsx +2 -2
  50. package/src/widgets/VectorField.tsx +2 -1
  51. package/src/widgets/useFieldTranslation.ts +17 -0
  52. package/dist/AddressField-B6fPb2Fs.js +0 -3
  53. package/dist/AddressField-Ctsxkj35.cjs +0 -1
  54. package/dist/AutoNumberField-DXIYJfiB.js +0 -3
  55. package/dist/AutoNumberField-f76XU1bR.cjs +0 -1
  56. package/dist/AvatarField-BHpex3Wa.js +0 -3
  57. package/dist/AvatarField-CXOdYavl.cjs +0 -1
  58. package/dist/BooleanField-Bs0urhsx.js +0 -3
  59. package/dist/BooleanField-DeexI_O2.cjs +0 -1
  60. package/dist/CodeField-DDOMH8E8.cjs +0 -1
  61. package/dist/CodeField-tRACqngO.js +0 -3
  62. package/dist/ColorField-Bar6bOJw.js +0 -3
  63. package/dist/ColorField-CM9CxZ9h.cjs +0 -1
  64. package/dist/CurrencyField-CezTX_rA.cjs +0 -1
  65. package/dist/CurrencyField-D4VwXI9k.js +0 -3
  66. package/dist/DateField-DDUllj7w.cjs +0 -1
  67. package/dist/DateField-DjGiK6BH.js +0 -3
  68. package/dist/DateTimeField-DBh-dYdR.js +0 -3
  69. package/dist/DateTimeField-DDqgVN3b.cjs +0 -1
  70. package/dist/EmailField-B-3nY8Pr.cjs +0 -1
  71. package/dist/EmailField-Cp6yJlpH.js +0 -3
  72. package/dist/FileField-C1bcL7tu.cjs +0 -1
  73. package/dist/FileField-DUgt6ai3.js +0 -3
  74. package/dist/FormulaField-BLV1KgkQ.js +0 -3
  75. package/dist/FormulaField-CsbQIZ7S.cjs +0 -1
  76. package/dist/GeolocationField-BbIRH51u.js +0 -3
  77. package/dist/GeolocationField-UFu9VR-x.cjs +0 -1
  78. package/dist/GridField-2m74haE0.cjs +0 -1
  79. package/dist/GridField-O3Jd1NuJ.js +0 -3
  80. package/dist/ImageField-Cna7g6KL.cjs +0 -1
  81. package/dist/ImageField-rc1Nzcvb.js +0 -3
  82. package/dist/LocationField-DpRe9QlH.cjs +0 -1
  83. package/dist/LocationField-QZPPYo0J.js +0 -3
  84. package/dist/LookupField-BX4sK8NG.js +0 -904
  85. package/dist/LookupField-C9rPrHi_.cjs +0 -1
  86. package/dist/MasterDetailField-BXncIT1s.js +0 -3
  87. package/dist/MasterDetailField-TNP2825Z.cjs +0 -1
  88. package/dist/NumberField-DCHTzmDG.js +0 -3
  89. package/dist/NumberField-SqRwbcY8.cjs +0 -1
  90. package/dist/ObjectField-BEUHpn_X.js +0 -3
  91. package/dist/ObjectField-BWZwaFIG.cjs +0 -1
  92. package/dist/PasswordField-BZ_OeQyw.js +0 -3
  93. package/dist/PasswordField-kLp-ZyPm.cjs +0 -1
  94. package/dist/PercentField-B2t2knlc.js +0 -3
  95. package/dist/PercentField-BzhD6tQm.cjs +0 -1
  96. package/dist/PhoneField-CirvuysP.cjs +0 -1
  97. package/dist/PhoneField-CkrCFmcO.js +0 -3
  98. package/dist/QRCodeField-C4OOLCbE.cjs +0 -1
  99. package/dist/QRCodeField-CQoGow8y.js +0 -3
  100. package/dist/RatingField-CZG8SgQ8.cjs +0 -1
  101. package/dist/RatingField-DecDJkou.js +0 -3
  102. package/dist/RichTextField-Bog2i4NN.cjs +0 -1
  103. package/dist/RichTextField-b-8QbNCJ.js +0 -3
  104. package/dist/SelectField-CAa6c0cB.cjs +0 -1
  105. package/dist/SelectField-DMy5IxRc.js +0 -3
  106. package/dist/SignatureField-BvTPE7Dn.js +0 -3
  107. package/dist/SignatureField-jsb-0AYp.cjs +0 -1
  108. package/dist/SliderField-B7WvKFv7.js +0 -3
  109. package/dist/SliderField-DRjEJSxK.cjs +0 -1
  110. package/dist/SummaryField-DA16Edq3.cjs +0 -1
  111. package/dist/SummaryField-DVSVfKmY.js +0 -3
  112. package/dist/TextAreaField--ut87UiB.js +0 -3
  113. package/dist/TextAreaField-Cxp3DFC6.cjs +0 -1
  114. package/dist/TextField-CkEDGbkQ.js +0 -3
  115. package/dist/TextField-aLAzBaLZ.cjs +0 -1
  116. package/dist/TimeField-D0lF0y_I.cjs +0 -1
  117. package/dist/TimeField-DA_S6lQV.js +0 -3
  118. package/dist/UrlField-D0ugmD3f.cjs +0 -1
  119. package/dist/UrlField-DCnXHR1A.js +0 -3
  120. package/dist/UserField-DGlorzyG.cjs +0 -1
  121. package/dist/UserField-V0GmvyGV.js +0 -3
  122. package/dist/VectorField-BSD85ntU.cjs +0 -1
  123. package/dist/VectorField-CVi5yrrc.js +0 -3
  124. package/dist/jsx-runtime-DHCF5ANT.cjs +0 -6
  125. package/dist/jsx-runtime-MxADXuRH.js +0 -189
  126. /package/dist/{fields → packages/fields}/src/__tests__/boolean-checkbox.test.d.ts +0 -0
  127. /package/dist/{fields → packages/fields}/src/__tests__/cell-renderers.test.d.ts +0 -0
  128. /package/dist/{fields → packages/fields}/src/__tests__/datetime-cell.test.d.ts +0 -0
  129. /package/dist/{fields → packages/fields}/src/__tests__/datetime-timezone.test.d.ts +0 -0
  130. /package/dist/{fields → packages/fields}/src/__tests__/extreme-inputs.test.d.ts +0 -0
  131. /package/dist/{fields → packages/fields}/src/__tests__/form-variants.test.d.ts +0 -0
  132. /package/dist/{fields → packages/fields}/src/__tests__/validation-feedback.test.d.ts +0 -0
  133. /package/dist/{fields → packages/fields}/src/complex-widgets.test.d.ts +0 -0
  134. /package/dist/{fields → packages/fields}/src/datetime-widgets.test.d.ts +0 -0
  135. /package/dist/{fields → packages/fields}/src/fields-edge-cases.stories.d.ts +0 -0
  136. /package/dist/{fields → packages/fields}/src/fields.stories.d.ts +0 -0
  137. /package/dist/{fields → packages/fields}/src/index.d.ts +0 -0
  138. /package/dist/{fields → packages/fields}/src/new-widgets.test.d.ts +0 -0
  139. /package/dist/{fields → packages/fields}/src/record-picker-dialog.stories.d.ts +0 -0
  140. /package/dist/{fields → packages/fields}/src/record-picker.test.d.ts +0 -0
  141. /package/dist/{fields → packages/fields}/src/remaining-widgets.test.d.ts +0 -0
  142. /package/dist/{fields → packages/fields}/src/standard-widgets.test.d.ts +0 -0
  143. /package/dist/{fields → packages/fields}/src/text-rich-widgets.test.d.ts +0 -0
  144. /package/dist/{fields → packages/fields}/src/visual-widgets.test.d.ts +0 -0
  145. /package/dist/{fields → packages/fields}/src/widgets/AddressField.d.ts +0 -0
  146. /package/dist/{fields → packages/fields}/src/widgets/AutoNumberField.d.ts +0 -0
  147. /package/dist/{fields → packages/fields}/src/widgets/AvatarField.d.ts +0 -0
  148. /package/dist/{fields → packages/fields}/src/widgets/BooleanField.d.ts +0 -0
  149. /package/dist/{fields → packages/fields}/src/widgets/CodeField.d.ts +0 -0
  150. /package/dist/{fields → packages/fields}/src/widgets/ColorField.d.ts +0 -0
  151. /package/dist/{fields → packages/fields}/src/widgets/CurrencyField.d.ts +0 -0
  152. /package/dist/{fields → packages/fields}/src/widgets/DateField.d.ts +0 -0
  153. /package/dist/{fields → packages/fields}/src/widgets/DateTimeField.d.ts +0 -0
  154. /package/dist/{fields → packages/fields}/src/widgets/EmailField.d.ts +0 -0
  155. /package/dist/{fields → packages/fields}/src/widgets/FileField.d.ts +0 -0
  156. /package/dist/{fields → packages/fields}/src/widgets/FormulaField.d.ts +0 -0
  157. /package/dist/{fields → packages/fields}/src/widgets/GeolocationField.d.ts +0 -0
  158. /package/dist/{fields → packages/fields}/src/widgets/GridField.d.ts +0 -0
  159. /package/dist/{fields → packages/fields}/src/widgets/ImageField.d.ts +0 -0
  160. /package/dist/{fields → packages/fields}/src/widgets/LocationField.d.ts +0 -0
  161. /package/dist/{fields → packages/fields}/src/widgets/LookupField.d.ts +0 -0
  162. /package/dist/{fields → packages/fields}/src/widgets/MasterDetailField.d.ts +0 -0
  163. /package/dist/{fields → packages/fields}/src/widgets/NumberField.d.ts +0 -0
  164. /package/dist/{fields → packages/fields}/src/widgets/ObjectField.d.ts +0 -0
  165. /package/dist/{fields → packages/fields}/src/widgets/PasswordField.d.ts +0 -0
  166. /package/dist/{fields → packages/fields}/src/widgets/PercentField.d.ts +0 -0
  167. /package/dist/{fields → packages/fields}/src/widgets/PhoneField.d.ts +0 -0
  168. /package/dist/{fields → packages/fields}/src/widgets/QRCodeField.d.ts +0 -0
  169. /package/dist/{fields → packages/fields}/src/widgets/RatingField.d.ts +0 -0
  170. /package/dist/{fields → packages/fields}/src/widgets/RecordPickerDialog.d.ts +0 -0
  171. /package/dist/{fields → packages/fields}/src/widgets/RichTextField.d.ts +0 -0
  172. /package/dist/{fields → packages/fields}/src/widgets/SelectField.d.ts +0 -0
  173. /package/dist/{fields → packages/fields}/src/widgets/SignatureField.d.ts +0 -0
  174. /package/dist/{fields → packages/fields}/src/widgets/SliderField.d.ts +0 -0
  175. /package/dist/{fields → packages/fields}/src/widgets/SummaryField.d.ts +0 -0
  176. /package/dist/{fields → packages/fields}/src/widgets/TextAreaField.d.ts +0 -0
  177. /package/dist/{fields → packages/fields}/src/widgets/TextField.d.ts +0 -0
  178. /package/dist/{fields → packages/fields}/src/widgets/TimeField.d.ts +0 -0
  179. /package/dist/{fields → packages/fields}/src/widgets/UrlField.d.ts +0 -0
  180. /package/dist/{fields → packages/fields}/src/widgets/UserField.d.ts +0 -0
  181. /package/dist/{fields → packages/fields}/src/widgets/VectorField.d.ts +0 -0
  182. /package/dist/{fields → packages/fields}/src/widgets/_cell-renderer-bridge.d.ts +0 -0
  183. /package/dist/{fields → packages/fields}/src/widgets/types.d.ts +0 -0
@@ -0,0 +1,5 @@
1
+ export declare const useFieldTranslation: () => {
2
+ t: (key: string, options?: Record<string, unknown>) => string;
3
+ } | {
4
+ t: import('i18next').TFunction<string, undefined>;
5
+ };
@@ -0,0 +1,11 @@
1
+ //#region \0rolldown/runtime.js
2
+ var e = Object.defineProperty, t = (t, n) => {
3
+ let r = {};
4
+ for (var i in t) e(r, i, {
5
+ get: t[i],
6
+ enumerable: !0
7
+ });
8
+ return n || e(r, Symbol.toStringTag, { value: "Module" }), r;
9
+ };
10
+ //#endregion
11
+ export { t };
@@ -0,0 +1 @@
1
+ require(`./chunk-D-qHiVGv.cjs`);var e=(0,require(`@object-ui/i18n`).createSafeTranslation)({"common.selectOption":`Select an option`,"common.select":`Select...`,"common.search":`Search`,"table.selected":`{{count}} selected`},`common.selectOption`);Object.defineProperty(exports,`t`,{enumerable:!0,get:function(){return e}});
@@ -0,0 +1,9 @@
1
+ import { createSafeTranslation as e } from "@object-ui/i18n";
2
+ var t = e({
3
+ "common.selectOption": "Select an option",
4
+ "common.select": "Select...",
5
+ "common.search": "Search",
6
+ "table.selected": "{{count}} selected"
7
+ }, "common.selectOption");
8
+ //#endregion
9
+ export { t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/fields",
3
- "version": "3.1.5",
3
+ "version": "3.3.1",
4
4
  "description": "Field renderers and registry for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,16 +19,19 @@
19
19
  "files": [
20
20
  "dist",
21
21
  "src",
22
- "README.md"
22
+ "README.md",
23
+ "CHANGELOG.md",
24
+ "LICENSE"
23
25
  ],
24
26
  "dependencies": {
25
27
  "clsx": "^2.1.1",
26
- "lucide-react": "^0.577.0",
28
+ "lucide-react": "^1.8.0",
27
29
  "tailwind-merge": "^3.5.0",
28
- "@object-ui/components": "3.1.5",
29
- "@object-ui/core": "3.1.5",
30
- "@object-ui/react": "3.1.5",
31
- "@object-ui/types": "3.1.5"
30
+ "@object-ui/components": "3.3.1",
31
+ "@object-ui/core": "3.3.1",
32
+ "@object-ui/i18n": "3.3.1",
33
+ "@object-ui/react": "3.3.1",
34
+ "@object-ui/types": "3.3.1"
32
35
  },
33
36
  "peerDependencies": {
34
37
  "react": "^18.0.0 || ^19.0.0",
@@ -38,13 +41,36 @@
38
41
  "@types/react": "19.2.14",
39
42
  "@types/react-dom": "19.2.3",
40
43
  "@vitejs/plugin-react": "^6.0.1",
41
- "typescript": "^5.9.3",
42
- "vite": "^8.0.1",
44
+ "typescript": "^6.0.3",
45
+ "vite": "^8.0.9",
43
46
  "vite-plugin-dts": "^4.5.4"
44
47
  },
45
48
  "publishConfig": {
46
49
  "access": "public"
47
50
  },
51
+ "keywords": [
52
+ "objectui",
53
+ "sdui",
54
+ "schema-driven-ui",
55
+ "react",
56
+ "tailwind",
57
+ "shadcn",
58
+ "objectstack",
59
+ "fields",
60
+ "form-fields",
61
+ "renderer",
62
+ "widgets"
63
+ ],
64
+ "author": "ObjectStack Team <team@objectstack.ai>",
65
+ "repository": {
66
+ "type": "git",
67
+ "url": "git+https://github.com/objectstack-ai/objectui.git",
68
+ "directory": "packages/fields"
69
+ },
70
+ "bugs": {
71
+ "url": "https://github.com/objectstack-ai/objectui/issues"
72
+ },
73
+ "homepage": "https://www.objectui.org/docs/fields",
48
74
  "scripts": {
49
75
  "build": "tsc && vite build",
50
76
  "clean": "rm -rf dist",
@@ -106,17 +106,17 @@ describe('TextCellRenderer', () => {
106
106
 
107
107
  it('should render dash for null', () => {
108
108
  render(<TextCellRenderer value={null} field={{ name: 'title', type: 'text' } as any} />);
109
- expect(screen.getByText('-')).toBeInTheDocument();
109
+ expect(screen.getByText('—')).toBeInTheDocument();
110
110
  });
111
111
 
112
112
  it('should render dash for undefined', () => {
113
113
  render(<TextCellRenderer value={undefined} field={{ name: 'title', type: 'text' } as any} />);
114
- expect(screen.getByText('-')).toBeInTheDocument();
114
+ expect(screen.getByText('—')).toBeInTheDocument();
115
115
  });
116
116
 
117
117
  it('should render dash for empty string', () => {
118
118
  render(<TextCellRenderer value="" field={{ name: 'title', type: 'text' } as any} />);
119
- expect(screen.getByText('-')).toBeInTheDocument();
119
+ expect(screen.getByText('—')).toBeInTheDocument();
120
120
  });
121
121
 
122
122
  it('should render "0" for numeric zero (not dash)', () => {
@@ -149,8 +149,12 @@ describe('SelectCellRenderer', () => {
149
149
  expect(container.querySelector('[class*="bg-blue-100"]')).toBeInTheDocument();
150
150
  });
151
151
 
152
- it('should use muted style when no color configured', () => {
153
- const { container } = render(
152
+ it('should fall back to a deterministic palette color when no color configured and no semantic match', () => {
153
+ // Hash-based fallback: distinct values get distinct colors so badges
154
+ // never all collapse to muted gray. The exact color is deterministic
155
+ // (stable across renders) but value-derived; we just assert one of the
156
+ // palette tones is applied.
157
+ const { container, rerender } = render(
154
158
  <SelectCellRenderer
155
159
  value="Unknown"
156
160
  field={{
@@ -161,7 +165,24 @@ describe('SelectCellRenderer', () => {
161
165
  />
162
166
  );
163
167
  expect(screen.getByText('Unknown')).toBeInTheDocument();
164
- expect(container.querySelector('[class*="bg-muted"]')).toBeInTheDocument();
168
+ const palette = ['blue', 'green', 'purple', 'orange', 'pink', 'indigo', 'yellow', 'red'];
169
+ const matched = palette.some((c) => container.querySelector(`[class*="bg-${c}-100"]`));
170
+ expect(matched).toBe(true);
171
+
172
+ // Same value → same color across renders (deterministic).
173
+ const firstClass = container.querySelector('[data-slot="badge"], [class*="bg-"][class*="-100"]')?.className;
174
+ rerender(
175
+ <SelectCellRenderer
176
+ value="Unknown"
177
+ field={{
178
+ name: 'status',
179
+ type: 'select',
180
+ options: [{ value: 'Unknown', label: 'Unknown' }],
181
+ } as any}
182
+ />
183
+ );
184
+ const secondClass = container.querySelector('[data-slot="badge"], [class*="bg-"][class*="-100"]')?.className;
185
+ expect(secondClass).toBe(firstClass);
165
186
  });
166
187
 
167
188
  it('should auto-detect priority semantic colors (Critical → red)', () => {
@@ -302,7 +323,7 @@ describe('SelectCellRenderer', () => {
302
323
  field={{ name: 'status', type: 'select' } as any}
303
324
  />
304
325
  );
305
- expect(screen.getByText('-')).toBeInTheDocument();
326
+ expect(screen.getByText('—')).toBeInTheDocument();
306
327
  });
307
328
 
308
329
  it('should render multiple badges for array values', () => {
@@ -413,7 +434,7 @@ describe('DateCellRenderer', () => {
413
434
  field={{ name: 'due_date', type: 'date' } as any}
414
435
  />
415
436
  );
416
- expect(container.textContent).toBe('-');
437
+ expect(container.textContent).toBe('—');
417
438
  });
418
439
 
419
440
  it('should use explicit format when provided', () => {
@@ -590,7 +611,7 @@ describe('EmailCellRenderer', () => {
590
611
 
591
612
  it('should render dash for empty value', () => {
592
613
  render(<EmailCellRenderer value={null} field={{ name: 'email', type: 'email' } as any} />);
593
- expect(screen.getByText('-')).toBeInTheDocument();
614
+ expect(screen.getByText('—')).toBeInTheDocument();
594
615
  });
595
616
  });
596
617
 
@@ -613,7 +634,7 @@ describe('PhoneCellRenderer', () => {
613
634
 
614
635
  it('should render dash for empty value', () => {
615
636
  render(<PhoneCellRenderer value={null} field={{ name: 'phone', type: 'phone' } as any} />);
616
- expect(screen.getByText('-')).toBeInTheDocument();
637
+ expect(screen.getByText('—')).toBeInTheDocument();
617
638
  });
618
639
  });
619
640
 
@@ -632,15 +653,15 @@ describe('formatRelativeDate', () => {
632
653
  });
633
654
 
634
655
  it('should return "-" for null', () => {
635
- expect(formatRelativeDate(null as any)).toBe('-');
656
+ expect(formatRelativeDate(null as any)).toBe('—');
636
657
  });
637
658
 
638
659
  it('should return "-" for empty string', () => {
639
- expect(formatRelativeDate('')).toBe('-');
660
+ expect(formatRelativeDate('')).toBe('—');
640
661
  });
641
662
 
642
663
  it('should return "-" for invalid date', () => {
643
- expect(formatRelativeDate('not-a-date')).toBe('-');
664
+ expect(formatRelativeDate('not-a-date')).toBe('—');
644
665
  });
645
666
 
646
667
  it('should return "Today" for today', () => {
@@ -694,11 +715,11 @@ describe('formatRelativeDate', () => {
694
715
  // =========================================================================
695
716
  describe('formatDate', () => {
696
717
  it('should return "-" for null', () => {
697
- expect(formatDate(null as any)).toBe('-');
718
+ expect(formatDate(null as any)).toBe('—');
698
719
  });
699
720
 
700
721
  it('should return "-" for invalid date', () => {
701
- expect(formatDate('invalid')).toBe('-');
722
+ expect(formatDate('invalid')).toBe('—');
702
723
  });
703
724
 
704
725
  it('should format as relative when style is "relative"', () => {
@@ -731,7 +752,7 @@ describe('LookupCellRenderer', () => {
731
752
  field={{ name: 'customer', type: 'lookup' } as any}
732
753
  />
733
754
  );
734
- expect(screen.getByText('-')).toBeInTheDocument();
755
+ expect(screen.getByText('—')).toBeInTheDocument();
735
756
  });
736
757
 
737
758
  it('should render dash for empty string', () => {
@@ -741,7 +762,7 @@ describe('LookupCellRenderer', () => {
741
762
  field={{ name: 'customer', type: 'lookup' } as any}
742
763
  />
743
764
  );
744
- expect(screen.getByText('-')).toBeInTheDocument();
765
+ expect(screen.getByText('—')).toBeInTheDocument();
745
766
  });
746
767
 
747
768
  it('should resolve primitive ID to label via field options', () => {
@@ -858,7 +879,7 @@ describe('UserCellRenderer', () => {
858
879
  field={{ name: 'owner', type: 'user' } as any}
859
880
  />
860
881
  );
861
- expect(screen.getByText('-')).toBeInTheDocument();
882
+ expect(screen.getByText('—')).toBeInTheDocument();
862
883
  });
863
884
 
864
885
  it('should render text for primitive user ID (number)', () => {
@@ -31,7 +31,7 @@ describe('DateTimeCellRenderer', () => {
31
31
  field={{ name: 'created_at', type: 'datetime' } as any}
32
32
  />
33
33
  );
34
- expect(container.textContent).toBe('-');
34
+ expect(container.textContent).toBe('—');
35
35
  });
36
36
 
37
37
  it('should show dash for invalid date', () => {
@@ -41,6 +41,6 @@ describe('DateTimeCellRenderer', () => {
41
41
  field={{ name: 'created_at', type: 'datetime' } as any}
42
42
  />
43
43
  );
44
- expect(container.textContent).toBe('-');
44
+ expect(container.textContent).toBe('—');
45
45
  });
46
46
  });
@@ -347,7 +347,7 @@ describe('P3.2 Date/Time Timezone & Locale Formatting', () => {
347
347
  readonly={true}
348
348
  />
349
349
  );
350
- expect(container.textContent).toBe('-');
350
+ expect(container.textContent).toBe('—');
351
351
  });
352
352
 
353
353
  it('DateField readonly shows "-" for null', () => {
@@ -359,7 +359,7 @@ describe('P3.2 Date/Time Timezone & Locale Formatting', () => {
359
359
  readonly={true}
360
360
  />
361
361
  );
362
- expect(container.textContent).toBe('-');
362
+ expect(container.textContent).toBe('—');
363
363
  });
364
364
 
365
365
  it('DateField readonly shows "-" for undefined', () => {
@@ -371,7 +371,7 @@ describe('P3.2 Date/Time Timezone & Locale Formatting', () => {
371
371
  readonly={true}
372
372
  />
373
373
  );
374
- expect(container.textContent).toBe('-');
374
+ expect(container.textContent).toBe('—');
375
375
  });
376
376
 
377
377
  it('TimeField readonly shows "-" for empty string', () => {
@@ -383,7 +383,7 @@ describe('P3.2 Date/Time Timezone & Locale Formatting', () => {
383
383
  readonly={true}
384
384
  />
385
385
  );
386
- expect(container.textContent).toBe('-');
386
+ expect(container.textContent).toBe('—');
387
387
  });
388
388
 
389
389
  it('TimeField readonly shows "-" for null', () => {
@@ -395,7 +395,7 @@ describe('P3.2 Date/Time Timezone & Locale Formatting', () => {
395
395
  readonly={true}
396
396
  />
397
397
  );
398
- expect(container.textContent).toBe('-');
398
+ expect(container.textContent).toBe('—');
399
399
  });
400
400
 
401
401
  it('TimeField readonly shows "-" for undefined', () => {
@@ -407,7 +407,7 @@ describe('P3.2 Date/Time Timezone & Locale Formatting', () => {
407
407
  readonly={true}
408
408
  />
409
409
  );
410
- expect(container.textContent).toBe('-');
410
+ expect(container.textContent).toBe('—');
411
411
  });
412
412
 
413
413
  it('DateTimeField readonly shows "-" for empty string', () => {
@@ -419,7 +419,7 @@ describe('P3.2 Date/Time Timezone & Locale Formatting', () => {
419
419
  readonly={true}
420
420
  />
421
421
  );
422
- expect(container.textContent).toBe('-');
422
+ expect(container.textContent).toBe('—');
423
423
  });
424
424
 
425
425
  it('DateTimeField readonly shows "-" for null', () => {
@@ -431,7 +431,7 @@ describe('P3.2 Date/Time Timezone & Locale Formatting', () => {
431
431
  readonly={true}
432
432
  />
433
433
  );
434
- expect(container.textContent).toBe('-');
434
+ expect(container.textContent).toBe('—');
435
435
  });
436
436
 
437
437
  it('DateTimeField readonly shows "-" for undefined', () => {
@@ -443,7 +443,7 @@ describe('P3.2 Date/Time Timezone & Locale Formatting', () => {
443
443
  readonly={true}
444
444
  />
445
445
  );
446
- expect(container.textContent).toBe('-');
446
+ expect(container.textContent).toBe('—');
447
447
  });
448
448
 
449
449
  it('DateField editable uses empty string for null value', () => {
@@ -191,7 +191,7 @@ describe('P3.2 Validation Feedback', () => {
191
191
  readonly={true}
192
192
  />
193
193
  );
194
- expect(container.textContent).toBe('-');
194
+ expect(container.textContent).toBe('—');
195
195
  });
196
196
 
197
197
  it('NumberField shows dash for null value in readonly', () => {
@@ -203,7 +203,7 @@ describe('P3.2 Validation Feedback', () => {
203
203
  readonly={true}
204
204
  />
205
205
  );
206
- expect(container.textContent).toBe('-');
206
+ expect(container.textContent).toBe('—');
207
207
  });
208
208
 
209
209
  it('EmailField shows dash for empty value in readonly', () => {
@@ -215,7 +215,7 @@ describe('P3.2 Validation Feedback', () => {
215
215
  readonly={true}
216
216
  />
217
217
  );
218
- expect(container.textContent).toBe('-');
218
+ expect(container.textContent).toBe('—');
219
219
  });
220
220
 
221
221
  it('UrlField shows dash for empty value in readonly', () => {
@@ -227,7 +227,7 @@ describe('P3.2 Validation Feedback', () => {
227
227
  readonly={true}
228
228
  />
229
229
  );
230
- expect(container.textContent).toBe('-');
230
+ expect(container.textContent).toBe('—');
231
231
  });
232
232
 
233
233
  it('PhoneField shows dash for empty value in readonly', () => {
@@ -239,7 +239,7 @@ describe('P3.2 Validation Feedback', () => {
239
239
  readonly={true}
240
240
  />
241
241
  );
242
- expect(container.textContent).toBe('-');
242
+ expect(container.textContent).toBe('—');
243
243
  });
244
244
 
245
245
  it('TextAreaField shows dash for empty value in readonly', () => {
@@ -251,7 +251,7 @@ describe('P3.2 Validation Feedback', () => {
251
251
  readonly={true}
252
252
  />
253
253
  );
254
- expect(container.textContent).toBe('-');
254
+ expect(container.textContent).toBe('—');
255
255
  });
256
256
 
257
257
  it('BooleanField shows Yes/No in readonly', () => {
@@ -285,7 +285,7 @@ describe('P3.2 Validation Feedback', () => {
285
285
  readonly={true}
286
286
  />
287
287
  );
288
- expect(container.textContent).toBe('-');
288
+ expect(container.textContent).toBe('—');
289
289
  });
290
290
 
291
291
  it('CurrencyField shows dash for null value in readonly', () => {
@@ -297,7 +297,7 @@ describe('P3.2 Validation Feedback', () => {
297
297
  readonly={true}
298
298
  />
299
299
  );
300
- expect(container.textContent).toBe('-');
300
+ expect(container.textContent).toBe('—');
301
301
  });
302
302
  });
303
303
 
@@ -42,7 +42,7 @@ describe('Date/Time Widgets', () => {
42
42
 
43
43
  it('renders dash for empty readonly value', () => {
44
44
  render(<DateField {...baseProps} readonly value={''} />);
45
- expect(screen.getByText('-')).toBeInTheDocument();
45
+ expect(screen.getByText('—')).toBeInTheDocument();
46
46
  });
47
47
  });
48
48
 
package/src/index.tsx CHANGED
@@ -9,7 +9,7 @@
9
9
  import React from 'react';
10
10
  import type { FieldMetadata, SelectOptionMetadata } from '@object-ui/types';
11
11
  import { ComponentRegistry } from '@object-ui/core';
12
- import { Badge, Avatar, AvatarFallback, Button, Checkbox } from '@object-ui/components';
12
+ import { Badge, Avatar, AvatarFallback, Button, Checkbox, EmptyValue } from '@object-ui/components';
13
13
  import { Check, X, Copy, Phone as PhoneIcon } from 'lucide-react';
14
14
 
15
15
  import { TextField } from './widgets/TextField';
@@ -154,9 +154,9 @@ export function humanizeLabel(value: string): string {
154
154
  * Format date as relative time (e.g., "2 days ago", "Today", "Overdue 3d")
155
155
  */
156
156
  export function formatRelativeDate(value: string | Date): string {
157
- if (!value) return '-';
157
+ if (!value) return '—';
158
158
  const date = typeof value === 'string' ? new Date(value) : value;
159
- if (isNaN(date.getTime())) return '-';
159
+ if (isNaN(date.getTime())) return '—';
160
160
 
161
161
  const now = new Date();
162
162
  const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
@@ -180,9 +180,9 @@ export function formatRelativeDate(value: string | Date): string {
180
180
  * Format date value
181
181
  */
182
182
  export function formatDate(value: string | Date, style?: string): string {
183
- if (!value) return '-';
183
+ if (!value) return '—';
184
184
  const date = typeof value === 'string' ? new Date(value) : value;
185
- if (isNaN(date.getTime())) return '-';
185
+ if (isNaN(date.getTime())) return '—';
186
186
 
187
187
  if (style === 'short') {
188
188
  // Compact format for mobile: "Jan 15, '24"
@@ -208,9 +208,9 @@ export function formatDate(value: string | Date, style?: string): string {
208
208
  * Format datetime value
209
209
  */
210
210
  export function formatDateTime(value: string | Date): string {
211
- if (!value) return '-';
211
+ if (!value) return '—';
212
212
  const date = typeof value === 'string' ? new Date(value) : value;
213
- if (isNaN(date.getTime())) return '-';
213
+ if (isNaN(date.getTime())) return '—';
214
214
 
215
215
  return date.toLocaleDateString(undefined, {
216
216
  year: 'numeric',
@@ -226,14 +226,15 @@ export function formatDateTime(value: string | Date): string {
226
226
  */
227
227
  export function TextCellRenderer({ value }: CellRendererProps): React.ReactElement {
228
228
  const safe = coerceToSafeValue(value);
229
- return <span className="truncate">{(safe != null && safe !== '') ? String(safe) : '-'}</span>;
229
+ if (safe == null || safe === '') return <EmptyValue />;
230
+ return <span className="truncate">{String(safe)}</span>;
230
231
  }
231
232
 
232
233
  /**
233
234
  * Number field cell renderer
234
235
  */
235
236
  export function NumberCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
236
- if (value == null) return <span className="text-muted-foreground">-</span>;
237
+ if (value == null) return <EmptyValue />;
237
238
 
238
239
  const safe = coerceToSafeValue(value);
239
240
  const numField = field as any;
@@ -250,7 +251,7 @@ export function NumberCellRenderer({ value, field }: CellRendererProps): React.R
250
251
  * Currency field cell renderer
251
252
  */
252
253
  export function CurrencyCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
253
- if (value == null) return <span className="text-muted-foreground">-</span>;
254
+ if (value == null) return <EmptyValue />;
254
255
 
255
256
  const safe = coerceToSafeValue(value);
256
257
  const currencyField = field as any;
@@ -268,7 +269,7 @@ const WHOLE_PERCENT_FIELD_PATTERN = /progress|completion/;
268
269
  * Percent field cell renderer with mini progress bar
269
270
  */
270
271
  export function PercentCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
271
- if (value == null) return <span className="text-muted-foreground">-</span>;
272
+ if (value == null) return <EmptyValue />;
272
273
 
273
274
  const safe = coerceToSafeValue(value);
274
275
  const percentField = field as any;
@@ -317,7 +318,7 @@ const STATUS_FIELD_NAMES = new Set([
317
318
  */
318
319
  export function BooleanCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
319
320
  if (value == null) {
320
- return <span className="text-muted-foreground/50 text-xs italic flex items-center justify-center">—</span>;
321
+ return <span className="flex items-center justify-center"><EmptyValue /></span>;
321
322
  }
322
323
 
323
324
  // Semantic rendering for completion fields (green circle indicator)
@@ -360,7 +361,7 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React.
360
361
  * Date field cell renderer
361
362
  */
362
363
  export function DateCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
363
- if (!value) return <span className="text-muted-foreground">-</span>;
364
+ if (!value) return <EmptyValue />;
364
365
  const safe = coerceToSafeValue(value);
365
366
  const dateField = field as any;
366
367
  const style = dateField.format || 'relative';
@@ -388,10 +389,10 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
388
389
  * DateTime field cell renderer (Airtable-style with date and time visually separated)
389
390
  */
390
391
  export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactElement {
391
- if (!value) return <span className="text-muted-foreground">-</span>;
392
+ if (!value) return <EmptyValue />;
392
393
  const safe = coerceToSafeValue(value);
393
394
  const date = typeof safe === 'string' ? new Date(safe) : safe;
394
- if (!(date instanceof Date) || isNaN(date.getTime())) return <span className="text-muted-foreground">-</span>;
395
+ if (!(date instanceof Date) || isNaN(date.getTime())) return <EmptyValue />;
395
396
 
396
397
  const datePart = date.toLocaleDateString(undefined, {
397
398
  month: 'numeric',
@@ -463,10 +464,34 @@ const BADGE_COLOR_MAP: Record<string, string> = {
463
464
  pink: 'bg-pink-100 text-pink-800 border-pink-300',
464
465
  };
465
466
 
466
- function getBadgeColorClasses(color?: string, val?: string): string {
467
- const resolvedColor = color
468
- || (val ? SEMANTIC_COLOR_MAP[String(val).toLowerCase().replace(/[\s-]/g, '_')] : undefined);
469
- return BADGE_COLOR_MAP[resolvedColor || ''] || 'bg-muted text-muted-foreground border-border';
467
+ // Color palette used by the deterministic fallback when no schema/semantic
468
+ // color matches. Excludes 'gray' to ensure visual contrast between values.
469
+ const BADGE_FALLBACK_PALETTE: readonly string[] = [
470
+ 'blue', 'green', 'purple', 'orange', 'pink', 'indigo', 'yellow', 'red',
471
+ ];
472
+
473
+ /**
474
+ * Stable string hash (djb2-ish) → palette index.
475
+ * Same value always yields the same color across renders/sessions.
476
+ */
477
+ function hashToColor(value: string): string {
478
+ let h = 5381;
479
+ for (let i = 0; i < value.length; i++) {
480
+ h = ((h << 5) + h) ^ value.charCodeAt(i);
481
+ }
482
+ const idx = Math.abs(h) % BADGE_FALLBACK_PALETTE.length;
483
+ return BADGE_FALLBACK_PALETTE[idx];
484
+ }
485
+
486
+ function getBadgeColorClasses(color?: string, val?: unknown): string {
487
+ if (color && BADGE_COLOR_MAP[color]) return BADGE_COLOR_MAP[color];
488
+ if (val == null || val === '') return 'bg-muted text-muted-foreground border-border';
489
+ const key = String(val).toLowerCase().replace(/[\s-]/g, '_');
490
+ const semantic = SEMANTIC_COLOR_MAP[key];
491
+ if (semantic && BADGE_COLOR_MAP[semantic]) return BADGE_COLOR_MAP[semantic];
492
+ // Deterministic fallback so distinct values are visually distinguishable
493
+ // even when metadata declares no colors.
494
+ return BADGE_COLOR_MAP[hashToColor(key)];
470
495
  }
471
496
 
472
497
  /**
@@ -476,7 +501,7 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
476
501
  const selectField = field as any;
477
502
  const options: SelectOptionMetadata[] = selectField.options || [];
478
503
 
479
- if (!value) return <span>-</span>;
504
+ if (value == null || value === '') return <EmptyValue />;
480
505
 
481
506
  // Handle multiple values
482
507
  if (Array.isArray(value)) {
@@ -520,7 +545,7 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
520
545
  * Email field cell renderer
521
546
  */
522
547
  export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElement {
523
- if (!value) return <span>-</span>;
548
+ if (!value) return <EmptyValue />;
524
549
 
525
550
  const safe = String(coerceToSafeValue(value) ?? '');
526
551
  const [copied, setCopied] = React.useState(false);
@@ -568,7 +593,7 @@ export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElem
568
593
  * URL field cell renderer
569
594
  */
570
595
  export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElement {
571
- if (!value) return <span>-</span>;
596
+ if (!value) return <EmptyValue />;
572
597
 
573
598
  const safe = String(coerceToSafeValue(value) ?? '');
574
599
  return (
@@ -593,7 +618,7 @@ export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElemen
593
618
  * Phone field cell renderer
594
619
  */
595
620
  export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElement {
596
- if (!value) return <span>-</span>;
621
+ if (!value) return <EmptyValue />;
597
622
 
598
623
  const safe = String(coerceToSafeValue(value) ?? '');
599
624
  const [copied, setCopied] = React.useState(false);
@@ -637,7 +662,7 @@ export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElem
637
662
  * File field cell renderer
638
663
  */
639
664
  export function FileCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
640
- if (!value) return <span>-</span>;
665
+ if (!value) return <EmptyValue />;
641
666
 
642
667
  const fileField = field as any;
643
668
  const isMultiple = fileField.multiple;
@@ -659,7 +684,7 @@ export function FileCellRenderer({ value, field }: CellRendererProps): React.Rea
659
684
  * Image field cell renderer (with thumbnails)
660
685
  */
661
686
  export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElement {
662
- if (!value) return <span>-</span>;
687
+ if (!value) return <EmptyValue />;
663
688
 
664
689
  if (Array.isArray(value)) {
665
690
  return (
@@ -694,7 +719,7 @@ export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElem
694
719
  * Lookup/Master-Detail field cell renderer
695
720
  */
696
721
  export function LookupCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
697
- if (value == null || value === '') return <span>-</span>;
722
+ if (value == null || value === '') return <EmptyValue />;
698
723
 
699
724
  const options: Array<{ value: unknown; label: string }> =
700
725
  (field as { options?: Array<{ value: unknown; label: string }> }).options || [];
@@ -745,9 +770,10 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
745
770
  */
746
771
  export function FormulaCellRenderer({ value }: CellRendererProps): React.ReactElement {
747
772
  const safe = coerceToSafeValue(value);
773
+ if (safe == null || safe === '') return <EmptyValue />;
748
774
  return (
749
775
  <span className="text-gray-700 font-mono text-sm">
750
- {safe != null ? String(safe) : '-'}
776
+ {String(safe)}
751
777
  </span>
752
778
  );
753
779
  }
@@ -756,7 +782,7 @@ export function FormulaCellRenderer({ value }: CellRendererProps): React.ReactEl
756
782
  * User/Owner field cell renderer (with avatars)
757
783
  */
758
784
  export function UserCellRenderer({ value }: CellRendererProps): React.ReactElement {
759
- if (!value) return <span>-</span>;
785
+ if (!value) return <EmptyValue />;
760
786
 
761
787
  // Primitive value: just display the ID/username as text
762
788
  if (typeof value !== 'object') {