@object-ui/fields 17.2.0 → 17.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CHANGELOG.md +592 -0
  2. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  3. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  4. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  5. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  6. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  7. package/dist/index.cjs +5 -5
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.js +2881 -2566
  10. package/dist/widgets/AddressField.d.ts +1 -1
  11. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  12. package/dist/widgets/AvatarField.d.ts +1 -1
  13. package/dist/widgets/CharacterCount.d.ts +56 -0
  14. package/dist/widgets/CheckboxesField.d.ts +1 -1
  15. package/dist/widgets/CodeField.d.ts +1 -1
  16. package/dist/widgets/ColorField.d.ts +1 -1
  17. package/dist/widgets/CurrencyField.d.ts +1 -1
  18. package/dist/widgets/EmailField.d.ts +1 -1
  19. package/dist/widgets/FileField.d.ts +1 -1
  20. package/dist/widgets/FilterConditionField.d.ts +1 -1
  21. package/dist/widgets/FullscreenFieldEditor.d.ts +155 -0
  22. package/dist/widgets/GeolocationField.d.ts +1 -1
  23. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  24. package/dist/widgets/ImageField.d.ts +1 -1
  25. package/dist/widgets/LocationField.d.ts +1 -1
  26. package/dist/widgets/LookupField.d.ts +1 -1
  27. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +8 -0
  28. package/dist/widgets/MultiSelectField.d.ts +1 -1
  29. package/dist/widgets/ObjectField.d.ts +1 -1
  30. package/dist/widgets/ObjectRefField.d.ts +1 -1
  31. package/dist/widgets/OptionsEmptyState.d.ts +45 -0
  32. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  33. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  34. package/dist/widgets/PercentField.d.ts +1 -1
  35. package/dist/widgets/PhoneField.d.ts +1 -1
  36. package/dist/widgets/QRCodeField.d.ts +1 -1
  37. package/dist/widgets/RadioField.d.ts +1 -1
  38. package/dist/widgets/RatingField.d.ts +1 -1
  39. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  40. package/dist/widgets/RecordPickerDialog.d.ts +17 -1
  41. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +8 -0
  42. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +8 -0
  43. package/dist/widgets/RichTextField.d.ts +19 -1
  44. package/dist/widgets/TagsField.d.ts +1 -1
  45. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  46. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  47. package/dist/widgets/TextAreaField.d.ts +44 -5
  48. package/dist/widgets/UrlField.d.ts +1 -1
  49. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  50. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  51. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  52. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  53. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  54. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  55. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  56. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  57. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  58. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  59. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  60. package/dist/widgets/toDomProps.d.ts +91 -0
  61. package/dist/widgets/types.d.ts +128 -33
  62. package/dist/withFieldCarrier.d.ts +56 -0
  63. package/package.json +13 -13
  64. package/src/__tests__/cell-truncation.test.tsx +96 -0
  65. package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
  66. package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
  67. package/src/__tests__/validation-feedback.test.tsx +22 -13
  68. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
  69. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
  70. package/src/__tests__/widget-dom-leak-e2e.test.tsx +598 -0
  71. package/src/__tests__/widget-props-contract.test.tsx +22 -9
  72. package/src/index.tsx +74 -26
  73. package/src/widgets/AddressField.tsx +31 -12
  74. package/src/widgets/AddressField.uniqueIds.test.tsx +92 -0
  75. package/src/widgets/AvatarField.tsx +9 -2
  76. package/src/widgets/BooleanField.tsx +3 -3
  77. package/src/widgets/CharacterCount.tsx +263 -0
  78. package/src/widgets/CheckboxesField.tsx +31 -14
  79. package/src/widgets/CodeField.tsx +9 -2
  80. package/src/widgets/ColorField.tsx +11 -2
  81. package/src/widgets/CurrencyField.tsx +5 -4
  82. package/src/widgets/DateField.tsx +2 -2
  83. package/src/widgets/DateTimeField.tsx +2 -2
  84. package/src/widgets/EmailField.tsx +5 -5
  85. package/src/widgets/FileField.tsx +13 -3
  86. package/src/widgets/FilterConditionField.tsx +11 -0
  87. package/src/widgets/FormulaField.tsx +1 -1
  88. package/src/widgets/FullscreenFieldEditor.tsx +271 -0
  89. package/src/widgets/GeolocationField.tsx +21 -6
  90. package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
  91. package/src/widgets/GridField.tsx +1 -1
  92. package/src/widgets/ImageField.tsx +9 -2
  93. package/src/widgets/LocationField.tsx +9 -2
  94. package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
  95. package/src/widgets/LookupField.tsx +41 -6
  96. package/src/widgets/MasterDetailField.tsx +1 -1
  97. package/src/widgets/MultiSelectField.tsx +30 -14
  98. package/src/widgets/NumberField.tsx +3 -3
  99. package/src/widgets/ObjectField.tsx +17 -8
  100. package/src/widgets/ObjectRefField.tsx +10 -0
  101. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
  102. package/src/widgets/OptionsEmptyState.test.tsx +169 -0
  103. package/src/widgets/OptionsEmptyState.tsx +83 -0
  104. package/src/widgets/PasswordField.tsx +3 -3
  105. package/src/widgets/PeoplePicker.tsx +7 -1
  106. package/src/widgets/PercentField.tsx +5 -4
  107. package/src/widgets/PhoneField.tsx +5 -5
  108. package/src/widgets/QRCodeField.tsx +9 -2
  109. package/src/widgets/RadioField.tsx +24 -14
  110. package/src/widgets/RatingField.tsx +18 -3
  111. package/src/widgets/RecipientPickerField.tsx +18 -0
  112. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
  113. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
  114. package/src/widgets/RecordPickerDialog.tsx +173 -8
  115. package/src/widgets/RichTextField.tsx +151 -24
  116. package/src/widgets/SelectField.tsx +48 -17
  117. package/src/widgets/SliderField.tsx +1 -1
  118. package/src/widgets/SummaryField.tsx +1 -1
  119. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
  120. package/src/widgets/TagsField.placeholder.test.tsx +95 -0
  121. package/src/widgets/TagsField.tsx +23 -2
  122. package/src/widgets/TextAreaField.tsx +171 -93
  123. package/src/widgets/TextField.tsx +3 -3
  124. package/src/widgets/TimeField.tsx +2 -2
  125. package/src/widgets/UrlField.tsx +5 -5
  126. package/src/widgets/UserField.tsx +1 -1
  127. package/src/widgets/VectorField.tsx +1 -1
  128. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
  129. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
  130. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
  131. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
  132. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
  133. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
  134. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
  135. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
  136. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
  137. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
  138. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
  139. package/src/widgets/toDomProps.ts +175 -0
  140. package/src/widgets/types.ts +135 -35
  141. package/src/widgets/useFieldTranslation.ts +60 -0
  142. package/src/withFieldCarrier.tsx +75 -0
package/src/index.tsx CHANGED
@@ -14,6 +14,7 @@ import { Badge, Avatar, AvatarImage, AvatarFallback, Button, Checkbox, EmptyValu
14
14
  import { Check, X, Copy, Phone as PhoneIcon, MapPin } from 'lucide-react';
15
15
  import { useObjectTranslation } from '@object-ui/react';
16
16
  import { SchemaRendererContext as _SchemaRendererContext } from '@object-ui/react';
17
+ import { withFieldCarrier } from './withFieldCarrier';
17
18
 
18
19
  // Module-level cache so multiple renderers fetching the same lookup ID
19
20
  // only trigger one network call. Keyed by `${objectName}:${id}`.
@@ -589,13 +590,42 @@ export function formatDateTime(value: string | Date | number): string {
589
590
  });
590
591
  }
591
592
 
593
+ /**
594
+ * Single-line cell value with a working ellipsis and a full-text fallback.
595
+ *
596
+ * `truncate` on a bare inline `<span>` never clips — an inline box has no
597
+ * width box for `overflow:hidden` / `text-overflow:ellipsis` to act on, so
598
+ * the value renders at full content width and the tail is silently cut by
599
+ * whatever ancestor happens to clip (the detail card edge), with no ellipsis
600
+ * (objectui#3466; same mechanism as the JSON cell in objectui#2578).
601
+ * Block-level + `max-w-full` gives the span its parent's width to truncate
602
+ * against, and as a flex item its `overflow:hidden` zeroes the automatic
603
+ * min-size so it can shrink below the text width. The `title` keeps the full
604
+ * text reachable on hover — host rows (detail sections) put their inline-edit
605
+ * hint in *their* `title`, so the value's full text must live on the value
606
+ * element itself, where it takes precedence under the cursor.
607
+ */
608
+ function TruncatedText({
609
+ text,
610
+ className,
611
+ }: {
612
+ text: string;
613
+ className?: string;
614
+ }): React.ReactElement {
615
+ return (
616
+ <span className={cn('block max-w-full truncate', className)} title={text}>
617
+ {text}
618
+ </span>
619
+ );
620
+ }
621
+
592
622
  /**
593
623
  * Text field cell renderer
594
624
  */
595
625
  export function TextCellRenderer({ value }: CellRendererProps): React.ReactElement {
596
626
  const safe = coerceToSafeValue(value);
597
627
  if (safe == null || safe === '') return <EmptyValue />;
598
- return <span className="truncate">{String(safe)}</span>;
628
+ return <TruncatedText text={String(safe)} />;
599
629
  }
600
630
 
601
631
  /**
@@ -1089,10 +1119,13 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
1089
1119
  || SEMANTIC_COLOR_MAP[String(val).toLowerCase().replace(/[\s-]/g, '_')]
1090
1120
  || hashToColor(String(val).toLowerCase().replace(/[\s-]/g, '_'));
1091
1121
  const dotClass = DOT_COLOR_MAP[colorName] || DOT_COLOR_MAP.gray;
1122
+ // max-w-full bounds the (otherwise content-sized) inline-flex box so the
1123
+ // inner truncate can engage; title keeps the full label on hover
1124
+ // (objectui#3466, same class of bug as the badge branch below).
1092
1125
  return (
1093
- <span key={key} className="inline-flex items-center gap-1.5 text-sm">
1126
+ <span key={key} className="inline-flex max-w-full items-center gap-1.5 text-sm" title={label}>
1094
1127
  <span className={cn('h-1.5 w-1.5 rounded-full shrink-0', dotClass)} aria-hidden="true" />
1095
- <span className="truncate">{label}</span>
1128
+ <span className="min-w-0 truncate">{label}</span>
1096
1129
  </span>
1097
1130
  );
1098
1131
  }
@@ -1266,7 +1299,7 @@ export function FileCellRenderer({ value, field }: CellRendererProps): React.Rea
1266
1299
  }
1267
1300
 
1268
1301
  const fileName = value.name || value.original_name || 'File';
1269
- return <span className="text-sm truncate">{fileName}</span>;
1302
+ return <TruncatedText text={String(fileName)} className="text-sm" />;
1270
1303
  }
1271
1304
 
1272
1305
  /**
@@ -1435,7 +1468,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
1435
1468
  String(parsed.externalId ?? parsed.id ?? parsed._id ?? '');
1436
1469
  }
1437
1470
  } catch { /* not JSON — fall through to normal resolution */ }
1438
- if (parsedDisplay) return <span className="truncate">{parsedDisplay}</span>;
1471
+ if (parsedDisplay) return <TruncatedText text={parsedDisplay} />;
1439
1472
  }
1440
1473
  }
1441
1474
 
@@ -1446,7 +1479,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
1446
1479
  const display =
1447
1480
  resolveLookupRecordName(obj, refSchema, displayField) || String(obj.id || obj._id || '');
1448
1481
  if (display) {
1449
- return <span className="truncate">{display}</span>;
1482
+ return <TruncatedText text={display} />;
1450
1483
  }
1451
1484
  }
1452
1485
 
@@ -1506,12 +1539,12 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
1506
1539
  const label =
1507
1540
  resolveLookupRecordName(value as Record<string, unknown>, refSchema, displayField) ||
1508
1541
  String((value as any).id || (value as any)._id || '[Object]');
1509
- return <span className="truncate">{label}</span>;
1542
+ return <TruncatedText text={label} />;
1510
1543
  }
1511
1544
 
1512
1545
  // Primitive value (e.g. raw ID): try options → resolver → opaque-ID placeholder → raw
1513
1546
  const { text, muted } = resolveLabel(value);
1514
- return <span className={cn('truncate', muted && 'text-muted-foreground')}>{text}</span>;
1547
+ return <TruncatedText text={text} className={muted ? 'text-muted-foreground' : undefined} />;
1515
1548
  }
1516
1549
 
1517
1550
  /**
@@ -1535,7 +1568,7 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme
1535
1568
 
1536
1569
  // Primitive value: just display the ID/username as text
1537
1570
  if (typeof value !== 'object') {
1538
- return <span className="truncate">{String(value)}</span>;
1571
+ return <TruncatedText text={String(value)} />;
1539
1572
  }
1540
1573
 
1541
1574
  if (Array.isArray(value)) {
@@ -1544,11 +1577,7 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme
1544
1577
  {value.slice(0, 3).map((user, idx) => {
1545
1578
  // Primitive user in array
1546
1579
  if (typeof user !== 'object' || user === null) {
1547
- return (
1548
- <span key={idx} className="truncate text-sm">
1549
- {String(user)}
1550
- </span>
1551
- );
1580
+ return <TruncatedText key={idx} text={String(user)} className="text-sm" />;
1552
1581
  }
1553
1582
  const name = user.name || user.username || 'User';
1554
1583
  const initials = name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2);
@@ -1588,7 +1617,7 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme
1588
1617
  {initials}
1589
1618
  </AvatarFallback>
1590
1619
  </Avatar>
1591
- <span className="truncate">{name}</span>
1620
+ <TruncatedText text={String(name)} />
1592
1621
  </div>
1593
1622
  );
1594
1623
  }
@@ -1670,13 +1699,10 @@ export function JsonCellRenderer({ value }: CellRendererProps): React.ReactEleme
1670
1699
  } else {
1671
1700
  text = String(value);
1672
1701
  }
1673
- // inline-block + max-w-full so `truncate` (overflow-hidden/ellipsis/nowrap)
1674
- // actually clamps to the cell width. On a bare inline <span> truncate never
1675
- // clips — there is no width box — and its `white-space:nowrap` also defeats
1676
- // the parent cell's `break-words`, so a long name-keyed map / address JSON
1677
- // spills into the neighbouring column (objectui#2578). The title keeps the
1678
- // full value on hover.
1679
- return <span className="block max-w-full font-mono text-xs text-gray-600 truncate" title={text}>{text}</span>;
1702
+ // The original site of the block-level+max-w-full+title pattern
1703
+ // (objectui#2578) — now shared with every single-line value renderer via
1704
+ // TruncatedText (objectui#3466).
1705
+ return <TruncatedText text={text} className="font-mono text-xs text-gray-600" />;
1680
1706
  }
1681
1707
 
1682
1708
  /**
@@ -2277,9 +2303,12 @@ export function registerField(fieldType: string): void {
2277
2303
 
2278
2304
  // Create lazy component
2279
2305
  const LazyFieldWidget = React.lazy(loader);
2280
-
2281
- // Register with field namespace - NO WRAPPER to allow form renderer to control label/layout
2282
- ComponentRegistry.register(fieldType, LazyFieldWidget, {
2306
+
2307
+ // Register with field namespace. No LAYOUT wrapper — the form renderer owns
2308
+ // label/description/spacing. The only thing wrapped is the metadata carrier:
2309
+ // `withFieldCarrier` maps `SchemaRenderer`'s `schema` node onto `field`
2310
+ // (objectui#3233) and renders nothing of its own.
2311
+ ComponentRegistry.register(fieldType, withFieldCarrier(LazyFieldWidget), {
2283
2312
  namespace: 'field',
2284
2313
  skipFallback: FIELD_TYPES_SKIP_FALLBACK.has(fieldType),
2285
2314
  });
@@ -2353,7 +2382,7 @@ export function registerFields() {
2353
2382
  // field type. Registered WITHOUT createFieldRenderer: it is a fully-controlled
2354
2383
  // widget (value/onChange from RHF), so the wrapper's extra label + local value
2355
2384
  // buffer would only duplicate the form's FormLabel and desync the value.
2356
- ComponentRegistry.register('capability-multiselect', CapabilityMultiSelectField, { namespace: 'field', skipFallback: true });
2385
+ ComponentRegistry.register('capability-multiselect', withFieldCarrier(CapabilityMultiSelectField), { namespace: 'field', skipFallback: true });
2357
2386
  // master_detail = child-side FK lookup (single value, typically required). See
2358
2387
  // fieldWidgetMap above for rationale.
2359
2388
  ComponentRegistry.register('master_detail', createFieldRenderer(LookupField), { namespace: 'field' });
@@ -2423,6 +2452,11 @@ export * from './widgets/ObjectRefField';
2423
2452
  export * from './widgets/FilterConditionField';
2424
2453
  export * from './widgets/RecipientPickerField';
2425
2454
  export * from './widgets/RecordPickerDialog';
2455
+ // Shared picker-column derivation (ADR-0085 highlightFields → displayFields →
2456
+ // schema walk) — consumed by LookupField AND by RelatedList's Add-picker so a
2457
+ // lookup picker and a related-list Add dialog of the same object agree on
2458
+ // columns (#3365).
2459
+ export * from './widgets/deriveLookupColumns';
2426
2460
  export * from './widgets/FileField';
2427
2461
  export * from './widgets/ImageField';
2428
2462
  export { ImageCropperDialog } from './widgets/ImageCropperDialog';
@@ -2451,5 +2485,19 @@ export * from './widgets/RadioField';
2451
2485
  export * from './widgets/CheckboxesField';
2452
2486
  export * from './widgets/TagsField';
2453
2487
 
2488
+ // The SDUI-node → `field` adapter every registration here goes through
2489
+ // (objectui#3233). Exported so a widget registered OUTSIDE this repo can reach
2490
+ // the same single-carrier guarantee instead of re-growing a `field || schema`
2491
+ // read of its own.
2492
+ export { withFieldCarrier } from './withFieldCarrier';
2493
+
2494
+ // The whitelist that decides what a widget's `...props` spread may put on a
2495
+ // DOM element (objectui#3291) — the runtime executor of the "DOM pass-through"
2496
+ // block of `FieldWidgetComponentProps`. Exported for the same reason as
2497
+ // `withFieldCarrier` above: a widget authored outside this repo needs to reach
2498
+ // it, or it re-grows the bare spread this closed.
2499
+ export { toDomProps } from './widgets/toDomProps';
2500
+ export type { DomProps } from './widgets/toDomProps';
2501
+
2454
2502
  // Initialize registry
2455
2503
  registerAllFields();
@@ -1,6 +1,7 @@
1
- import React from 'react';
1
+ import React, { useId } from 'react';
2
2
  import { Input, Label, EmptyValue } from '@object-ui/components';
3
3
  import { FieldWidgetComponentProps } from './types';
4
+ import { toDomProps } from './toDomProps';
4
5
 
5
6
  /**
6
7
  * Address data structure
@@ -17,8 +18,20 @@ export interface AddressValue {
17
18
  * Address field widget - provides a structured address input
18
19
  * Supports street, city, state, zip code, and country
19
20
  */
20
- export function AddressField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<AddressValue>) {
21
+ export function AddressField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<AddressValue>) {
21
22
  const address = value || {};
23
+ // DOM pass-through (objectui#3318): the whitelist spread goes onto the FIRST
24
+ // sub-input (street) — one carrier for the form renderer's aria-describedby;
25
+ // the composite's validation state goes onto EVERY focusable sub-input via
26
+ // `aria-invalid={!!error}` (a form-level failure means the whole address is
27
+ // missing/invalid, and whichever sub-input the user reaches must announce it).
28
+ const domProps = toDomProps(props);
29
+ // Sub-input ids (objectui#3343): `useId()` prefix + sub-field name — the
30
+ // `groupId` paradigm of RadioField / CheckboxesField. Hardcoded literals
31
+ // ("street", "city", …) collide as soon as a form renders two address
32
+ // fields, and every label's htmlFor then resolves to the FIRST match.
33
+ const groupId = useId();
34
+ const subId = (name: keyof AddressValue) => `${groupId}-${name}`;
22
35
 
23
36
  const handleFieldChange = (fieldName: keyof AddressValue, fieldValue: string) => {
24
37
  onChange({
@@ -45,66 +58,72 @@ export function AddressField({ value, onChange, field, readonly, ...props }: Fie
45
58
  return (
46
59
  <div className="space-y-3">
47
60
  <div>
48
- <Label htmlFor="street" className="text-xs">Street Address</Label>
61
+ <Label htmlFor={subId('street')} className="text-xs">Street Address</Label>
49
62
  <Input
50
- id="street"
63
+ {...domProps}
64
+ id={subId('street')}
51
65
  type="text"
52
66
  value={address.street || ''}
53
67
  onChange={(e) => handleFieldChange('street', e.target.value)}
54
68
  placeholder="123 Main St"
55
69
  disabled={readonly || props.disabled}
56
70
  className={props.className}
71
+ aria-invalid={!!error}
57
72
  />
58
73
  </div>
59
74
 
60
75
  <div className="grid grid-cols-2 gap-3">
61
76
  <div>
62
- <Label htmlFor="city" className="text-xs">City</Label>
77
+ <Label htmlFor={subId('city')} className="text-xs">City</Label>
63
78
  <Input
64
- id="city"
79
+ id={subId('city')}
65
80
  type="text"
66
81
  value={address.city || ''}
67
82
  onChange={(e) => handleFieldChange('city', e.target.value)}
68
83
  placeholder="San Francisco"
69
84
  disabled={readonly || props.disabled}
85
+ aria-invalid={!!error}
70
86
  />
71
87
  </div>
72
88
 
73
89
  <div>
74
- <Label htmlFor="state" className="text-xs">State / Province</Label>
90
+ <Label htmlFor={subId('state')} className="text-xs">State / Province</Label>
75
91
  <Input
76
- id="state"
92
+ id={subId('state')}
77
93
  type="text"
78
94
  value={address.state || ''}
79
95
  onChange={(e) => handleFieldChange('state', e.target.value)}
80
96
  placeholder="CA"
81
97
  disabled={readonly || props.disabled}
98
+ aria-invalid={!!error}
82
99
  />
83
100
  </div>
84
101
  </div>
85
102
 
86
103
  <div className="grid grid-cols-2 gap-3">
87
104
  <div>
88
- <Label htmlFor="zipCode" className="text-xs">ZIP / Postal Code</Label>
105
+ <Label htmlFor={subId('zipCode')} className="text-xs">ZIP / Postal Code</Label>
89
106
  <Input
90
- id="zipCode"
107
+ id={subId('zipCode')}
91
108
  type="text"
92
109
  value={address.zipCode || ''}
93
110
  onChange={(e) => handleFieldChange('zipCode', e.target.value)}
94
111
  placeholder="94102"
95
112
  disabled={readonly || props.disabled}
113
+ aria-invalid={!!error}
96
114
  />
97
115
  </div>
98
116
 
99
117
  <div>
100
- <Label htmlFor="country" className="text-xs">Country</Label>
118
+ <Label htmlFor={subId('country')} className="text-xs">Country</Label>
101
119
  <Input
102
- id="country"
120
+ id={subId('country')}
103
121
  type="text"
104
122
  value={address.country || ''}
105
123
  onChange={(e) => handleFieldChange('country', e.target.value)}
106
124
  placeholder="United States"
107
125
  disabled={readonly || props.disabled}
126
+ aria-invalid={!!error}
108
127
  />
109
128
  </div>
110
129
  </div>
@@ -0,0 +1,92 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * Unique sub-input ids for the composite `address` widget (objectui#3343).
11
+ *
12
+ * The widget used to hardcode literal ids ("street", "city", "state",
13
+ * "zipCode", "country") on its sub-inputs. Two address fields in one form —
14
+ * e.g. billing + shipping — then produced duplicate DOM ids, and every
15
+ * `Label htmlFor` resolved to the FIRST match in the document: each sub-label
16
+ * of the second field clicked/announced the first field's input.
17
+ *
18
+ * The fix follows the `groupId` paradigm of RadioField / CheckboxesField:
19
+ * a `useId()` prefix + the sub-field name. These tests pin it by rendering
20
+ * TWO instances in one form:
21
+ *
22
+ * 1. every `[id]` in the document is globally unique (red again if anyone
23
+ * reverts to hardcoded literals — both instances would emit id="street");
24
+ * 2. each sub-label of the SECOND instance resolves — via the exact
25
+ * mechanism browsers use for label activation, `for` → getElementById —
26
+ * to an input inside its OWN instance, and focusing it lands there.
27
+ */
28
+ import { describe, it, expect, vi } from 'vitest';
29
+ import React from 'react';
30
+ import { render, within } from '@testing-library/react';
31
+ import '@testing-library/jest-dom';
32
+ import { AddressField } from './AddressField';
33
+
34
+ const field = { name: 'address', type: 'address' } as any;
35
+
36
+ const SUB_LABELS = [
37
+ 'Street Address',
38
+ 'City',
39
+ 'State / Province',
40
+ 'ZIP / Postal Code',
41
+ 'Country',
42
+ ];
43
+
44
+ function renderTwoInOneForm() {
45
+ return render(
46
+ <form>
47
+ <div data-testid="address-a">
48
+ <AddressField value={{}} onChange={vi.fn()} field={field} />
49
+ </div>
50
+ <div data-testid="address-b">
51
+ <AddressField value={{}} onChange={vi.fn()} field={field} />
52
+ </div>
53
+ </form>,
54
+ );
55
+ }
56
+
57
+ describe('AddressField — unique sub-input ids (objectui#3343)', () => {
58
+ it('two address fields in one form produce globally unique DOM ids', () => {
59
+ const { baseElement } = renderTwoInOneForm();
60
+ const ids = Array.from(baseElement.querySelectorAll('[id]')).map((el) => el.id);
61
+ // 5 sub-inputs per instance — both instances must actually render ids.
62
+ expect(ids.length).toBeGreaterThanOrEqual(10);
63
+ const duplicates = ids.filter((id, i) => ids.indexOf(id) !== i);
64
+ expect(duplicates).toEqual([]);
65
+ });
66
+
67
+ it("each sub-label of the SECOND field focuses that field's own input", () => {
68
+ const { getByTestId } = renderTwoInOneForm();
69
+ const first = getByTestId('address-a');
70
+ const second = getByTestId('address-b');
71
+
72
+ for (const text of SUB_LABELS) {
73
+ const label = within(second).getByText(text);
74
+ const forId = label.getAttribute('for');
75
+ expect(forId, `label "${text}" must carry htmlFor`).toBeTruthy();
76
+
77
+ // Exactly what the browser does on label click / SR announcement:
78
+ // resolve `for` against the document.
79
+ const control = document.getElementById(forId!);
80
+ expect(control, `htmlFor of "${text}" must resolve`).not.toBeNull();
81
+ expect(control!.tagName).toBe('INPUT');
82
+ expect(
83
+ second.contains(control),
84
+ `"${text}" must resolve into its OWN field, not the first one`,
85
+ ).toBe(true);
86
+ expect(first.contains(control)).toBe(false);
87
+
88
+ (control as HTMLInputElement).focus();
89
+ expect(control).toHaveFocus();
90
+ }
91
+ });
92
+ });
@@ -2,16 +2,17 @@ import React from 'react';
2
2
  import { Avatar, AvatarFallback, AvatarImage, Button } from '@object-ui/components';
3
3
  import { Upload, X } from 'lucide-react';
4
4
  import { FieldWidgetComponentProps } from './types';
5
+ import { toDomProps } from './toDomProps';
5
6
 
6
7
  /**
7
8
  * Avatar field widget - provides an avatar/profile picture uploader
8
9
  * Supports image URLs or file uploads
9
10
  */
10
- export function AvatarField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
11
+ export function AvatarField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
11
12
  const [isHovered, setIsHovered] = React.useState(false);
12
13
  const fileInputRef = React.useRef<HTMLInputElement>(null);
13
14
 
14
- const avatarField = (field || (props as any).schema) as any;
15
+ const avatarField = field as any;
15
16
 
16
17
  const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
17
18
  const file = event.target.files?.[0];
@@ -92,11 +93,17 @@ export function AvatarField({ value, onChange, field, readonly, ...props }: Fiel
92
93
  className="hidden"
93
94
  />
94
95
  <Button
96
+ // DOM pass-through onto the widget's real focusable control — the
97
+ // upload button is the keyboard path to the hidden file input
98
+ // (objectui#3318).
99
+ {...toDomProps(props)}
95
100
  type="button"
96
101
  variant="outline"
97
102
  size="sm"
98
103
  onClick={() => fileInputRef.current?.click()}
99
104
  disabled={readonly || props.disabled}
105
+ // AFTER the spread so this widget's own computation wins (#3222).
106
+ aria-invalid={!!error}
100
107
  >
101
108
  <Upload className="w-4 h-4 mr-2" />
102
109
  {value ? 'Change' : 'Upload'} Avatar
@@ -1,13 +1,14 @@
1
1
  import React, { useId } from 'react';
2
2
  import { Switch, Checkbox, Label } from '@object-ui/components';
3
3
  import { FieldWidgetComponentProps } from './types';
4
+ import { toDomProps } from './toDomProps';
4
5
 
5
6
  /**
6
7
  * BooleanField - Toggle input supporting switch and checkbox variants
7
8
  * Renders as Switch or Checkbox based on field widget configuration
8
9
  */
9
10
  export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<boolean>) {
10
- const config = (field || (props as any).schema) as any;
11
+ const config = field as any;
11
12
  // Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
12
13
  const widget = config?.widget;
13
14
  // Generate unique ID using React's useId hook - must be before early returns (rules of hooks)
@@ -19,8 +20,7 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
19
20
  return <span className="text-sm">{value ? 'Yes' : 'No'}</span>;
20
21
  }
21
22
 
22
- // Filter out non-DOM props
23
- const { inputType, ...domProps } = props as any;
23
+ const domProps = toDomProps(props);
24
24
 
25
25
  if (widget === 'checkbox') {
26
26
  return (