@object-ui/fields 17.5.0 → 17.6.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 +714 -0
- package/dist/FieldEditWidget.d.ts +16 -2
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +4 -4
- package/dist/index.css +27 -9
- package/dist/index.d.ts +425 -72
- package/dist/index.js +2530 -2228
- package/dist/widgets/AddressField.d.ts +3 -3
- package/dist/widgets/AutoNumberField.d.ts +1 -1
- package/dist/widgets/AvatarField.d.ts +1 -1
- package/dist/widgets/BooleanField.d.ts +1 -1
- package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
- 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/DateField.d.ts +1 -1
- package/dist/widgets/DateTimeField.d.ts +1 -1
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +18 -2
- package/dist/widgets/FilterConditionField.d.ts +28 -1
- package/dist/widgets/FormulaField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
- package/dist/widgets/GeolocationField.d.ts +1 -1
- package/dist/widgets/GridField.d.ts +56 -4
- 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/MasterDetailField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/NumberField.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 +13 -7
- package/dist/widgets/PasswordField.d.ts +1 -1
- 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 +2 -4
- package/dist/widgets/RichTextField.d.ts +37 -1
- package/dist/widgets/SelectField.d.ts +1 -1
- package/dist/widgets/SignatureField.d.ts +32 -2
- package/dist/widgets/SliderField.d.ts +37 -2
- package/dist/widgets/SummaryField.d.ts +1 -1
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TextAreaField.d.ts +6 -2
- package/dist/widgets/TextField.d.ts +1 -1
- package/dist/widgets/TimeField.d.ts +1 -1
- package/dist/widgets/UrlField.d.ts +1 -1
- package/dist/widgets/UserField.d.ts +1 -1
- package/dist/widgets/VectorField.d.ts +1 -1
- package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
- package/dist/widgets/address-format.d.ts +27 -7
- package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
- package/dist/widgets/richTextDisplay.d.ts +80 -0
- package/dist/widgets/toHostGroupProps.d.ts +119 -21
- package/dist/widgets/types.d.ts +8 -3
- package/package.json +9 -10
- package/dist/widgets/CharacterCount.d.ts +0 -56
- package/src/FieldEditWidget.test.ts +0 -133
- package/src/FieldEditWidget.tsx +0 -207
- package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
- package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
- package/src/__tests__/DateCellRenderer.test.tsx +0 -82
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
- package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
- package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
- package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
- package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
- package/src/__tests__/cell-truncation.test.tsx +0 -96
- package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
- package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
- package/src/__tests__/date-locale-channel.test.tsx +0 -305
- package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
- package/src/__tests__/group-labelling-declaration.test.ts +0 -148
- package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
- package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
- package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
- package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
- package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
- package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
- package/src/__tests__/registry-collision.test.ts +0 -91
- package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
- package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
- package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
- package/src/__tests__/validation-feedback.test.tsx +0 -361
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
- package/src/__tests__/widget-props-contract.test.tsx +0 -142
- package/src/coerce-safe-value.test.ts +0 -34
- package/src/complex-widgets.test.tsx +0 -924
- package/src/currency.ts +0 -144
- package/src/datetime-widgets.test.tsx +0 -185
- package/src/field-type-alias.multiple.test.ts +0 -81
- package/src/field-type-alias.ts +0 -156
- package/src/field-type-coverage.test.ts +0 -67
- package/src/index.css +0 -44
- package/src/index.tsx +0 -2745
- package/src/standard-widgets.test.tsx +0 -364
- package/src/widgets/AddressField.postalCode.test.tsx +0 -257
- package/src/widgets/AddressField.tsx +0 -182
- package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
- package/src/widgets/AutoNumberField.tsx +0 -18
- package/src/widgets/AvatarField.tsx +0 -117
- package/src/widgets/BooleanField.tsx +0 -86
- package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
- package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
- package/src/widgets/CharacterCount.tsx +0 -263
- package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
- package/src/widgets/CheckboxesField.tsx +0 -169
- package/src/widgets/CodeField.tsx +0 -41
- package/src/widgets/ColorField.tsx +0 -53
- package/src/widgets/CurrencyField.tsx +0 -135
- package/src/widgets/DateField.tsx +0 -40
- package/src/widgets/DateTimeField.tsx +0 -46
- package/src/widgets/EmailField.tsx +0 -37
- package/src/widgets/FileField.tsx +0 -512
- package/src/widgets/FilterConditionField.tsx +0 -454
- package/src/widgets/FormulaField.tsx +0 -38
- package/src/widgets/FullscreenFieldEditor.tsx +0 -60
- package/src/widgets/GeolocationField.tsx +0 -193
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
- package/src/widgets/GridField.test.tsx +0 -632
- package/src/widgets/GridField.tsx +0 -1070
- package/src/widgets/ImageCropperDialog.tsx +0 -245
- package/src/widgets/ImageField.maxSize.test.tsx +0 -154
- package/src/widgets/ImageField.tsx +0 -305
- package/src/widgets/ImageLightbox.test.tsx +0 -52
- package/src/widgets/ImageLightbox.tsx +0 -108
- package/src/widgets/LocationField.tsx +0 -57
- package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
- package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
- package/src/widgets/LookupField.hydration.test.tsx +0 -136
- package/src/widgets/LookupField.idField.test.tsx +0 -61
- package/src/widgets/LookupField.tsx +0 -1295
- package/src/widgets/MarkdownContent.tsx +0 -19
- package/src/widgets/MasterDetailField.tsx +0 -131
- package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
- package/src/widgets/MultiSelectField.tsx +0 -196
- package/src/widgets/NumberField.tsx +0 -49
- package/src/widgets/ObjectField.tsx +0 -94
- package/src/widgets/ObjectRefField.tsx +0 -121
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
- package/src/widgets/OptionsEmptyState.test.tsx +0 -169
- package/src/widgets/OptionsEmptyState.tsx +0 -99
- package/src/widgets/PasswordField.tsx +0 -49
- package/src/widgets/PeoplePicker.test.tsx +0 -360
- package/src/widgets/PeoplePicker.tsx +0 -579
- package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
- package/src/widgets/PercentField.tsx +0 -95
- package/src/widgets/PersonRow.tsx +0 -117
- package/src/widgets/PhoneField.tsx +0 -37
- package/src/widgets/QRCodeField.tsx +0 -103
- package/src/widgets/RadioField.cascade.test.tsx +0 -141
- package/src/widgets/RadioField.tsx +0 -143
- package/src/widgets/RatingField.tsx +0 -94
- package/src/widgets/RecipientPickerField.test.tsx +0 -157
- package/src/widgets/RecipientPickerField.tsx +0 -188
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
- package/src/widgets/RecordPickerDialog.tsx +0 -1365
- package/src/widgets/RichTextField.tsx +0 -176
- package/src/widgets/SelectField.cascade.test.tsx +0 -138
- package/src/widgets/SelectField.tsx +0 -193
- package/src/widgets/SelectionTray.tsx +0 -110
- package/src/widgets/SignatureField.tsx +0 -140
- package/src/widgets/SliderField.tsx +0 -39
- package/src/widgets/SummaryField.tsx +0 -31
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
- package/src/widgets/TagsField.placeholder.test.tsx +0 -95
- package/src/widgets/TagsField.tsx +0 -88
- package/src/widgets/TextAreaField.tsx +0 -213
- package/src/widgets/TextField.tsx +0 -45
- package/src/widgets/TimeField.tsx +0 -31
- package/src/widgets/UrlField.tsx +0 -46
- package/src/widgets/UserField.tsx +0 -59
- package/src/widgets/UserFieldPicker.test.tsx +0 -78
- package/src/widgets/VectorField.tsx +0 -29
- package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
- package/src/widgets/_cell-renderer-bridge.ts +0 -19
- package/src/widgets/address-format.ts +0 -120
- package/src/widgets/deriveLookupColumns.test.ts +0 -112
- package/src/widgets/deriveLookupColumns.ts +0 -108
- package/src/widgets/file-size-guard.ts +0 -55
- package/src/widgets/file-value.test.ts +0 -230
- package/src/widgets/file-value.ts +0 -246
- package/src/widgets/nativeDateValue.test.ts +0 -115
- package/src/widgets/nativeDateValue.ts +0 -112
- package/src/widgets/openNativePicker.ts +0 -18
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
- package/src/widgets/personDisplay.test.ts +0 -113
- package/src/widgets/personDisplay.ts +0 -92
- package/src/widgets/recentLookups.test.ts +0 -45
- package/src/widgets/recentLookups.ts +0 -47
- package/src/widgets/toDomProps.ts +0 -204
- package/src/widgets/toHostGroupProps.ts +0 -102
- package/src/widgets/types.ts +0 -242
- package/src/widgets/useCascadingOptions.ts +0 -46
- package/src/widgets/useFieldTranslation.ts +0 -132
- package/src/widgets/useRecordQuery.test.ts +0 -174
- package/src/widgets/useRecordQuery.ts +0 -291
- package/src/widgets/useUploadingSignal.test.tsx +0 -62
- package/src/widgets/useUploadingSignal.ts +0 -20
- package/src/withFieldCarrier.tsx +0 -75
|
@@ -1,92 +0,0 @@
|
|
|
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
|
-
* Pure display helpers for person (sys_user) records, shared by the
|
|
11
|
-
* search-first PeoplePicker's rich rows, the SelectionTray chips, and the
|
|
12
|
-
* read-only user cell renderer. Kept framework-free so they're trivially
|
|
13
|
-
* unit-testable and reusable by a future org-tree tier.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/** Resolve a possibly-dotted path (e.g. `primary_business_unit_id.name`) against a record. */
|
|
17
|
-
export function resolvePath(record: any, path: string): any {
|
|
18
|
-
if (!record || !path) return undefined;
|
|
19
|
-
if (!path.includes('.')) return record[path];
|
|
20
|
-
return path.split('.').reduce((acc, key) => (acc == null ? acc : acc[key]), record);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Best display name for a person record, tolerating expanded / raw shapes. */
|
|
24
|
-
export function getPersonName(record: any, nameField = 'name'): string {
|
|
25
|
-
if (record == null) return '';
|
|
26
|
-
if (typeof record !== 'object') return String(record);
|
|
27
|
-
return String(record[nameField] ?? record.name ?? record.username ?? record.label ?? '');
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Up-to-2-char initials for the avatar fallback.
|
|
32
|
-
* - "John Doe" → "JD"; "John" → "JO"
|
|
33
|
-
* - CJK single token → trailing 1–2 chars (given name): "张三" → "张三", "王小明" → "小明"
|
|
34
|
-
*/
|
|
35
|
-
export function getPersonInitials(name: string): string {
|
|
36
|
-
const trimmed = (name || '').trim();
|
|
37
|
-
if (!trimmed) return '?';
|
|
38
|
-
const parts = trimmed.split(/\s+/).filter(Boolean);
|
|
39
|
-
if (parts.length >= 2) {
|
|
40
|
-
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
41
|
-
}
|
|
42
|
-
const token = parts[0];
|
|
43
|
-
if (/[㐀-鿿]/.test(token)) {
|
|
44
|
-
return token.length <= 2 ? token : token.slice(-2);
|
|
45
|
-
}
|
|
46
|
-
return token.slice(0, 2).toUpperCase();
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Secondary line: resolve each field path, drop empties, join with " · ". */
|
|
50
|
-
export function getPersonSubtitle(record: any, fields?: string[]): string {
|
|
51
|
-
if (!record || !fields || fields.length === 0) return '';
|
|
52
|
-
return fields
|
|
53
|
-
.map(f => resolvePath(record, f))
|
|
54
|
-
.filter(v => v != null && v !== '')
|
|
55
|
-
.map(v => String(v))
|
|
56
|
-
.join(' · ');
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Avatar image URL (defaults to `sys_user.image`); undefined when absent. */
|
|
60
|
-
export function getPersonAvatarUrl(record: any, avatarField = 'image'): string | undefined {
|
|
61
|
-
const v = record ? resolvePath(record, avatarField) : undefined;
|
|
62
|
-
return v ? String(v) : undefined;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Record id, tolerating `id` / `_id` / a custom id field. */
|
|
66
|
-
export function getPersonId(record: any, idField = 'id'): any {
|
|
67
|
-
if (!record || typeof record !== 'object') return record;
|
|
68
|
-
return record[idField] ?? record.id ?? record._id;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Case-insensitive literal-substring match ranges of `query` in `text`, as
|
|
73
|
-
* `[start, end)` index pairs. Used to highlight the typed term in candidate
|
|
74
|
-
* rows — a strong disambiguation aid for same-named people. Pinyin matches are
|
|
75
|
-
* resolved server-side and won't have client ranges; that's fine, the row still
|
|
76
|
-
* shows, just without a highlight.
|
|
77
|
-
*/
|
|
78
|
-
export function matchRanges(text: string, query: string): Array<[number, number]> {
|
|
79
|
-
const t = text ?? '';
|
|
80
|
-
const q = (query ?? '').trim().toLowerCase();
|
|
81
|
-
if (!q || !t) return [];
|
|
82
|
-
const lower = t.toLowerCase();
|
|
83
|
-
const ranges: Array<[number, number]> = [];
|
|
84
|
-
let from = 0;
|
|
85
|
-
for (;;) {
|
|
86
|
-
const idx = lower.indexOf(q, from);
|
|
87
|
-
if (idx === -1) break;
|
|
88
|
-
ranges.push([idx, idx + q.length]);
|
|
89
|
-
from = idx + q.length;
|
|
90
|
-
}
|
|
91
|
-
return ranges;
|
|
92
|
-
}
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
-
import { getRecentLookupIds, pushRecentLookupId } from './recentLookups';
|
|
3
|
-
|
|
4
|
-
describe('recentLookups', () => {
|
|
5
|
-
beforeEach(() => localStorage.clear());
|
|
6
|
-
|
|
7
|
-
it('returns [] when nothing stored', () => {
|
|
8
|
-
expect(getRecentLookupIds('acc')).toEqual([]);
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it('stores and returns ids most-recent-first', () => {
|
|
12
|
-
pushRecentLookupId('acc', 'a');
|
|
13
|
-
pushRecentLookupId('acc', 'b');
|
|
14
|
-
expect(getRecentLookupIds('acc')).toEqual(['b', 'a']);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('de-dupes and moves a re-picked id to the front', () => {
|
|
18
|
-
pushRecentLookupId('acc', 'a');
|
|
19
|
-
pushRecentLookupId('acc', 'b');
|
|
20
|
-
pushRecentLookupId('acc', 'a');
|
|
21
|
-
expect(getRecentLookupIds('acc')).toEqual(['a', 'b']);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it('caps at 5 entries', () => {
|
|
25
|
-
for (const id of ['a', 'b', 'c', 'd', 'e', 'f', 'g']) pushRecentLookupId('acc', id);
|
|
26
|
-
const ids = getRecentLookupIds('acc');
|
|
27
|
-
expect(ids).toHaveLength(5);
|
|
28
|
-
expect(ids[0]).toBe('g');
|
|
29
|
-
expect(ids).not.toContain('a');
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('scopes per object name', () => {
|
|
33
|
-
pushRecentLookupId('acc', 'a');
|
|
34
|
-
pushRecentLookupId('prod', 'x');
|
|
35
|
-
expect(getRecentLookupIds('acc')).toEqual(['a']);
|
|
36
|
-
expect(getRecentLookupIds('prod')).toEqual(['x']);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('ignores empty/nullish ids and object names', () => {
|
|
40
|
-
pushRecentLookupId('acc', '');
|
|
41
|
-
pushRecentLookupId('acc', null);
|
|
42
|
-
pushRecentLookupId('', 'a');
|
|
43
|
-
expect(getRecentLookupIds('acc')).toEqual([]);
|
|
44
|
-
});
|
|
45
|
-
});
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Recently-used lookup memory.
|
|
3
|
-
*
|
|
4
|
-
* Mainstream record pickers (Salesforce, Dataverse, SAP, ServiceNow) surface
|
|
5
|
-
* the user's recently-picked records the moment a lookup is focused — before
|
|
6
|
-
* any typing. We approximate that with a small per-object ring of the last few
|
|
7
|
-
* selected record ids, kept in localStorage (no backend dependency). When a
|
|
8
|
-
* recent-items API later exists, this module is the single seam to swap.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
const KEY_PREFIX = 'objectui:lookup:recent:';
|
|
12
|
-
const MAX_RECENT = 5;
|
|
13
|
-
|
|
14
|
-
function storage(): Storage | null {
|
|
15
|
-
try {
|
|
16
|
-
return typeof localStorage !== 'undefined' ? localStorage : null;
|
|
17
|
-
} catch {
|
|
18
|
-
// Accessing localStorage can throw in sandboxed iframes / SSR.
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Most-recent-first list of record ids previously chosen for `objectName`. */
|
|
24
|
-
export function getRecentLookupIds(objectName: string): Array<string | number> {
|
|
25
|
-
const s = storage();
|
|
26
|
-
if (!objectName || !s) return [];
|
|
27
|
-
try {
|
|
28
|
-
const raw = s.getItem(KEY_PREFIX + objectName);
|
|
29
|
-
if (!raw) return [];
|
|
30
|
-
const arr = JSON.parse(raw);
|
|
31
|
-
return Array.isArray(arr) ? arr.slice(0, MAX_RECENT) : [];
|
|
32
|
-
} catch {
|
|
33
|
-
return [];
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Record that `id` was just chosen for `objectName` (moves it to the front). */
|
|
38
|
-
export function pushRecentLookupId(objectName: string, id: string | number | null | undefined): void {
|
|
39
|
-
const s = storage();
|
|
40
|
-
if (!objectName || id === null || id === undefined || id === '' || !s) return;
|
|
41
|
-
try {
|
|
42
|
-
const next = [id, ...getRecentLookupIds(objectName).filter((x) => x !== id)].slice(0, MAX_RECENT);
|
|
43
|
-
s.setItem(KEY_PREFIX + objectName, JSON.stringify(next));
|
|
44
|
-
} catch {
|
|
45
|
-
// Ignore quota / serialization failures — recents are best-effort.
|
|
46
|
-
}
|
|
47
|
-
}
|
|
@@ -1,204 +0,0 @@
|
|
|
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
|
-
import {
|
|
10
|
-
pickDomProps,
|
|
11
|
-
type SduiDomPassThroughKey,
|
|
12
|
-
type DomProps as CoreDomProps,
|
|
13
|
-
} from '@object-ui/core';
|
|
14
|
-
import type { FieldWidgetComponentProps, FieldWidgetDomProps } from './types';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* The RUNTIME EXECUTOR of the "DOM pass-through" section of
|
|
18
|
-
* {@link FieldWidgetComponentProps} (objectui#3291).
|
|
19
|
-
*
|
|
20
|
-
* ## The defect
|
|
21
|
-
*
|
|
22
|
-
* Field widgets forwarded their leftover props to a control with a bare
|
|
23
|
-
* spread — `const { inputType, ...domProps } = props as any; <Input
|
|
24
|
-
* {...domProps} />`. Whatever a host handed the widget therefore became a DOM
|
|
25
|
-
* attribute. Measured on `origin/main`, a real form + a real widget:
|
|
26
|
-
*
|
|
27
|
-
* ```
|
|
28
|
-
* <input placeholder="PH-f" zzcanary="CANARY-STR" zzcanaryobj="[object Object]"
|
|
29
|
-
* zzcanarynum="42" id="…" type="text" value="" name="f">
|
|
30
|
-
* ```
|
|
31
|
-
*
|
|
32
|
-
* The `[object Object]` this issue is named for is an authored field-config
|
|
33
|
-
* key being `String()`-ed onto an attribute. React 19 does not warn: an
|
|
34
|
-
* all-lowercase unknown attribute is passed through in complete silence, which
|
|
35
|
-
* is why this survived so long.
|
|
36
|
-
*
|
|
37
|
-
* ## Why a whitelist, and not a list of keys to drop
|
|
38
|
-
*
|
|
39
|
-
* The biggest leak source is not any NAMED renderer prop — it is the open tail
|
|
40
|
-
* of author-supplied keys. The form renderer destructures a fixed set of known
|
|
41
|
-
* keys and forwards `...fieldProps` verbatim
|
|
42
|
-
* (`components/src/renderers/form/form.tsx`), and `SchemaRenderer` is wider
|
|
43
|
-
* still: it spreads the whole authored node as props and has no strip layer at
|
|
44
|
-
* all, so on the SDUI path a widget's own spread is the ONLY line of defence.
|
|
45
|
-
*
|
|
46
|
-
* A blacklist enumerating today's renderer-only keys (`error`, `emptyHint`,
|
|
47
|
-
* `dataSource`, `dependentValues`, `dependsOn`, `options`, `inputType`, …)
|
|
48
|
-
* would pass every one of the canaries above and would not stop the next
|
|
49
|
-
* authored key either. Only "keep the known-safe set, drop the rest" closes
|
|
50
|
-
* it, which is what this function does.
|
|
51
|
-
*
|
|
52
|
-
* ## Declared = enforced
|
|
53
|
-
*
|
|
54
|
-
* {@link FieldWidgetComponentProps} already DECLARES the closed set of keys a
|
|
55
|
-
* widget may receive, and {@link FieldWidgetDomProps} names the subset that may
|
|
56
|
-
* legitimately reach a DOM element (objectui#3221). Until now that was a
|
|
57
|
-
* type-level claim a widget could violate at runtime simply by spreading. This
|
|
58
|
-
* function is that declaration's executable form, and TWO compile-time
|
|
59
|
-
* assertions below bind them in both directions — forwarding an undeclared key
|
|
60
|
-
* and declaring an unforwarded DOM key are each a compile error. Neither
|
|
61
|
-
* direction can drift silently.
|
|
62
|
-
*
|
|
63
|
-
* ## Deliberately NOT forwarded
|
|
64
|
-
*
|
|
65
|
-
* `role` and other HTML global attributes are absent because the contract does
|
|
66
|
-
* not declare them. They reached the DOM before this change only through the
|
|
67
|
-
* open spread. If a field node should be able to author one, DECLARE it on
|
|
68
|
-
* `FieldWidgetComponentProps` first and add it here — do not reopen the spread
|
|
69
|
-
* (AGENTS.md #0.1: fix the contract, never widen the consumer).
|
|
70
|
-
*
|
|
71
|
-
* ## Where the mechanism lives now, and why the key list stayed here
|
|
72
|
-
*
|
|
73
|
-
* objectui#4425 phase 2 promoted this whitelist to the SDUI widget contract
|
|
74
|
-
* generally, so the MECHANISM — filter by a declared key list plus the `aria-*`
|
|
75
|
-
* / `data-*` open families — moved to `@object-ui/core`
|
|
76
|
-
* (`utils/dom-props.ts`, `pickDomProps`), where every plugin package can reach
|
|
77
|
-
* it without depending on `@object-ui/fields` (the objectui#4409
|
|
78
|
-
* dependency-direction method: `plugin-chatbot` declares `@object-ui/core` and
|
|
79
|
-
* does not declare this package).
|
|
80
|
-
*
|
|
81
|
-
* The KEY LIST did not move, because it is not the same list. Measured on
|
|
82
|
-
* objectui#4431:
|
|
83
|
-
*
|
|
84
|
-
* - `name` and `disabled` are here and NOT in the SDUI set: both are legal
|
|
85
|
-
* only on form controls, which is what every widget in this package renders
|
|
86
|
-
* and what `FieldWidgetComponentProps` declares. `name` on the `<div>` an
|
|
87
|
-
* SDUI container widget renders is a leak — one of the 14 attributes #4431
|
|
88
|
-
* closed — and two widgets here already hand-strip it when they spread onto
|
|
89
|
-
* something that is not a control (`ObjectRefField`'s trigger,
|
|
90
|
-
* `FileField`'s dropzone).
|
|
91
|
-
* - `role` is in the SDUI set and NOT here: `SchemaRenderer` resolves it for
|
|
92
|
-
* every node from `@objectstack/spec`'s `AriaPropsSchema`, while this
|
|
93
|
-
* contract deliberately does not declare it (see "Deliberately NOT
|
|
94
|
-
* forwarded" above).
|
|
95
|
-
*
|
|
96
|
-
* So: one mechanism, two declarations, each bound by the assertions below to
|
|
97
|
-
* the contract it executes. Not two judges.
|
|
98
|
-
*/
|
|
99
|
-
const DOM_PASS_THROUGH_KEYS = [
|
|
100
|
-
/* ── The contract's own "DOM pass-through" block, verbatim ─────────────── */
|
|
101
|
-
'id',
|
|
102
|
-
'name',
|
|
103
|
-
'autoFocus',
|
|
104
|
-
'tabIndex',
|
|
105
|
-
'onBlur',
|
|
106
|
-
'onFocus',
|
|
107
|
-
'onClick',
|
|
108
|
-
|
|
109
|
-
/* ── Two more declared keys that ARE valid DOM attributes ──────────────── */
|
|
110
|
-
//
|
|
111
|
-
// Both are declared on `FieldWidgetComponentProps` under the
|
|
112
|
-
// controlled-input contract rather than the DOM block, because widgets also
|
|
113
|
-
// INTERPRET them (`className` gets composed with the widget's own classes;
|
|
114
|
-
// `disabled` is OR-ed with `readonly`). They are still `class` and
|
|
115
|
-
// `disabled` on the element, and every widget here already forwards them.
|
|
116
|
-
//
|
|
117
|
-
// Withholding them would make this helper a silent styling- and
|
|
118
|
-
// interactivity-dropper: `<Input {...toDomProps(props)} />` — the shape this
|
|
119
|
-
// change teaches every future widget author to write — would quietly lose
|
|
120
|
-
// the host's className and disabled state. That is a NEW silent failure of
|
|
121
|
-
// exactly the kind the whitelist exists to prevent, so they are forwarded.
|
|
122
|
-
'className',
|
|
123
|
-
'disabled',
|
|
124
|
-
] as const;
|
|
125
|
-
|
|
126
|
-
type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Compile-time link to the declaration, direction 1 of 2: every key forwarded
|
|
130
|
-
* at runtime must exist on {@link FieldWidgetComponentProps}. Deleting a key
|
|
131
|
-
* from the contract without deleting it here is a compile error.
|
|
132
|
-
*
|
|
133
|
-
* Catches: helper forwards something the contract no longer declares.
|
|
134
|
-
*/
|
|
135
|
-
type EveryForwardedKeyIsDeclared =
|
|
136
|
-
DomPassThroughKey extends keyof FieldWidgetComponentProps ? true : never;
|
|
137
|
-
const _everyForwardedKeyIsDeclared: EveryForwardedKeyIsDeclared = true;
|
|
138
|
-
void _everyForwardedKeyIsDeclared;
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Direction 2 of 2: every key of {@link FieldWidgetDomProps} — the contract's
|
|
142
|
-
* DOM pass-through block — must be one this helper actually forwards. Adding a
|
|
143
|
-
* DOM key to the contract without adding it to `DOM_PASS_THROUGH_KEYS` is a
|
|
144
|
-
* compile error.
|
|
145
|
-
*
|
|
146
|
-
* Catches: DECLARED BUT NOT DELIVERED — a key that type-checks, reads as
|
|
147
|
-
* supported, and silently never reaches the element. That is the failure this
|
|
148
|
-
* repo treats as first-class (objectui#3290's `aria-required` that never
|
|
149
|
-
* reached a control; objectui#3222's validation slot nobody produced), and it
|
|
150
|
-
* is the one direction the leak test structurally CANNOT see: that test looks
|
|
151
|
-
* for attributes that arrive, not for ones that go missing.
|
|
152
|
-
*
|
|
153
|
-
* `className` / `disabled` are not part of `FieldWidgetDomProps` (they are
|
|
154
|
-
* controlled-input keys this helper also forwards — see above), so they are
|
|
155
|
-
* bound by direction 1 only. That is deliberate, not an oversight.
|
|
156
|
-
*/
|
|
157
|
-
type EveryDeclaredDomKeyIsForwarded =
|
|
158
|
-
keyof FieldWidgetDomProps extends DomPassThroughKey ? true : never;
|
|
159
|
-
const _everyDeclaredDomKeyIsForwarded: EveryDeclaredDomKeyIsForwarded = true;
|
|
160
|
-
void _everyDeclaredDomKeyIsForwarded;
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Direction 3, added with the core lift (objectui#4431): every key of the SDUI
|
|
164
|
-
* contract's own whitelist that this contract also declares must be forwarded
|
|
165
|
-
* here too, so the two lists cannot silently drift apart. `role` is the ONE
|
|
166
|
-
* measured exception — the SDUI contract declares it, this one does not — and
|
|
167
|
-
* naming it in the `Exclude` is what makes the divergence a deliberate,
|
|
168
|
-
* reviewable act rather than an omission.
|
|
169
|
-
*
|
|
170
|
-
* Catches: a key added to the shared SDUI set that field widgets quietly stop
|
|
171
|
-
* delivering.
|
|
172
|
-
*/
|
|
173
|
-
type SharedSduiKey = Exclude<SduiDomPassThroughKey, 'role'>;
|
|
174
|
-
type EverySharedSduiKeyIsForwarded = SharedSduiKey extends DomPassThroughKey ? true : never;
|
|
175
|
-
const _everySharedSduiKeyIsForwarded: EverySharedSduiKeyIsForwarded = true;
|
|
176
|
-
void _everySharedSduiKeyIsForwarded;
|
|
177
|
-
|
|
178
|
-
/** The subset of `P` this helper forwards, with each key's declared type. */
|
|
179
|
-
export type DomProps<P> = CoreDomProps<P, DomPassThroughKey>;
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Keep only what may legitimately become a DOM attribute, and drop everything
|
|
183
|
-
* else — renderer plumbing, and any extra key an author put on the field
|
|
184
|
-
* config or SDUI node.
|
|
185
|
-
*
|
|
186
|
-
* Replaces the bare `{...props}` spread in every field widget that renders a
|
|
187
|
-
* host element:
|
|
188
|
-
*
|
|
189
|
-
* ```tsx
|
|
190
|
-
* // before — forwards whatever it was handed
|
|
191
|
-
* const { inputType, ...domProps } = props as any;
|
|
192
|
-
* return <Input {...domProps} … />;
|
|
193
|
-
*
|
|
194
|
-
* // after
|
|
195
|
-
* return <Input {...toDomProps(props)} … />;
|
|
196
|
-
* ```
|
|
197
|
-
*
|
|
198
|
-
* Semantic props a widget INTERPRETS (`value`, `onChange`, `field`,
|
|
199
|
-
* `readonly`, `error`, …) are read from `props` as before; this function only
|
|
200
|
-
* governs what gets spread.
|
|
201
|
-
*/
|
|
202
|
-
export function toDomProps<P extends object>(props: P): DomProps<P> {
|
|
203
|
-
return pickDomProps(props, DOM_PASS_THROUGH_KEYS);
|
|
204
|
-
}
|
|
@@ -1,102 +0,0 @@
|
|
|
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
|
-
import { toDomProps } from './toDomProps';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* The props that make a group-labelled widget's rendered surface the thing its
|
|
13
|
-
* host label actually NAMES, on the surfaces that are not controls
|
|
14
|
-
* (objectui#3990).
|
|
15
|
-
*
|
|
16
|
-
* ## What it is for
|
|
17
|
-
*
|
|
18
|
-
* A widget declared `labelling: 'group'` (objectui#3961) is told by the form
|
|
19
|
-
* renderer: your label publishes an `id` and drops its `for`, so name yourself
|
|
20
|
-
* by IDREF. The renderer hands down exactly two facts that address the field as
|
|
21
|
-
* a WHOLE:
|
|
22
|
-
*
|
|
23
|
-
* - `id` — the host's control id (`…-form-item`), the id the label's `for`
|
|
24
|
-
* used to point at;
|
|
25
|
-
* - `aria-labelledby` — the IDREF of that visible label.
|
|
26
|
-
*
|
|
27
|
-
* Every editable branch already consumes them inside its wider `toDomProps`
|
|
28
|
-
* spread. The branches that returned EARLY did not — a field-level
|
|
29
|
-
* `readonly: true`, or an option list with zero offered options — so the label
|
|
30
|
-
* published an id that no element in the document referenced. Measured on
|
|
31
|
-
* `origin/main`, one field per row, reading the host label against the DOM:
|
|
32
|
-
*
|
|
33
|
-
* ```
|
|
34
|
-
* multiselect readonly+value consumers=0 byLabelText=0 namedByRole=[]
|
|
35
|
-
* multiselect readonly+empty consumers=0 byLabelText=0 namedByRole=[]
|
|
36
|
-
* multiselect zeroOptions consumers=0 byLabelText=0 namedByRole=[]
|
|
37
|
-
* multiselect editable consumers=1 byLabelText=1 namedByRole=[group:1]
|
|
38
|
-
* ```
|
|
39
|
-
*
|
|
40
|
-
* All seven group-labelled types measured the same, in every readonly state.
|
|
41
|
-
* This is quieter than the dangling `for` #3961 replaced: a `for` that resolves
|
|
42
|
-
* to nothing can at least be swept for, while "a label published an id and
|
|
43
|
-
* nobody consumed it" looks like healthy markup.
|
|
44
|
-
*
|
|
45
|
-
* ## Why this narrow pair, and not the whole `toDomProps` result
|
|
46
|
-
*
|
|
47
|
-
* Everything else in the DOM pass-through addresses a CONTROL, and a readonly
|
|
48
|
-
* display has none: `aria-describedby` / `aria-required` are announced when
|
|
49
|
-
* focus lands on something focusable, `disabled` / `tabIndex` / the focus
|
|
50
|
-
* handlers only mean something on an interactive element, and `name` is
|
|
51
|
-
* DOM-legal on form controls only — on a `div` it is exactly the leak
|
|
52
|
-
* objectui#3291 sweeps for. Spreading the full result would also put the host's
|
|
53
|
-
* `className` on top of the widget's own, because the widgets whose readonly
|
|
54
|
-
* surface is a placeholder or a plain container (`EmptyValue`, `FileField`,
|
|
55
|
-
* `AddressField`) keep `className` inside `props`.
|
|
56
|
-
*
|
|
57
|
-
* ## Why `role` travels with the two keys
|
|
58
|
-
*
|
|
59
|
-
* `aria-labelledby` on a role-less `div` / `span` names NOTHING — `generic`
|
|
60
|
-
* prohibits an author name — so the pair is inert without a role that supports
|
|
61
|
-
* naming. `group` is the one that fits a readonly surface: it names a set of
|
|
62
|
-
* values (chips, checked labels, stars, file names, a formatted address)
|
|
63
|
-
* without promising the interactivity `textbox` or `radiogroup` would. It is
|
|
64
|
-
* deliberately NOT the role the same widget answers with while editable:
|
|
65
|
-
* `RadioField`'s readonly branch renders the chosen label as text with no radios
|
|
66
|
-
* left in it, and `FileField`'s renders file names with no dropzone button.
|
|
67
|
-
*
|
|
68
|
-
* The role is emitted ONLY when a host actually named the field, mirroring
|
|
69
|
-
* every editable branch's `isLabelledGroup` test: standalone rendering (the
|
|
70
|
-
* grid's inline cell editor, a bare SDUI node) hands down neither key, so every
|
|
71
|
-
* value here is `undefined`, React emits no attribute, and that markup stays
|
|
72
|
-
* byte-identical to what it was.
|
|
73
|
-
*/
|
|
74
|
-
export interface HostGroupProps {
|
|
75
|
-
/** The host's control id — the id its label would have pointed `for` at. */
|
|
76
|
-
id?: string;
|
|
77
|
-
/** IDREF of the host's visible label. */
|
|
78
|
-
'aria-labelledby'?: string;
|
|
79
|
-
/** `'group'` when — and only when — a host named this surface. */
|
|
80
|
-
role?: 'group';
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Read the two whole-field keys out of a widget's leftover props, plus the role
|
|
85
|
-
* that makes them mean something. See {@link HostGroupProps}.
|
|
86
|
-
*
|
|
87
|
-
* Routed through {@link toDomProps} rather than reading `props` directly so the
|
|
88
|
-
* pair keeps ONE gate: those keys reach an element only if the DOM pass-through
|
|
89
|
-
* whitelist still forwards them, and its two compile-time assertions bind that
|
|
90
|
-
* whitelist to the props contract in both directions.
|
|
91
|
-
*/
|
|
92
|
-
export function toHostGroupProps(props: {
|
|
93
|
-
id?: string;
|
|
94
|
-
'aria-labelledby'?: string;
|
|
95
|
-
}): HostGroupProps {
|
|
96
|
-
const { id, 'aria-labelledby': labelledBy } = toDomProps(props);
|
|
97
|
-
return {
|
|
98
|
-
id,
|
|
99
|
-
'aria-labelledby': labelledBy,
|
|
100
|
-
...(labelledBy != null ? { role: 'group' as const } : null),
|
|
101
|
-
};
|
|
102
|
-
}
|