@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.
- package/CHANGELOG.md +592 -0
- package/dist/__tests__/cell-truncation.test.d.ts +0 -0
- package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2881 -2566
- package/dist/widgets/AddressField.d.ts +1 -1
- package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/AvatarField.d.ts +1 -1
- package/dist/widgets/CharacterCount.d.ts +56 -0
- package/dist/widgets/CheckboxesField.d.ts +1 -1
- package/dist/widgets/CodeField.d.ts +1 -1
- package/dist/widgets/ColorField.d.ts +1 -1
- package/dist/widgets/CurrencyField.d.ts +1 -1
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +1 -1
- package/dist/widgets/FilterConditionField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +155 -0
- package/dist/widgets/GeolocationField.d.ts +1 -1
- package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +1 -1
- package/dist/widgets/LookupField.d.ts +1 -1
- package/dist/widgets/LookupField.gateHintLabel.test.d.ts +8 -0
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/ObjectField.d.ts +1 -1
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +45 -0
- package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
- package/dist/widgets/PercentField.d.ts +1 -1
- package/dist/widgets/PhoneField.d.ts +1 -1
- package/dist/widgets/QRCodeField.d.ts +1 -1
- package/dist/widgets/RadioField.d.ts +1 -1
- package/dist/widgets/RatingField.d.ts +1 -1
- package/dist/widgets/RecipientPickerField.d.ts +1 -1
- package/dist/widgets/RecordPickerDialog.d.ts +17 -1
- package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +8 -0
- package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +8 -0
- package/dist/widgets/RichTextField.d.ts +19 -1
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
- package/dist/widgets/TextAreaField.d.ts +44 -5
- package/dist/widgets/UrlField.d.ts +1 -1
- package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/toDomProps.d.ts +91 -0
- package/dist/widgets/types.d.ts +128 -33
- package/dist/withFieldCarrier.d.ts +56 -0
- package/package.json +13 -13
- package/src/__tests__/cell-truncation.test.tsx +96 -0
- package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
- package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
- package/src/__tests__/validation-feedback.test.tsx +22 -13
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +598 -0
- package/src/__tests__/widget-props-contract.test.tsx +22 -9
- package/src/index.tsx +74 -26
- package/src/widgets/AddressField.tsx +31 -12
- package/src/widgets/AddressField.uniqueIds.test.tsx +92 -0
- package/src/widgets/AvatarField.tsx +9 -2
- package/src/widgets/BooleanField.tsx +3 -3
- package/src/widgets/CharacterCount.tsx +263 -0
- package/src/widgets/CheckboxesField.tsx +31 -14
- package/src/widgets/CodeField.tsx +9 -2
- package/src/widgets/ColorField.tsx +11 -2
- package/src/widgets/CurrencyField.tsx +5 -4
- package/src/widgets/DateField.tsx +2 -2
- package/src/widgets/DateTimeField.tsx +2 -2
- package/src/widgets/EmailField.tsx +5 -5
- package/src/widgets/FileField.tsx +13 -3
- package/src/widgets/FilterConditionField.tsx +11 -0
- package/src/widgets/FormulaField.tsx +1 -1
- package/src/widgets/FullscreenFieldEditor.tsx +271 -0
- package/src/widgets/GeolocationField.tsx +21 -6
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
- package/src/widgets/GridField.tsx +1 -1
- package/src/widgets/ImageField.tsx +9 -2
- package/src/widgets/LocationField.tsx +9 -2
- package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
- package/src/widgets/LookupField.tsx +41 -6
- package/src/widgets/MasterDetailField.tsx +1 -1
- package/src/widgets/MultiSelectField.tsx +30 -14
- package/src/widgets/NumberField.tsx +3 -3
- package/src/widgets/ObjectField.tsx +17 -8
- package/src/widgets/ObjectRefField.tsx +10 -0
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
- package/src/widgets/OptionsEmptyState.test.tsx +169 -0
- package/src/widgets/OptionsEmptyState.tsx +83 -0
- package/src/widgets/PasswordField.tsx +3 -3
- package/src/widgets/PeoplePicker.tsx +7 -1
- package/src/widgets/PercentField.tsx +5 -4
- package/src/widgets/PhoneField.tsx +5 -5
- package/src/widgets/QRCodeField.tsx +9 -2
- package/src/widgets/RadioField.tsx +24 -14
- package/src/widgets/RatingField.tsx +18 -3
- package/src/widgets/RecipientPickerField.tsx +18 -0
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
- package/src/widgets/RecordPickerDialog.tsx +173 -8
- package/src/widgets/RichTextField.tsx +151 -24
- package/src/widgets/SelectField.tsx +48 -17
- package/src/widgets/SliderField.tsx +1 -1
- package/src/widgets/SummaryField.tsx +1 -1
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
- package/src/widgets/TagsField.placeholder.test.tsx +95 -0
- package/src/widgets/TagsField.tsx +23 -2
- package/src/widgets/TextAreaField.tsx +171 -93
- package/src/widgets/TextField.tsx +3 -3
- package/src/widgets/TimeField.tsx +2 -2
- package/src/widgets/UrlField.tsx +5 -5
- package/src/widgets/UserField.tsx +1 -1
- package/src/widgets/VectorField.tsx +1 -1
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
- package/src/widgets/toDomProps.ts +175 -0
- package/src/widgets/types.ts +135 -35
- package/src/widgets/useFieldTranslation.ts +60 -0
- 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 <
|
|
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 <
|
|
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 <
|
|
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 <
|
|
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 <
|
|
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 <
|
|
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 <
|
|
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
|
-
<
|
|
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
|
-
//
|
|
1674
|
-
//
|
|
1675
|
-
//
|
|
1676
|
-
|
|
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
|
|
2282
|
-
|
|
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=
|
|
61
|
+
<Label htmlFor={subId('street')} className="text-xs">Street Address</Label>
|
|
49
62
|
<Input
|
|
50
|
-
|
|
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=
|
|
77
|
+
<Label htmlFor={subId('city')} className="text-xs">City</Label>
|
|
63
78
|
<Input
|
|
64
|
-
id=
|
|
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=
|
|
90
|
+
<Label htmlFor={subId('state')} className="text-xs">State / Province</Label>
|
|
75
91
|
<Input
|
|
76
|
-
id=
|
|
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=
|
|
105
|
+
<Label htmlFor={subId('zipCode')} className="text-xs">ZIP / Postal Code</Label>
|
|
89
106
|
<Input
|
|
90
|
-
id=
|
|
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=
|
|
118
|
+
<Label htmlFor={subId('country')} className="text-xs">Country</Label>
|
|
101
119
|
<Input
|
|
102
|
-
id=
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
23
|
-
const { inputType, ...domProps } = props as any;
|
|
23
|
+
const domProps = toDomProps(props);
|
|
24
24
|
|
|
25
25
|
if (widget === 'checkbox') {
|
|
26
26
|
return (
|