@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,96 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression for objectstack-ai/objectui#3466: single-line cell renderers
|
|
3
|
-
* emitted a BARE INLINE `span.truncate`. An inline box has no width box, so
|
|
4
|
-
* `overflow:hidden` / `text-overflow:ellipsis` never engage — the value
|
|
5
|
-
* rendered at full content width and its tail was silently clipped by
|
|
6
|
-
* whatever ancestor happened to clip (the record-detail card edge), with no
|
|
7
|
-
* ellipsis and no way to read the full text (the detail row's `title` is the
|
|
8
|
-
* inline-edit hint, not the value). Same mechanism as the JSON cell fix in
|
|
9
|
-
* objectui#2578.
|
|
10
|
-
*
|
|
11
|
-
* Pin: every single-line value renderer emits a BLOCK-level, `max-w-full`
|
|
12
|
-
* truncating span that carries the full text in `title` (so hovering the
|
|
13
|
-
* value shows the full text, taking precedence over any ancestor `title`).
|
|
14
|
-
*/
|
|
15
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
16
|
-
import { render, screen } from '@testing-library/react';
|
|
17
|
-
import '@testing-library/jest-dom';
|
|
18
|
-
import React from 'react';
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
TextCellRenderer,
|
|
22
|
-
LookupCellRenderer,
|
|
23
|
-
UserCellRenderer,
|
|
24
|
-
FileCellRenderer,
|
|
25
|
-
SelectCellRenderer,
|
|
26
|
-
} from '../index';
|
|
27
|
-
import { SchemaRendererProvider } from '@object-ui/react';
|
|
28
|
-
|
|
29
|
-
const LONG =
|
|
30
|
-
'A remarkably long referenced-record title that a detail card column can never fit on a single line without an ellipsis';
|
|
31
|
-
|
|
32
|
-
/** The block-level truncation contract every single-line value span must meet. */
|
|
33
|
-
function expectTruncating(el: HTMLElement, fullText: string) {
|
|
34
|
-
expect(el).toHaveClass('block', 'max-w-full', 'truncate');
|
|
35
|
-
expect(el).toHaveAttribute('title', fullText);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
describe('cell renderers truncate for real and expose the full text (issue #3466)', () => {
|
|
39
|
-
it('TextCellRenderer: block-level truncating span with title fallback', () => {
|
|
40
|
-
render(<TextCellRenderer value={LONG} field={{ type: 'text' } as any} />);
|
|
41
|
-
expectTruncating(screen.getByText(LONG), LONG);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('LookupCellRenderer: expanded record object', () => {
|
|
45
|
-
const ds = { find: vi.fn(), findOne: vi.fn() } as any;
|
|
46
|
-
render(
|
|
47
|
-
<SchemaRendererProvider dataSource={ds}>
|
|
48
|
-
<LookupCellRenderer
|
|
49
|
-
value={{ id: 'rec1', name: LONG }}
|
|
50
|
-
field={{ type: 'lookup', reference_to: 'obj_a' } as any}
|
|
51
|
-
/>
|
|
52
|
-
</SchemaRendererProvider>,
|
|
53
|
-
);
|
|
54
|
-
expectTruncating(screen.getByText(LONG), LONG);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('LookupCellRenderer: primitive non-opaque value', () => {
|
|
58
|
-
const ds = { find: vi.fn(), findOne: vi.fn() } as any;
|
|
59
|
-
render(
|
|
60
|
-
<SchemaRendererProvider dataSource={ds}>
|
|
61
|
-
<LookupCellRenderer value={LONG} field={{ type: 'lookup' } as any} />
|
|
62
|
-
</SchemaRendererProvider>,
|
|
63
|
-
);
|
|
64
|
-
expectTruncating(screen.getByText(LONG), LONG);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('UserCellRenderer: display name beside the avatar', () => {
|
|
68
|
-
render(<UserCellRenderer value={{ name: LONG }} field={{ type: 'user' } as any} />);
|
|
69
|
-
expectTruncating(screen.getByText(LONG), LONG);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('FileCellRenderer: single file name', () => {
|
|
73
|
-
render(<FileCellRenderer value={{ name: LONG }} field={{ type: 'file' } as any} />);
|
|
74
|
-
expectTruncating(screen.getByText(LONG), LONG);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('SelectCellRenderer (dot): bounded container with title, shrinkable label', () => {
|
|
78
|
-
render(
|
|
79
|
-
<SelectCellRenderer
|
|
80
|
-
value="opt1"
|
|
81
|
-
field={{
|
|
82
|
-
type: 'select',
|
|
83
|
-
appearance: 'dot',
|
|
84
|
-
options: [{ value: 'opt1', label: LONG }],
|
|
85
|
-
} as any}
|
|
86
|
-
/>,
|
|
87
|
-
);
|
|
88
|
-
const label = screen.getByText(LONG);
|
|
89
|
-
// The label shrinks inside the dot row; the row itself is width-bounded
|
|
90
|
-
// and carries the full text on hover.
|
|
91
|
-
expect(label).toHaveClass('min-w-0', 'truncate');
|
|
92
|
-
const row = label.parentElement!;
|
|
93
|
-
expect(row).toHaveClass('max-w-full');
|
|
94
|
-
expect(row).toHaveAttribute('title', LONG);
|
|
95
|
-
});
|
|
96
|
-
});
|
|
@@ -1,436 +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
|
-
* End-to-end: a composite / grouped field's VISIBLE label is the accessible name
|
|
11
|
-
* of the thing the widget renders (objectui#3961).
|
|
12
|
-
*
|
|
13
|
-
* The sibling case of #3952 (`BooleanField`, a single control that renamed the
|
|
14
|
-
* host's id). Here the widget has no single labelable control to hand the label
|
|
15
|
-
* to, and the failure came in two shapes — measured in a real form on
|
|
16
|
-
* `origin/main`, one field per row, reading each `<label for>` against the DOM:
|
|
17
|
-
*
|
|
18
|
-
* ```
|
|
19
|
-
* address for=_r_0_-form-item -> MISSING byLabelText=0 role+name=0
|
|
20
|
-
* geolocation for=_r_2_-form-item -> MISSING byLabelText=0 role+name=0
|
|
21
|
-
* checkboxes for=_r_4_-form-item -> div byLabelText=0 role+name=0
|
|
22
|
-
* radio for=_r_6_-form-item -> div[radiogroup] byLabelText=0 role+name=0
|
|
23
|
-
* rating for=_r_a_-form-item -> div byLabelText=0 role+name=0
|
|
24
|
-
* file for=_r_b_-form-item -> div[role=button] byLabelText=0 role+name=0
|
|
25
|
-
* ```
|
|
26
|
-
*
|
|
27
|
-
* - **id overwritten** (`address` / `geolocation`): the host id was spread onto
|
|
28
|
-
* the first sub-input and then replaced by that input's own `subId(...)`
|
|
29
|
-
* (objectui#3343 — correctly, sub-inputs need unique ids), so the `for` named
|
|
30
|
-
* an id NOTHING carried. Clicking "Shipping Address" did nothing and the group
|
|
31
|
-
* label was absent from the accessibility tree entirely.
|
|
32
|
-
* - **id kept, on a `div`** (`checkboxes` / `radio` / `rating` / `file`): the
|
|
33
|
-
* `for` "resolved", but `<label for>` on a non-labelable element is inert —
|
|
34
|
-
* `HTMLLabelElement.control` is `null`, so it activates nothing and
|
|
35
|
-
* contributes no name. Both columns on the right show it: zero elements named
|
|
36
|
-
* by that label, in either query.
|
|
37
|
-
*
|
|
38
|
-
* So all six ended in the same place — a visible label that names NOTHING — and
|
|
39
|
-
* the fix is the WAI-ARIA group pattern: the widget declares that it must be
|
|
40
|
-
* named by IDREF (`ComponentMeta.labelling: 'group'`), the form renderer
|
|
41
|
-
* publishes its label's `id` and drops the `for`, and the widget's group
|
|
42
|
-
* container answers with `role="group"` + the handed-down `aria-labelledby`.
|
|
43
|
-
*
|
|
44
|
-
* Two directions per widget, because a one-directional pin here is nearly free to
|
|
45
|
-
* pass by accident: the name must be present when hosted, and STANDALONE
|
|
46
|
-
* rendering (the inline grid editor, a bare SDUI node — nobody hands an id, and
|
|
47
|
-
* there is no host label to point at) must stay exactly as it was.
|
|
48
|
-
*
|
|
49
|
-
* The widgets are registered raw rather than through `registerAllFields()`, which
|
|
50
|
-
* wraps every loader in `React.lazy`: an unbounded module load inside a bounded
|
|
51
|
-
* `findBy`/`waitFor` window is the repo's known flake generator (AGENTS.md
|
|
52
|
-
* 测试纪律, objectui#3010). Same components, no Suspense boundary. The
|
|
53
|
-
* DECLARATION that ships with the real registration is pinned separately, in
|
|
54
|
-
* `group-labelling-declaration.test.ts`.
|
|
55
|
-
*/
|
|
56
|
-
|
|
57
|
-
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
|
|
58
|
-
import { render, screen, cleanup } from '@testing-library/react';
|
|
59
|
-
import '@testing-library/jest-dom';
|
|
60
|
-
import { ComponentRegistry } from '@object-ui/core';
|
|
61
|
-
// Module scope: pulls in the form renderer's registration side effect.
|
|
62
|
-
import '@object-ui/components';
|
|
63
|
-
|
|
64
|
-
import { AddressField } from '../widgets/AddressField';
|
|
65
|
-
import { GeolocationField } from '../widgets/GeolocationField';
|
|
66
|
-
import { CheckboxesField } from '../widgets/CheckboxesField';
|
|
67
|
-
import { RadioField } from '../widgets/RadioField';
|
|
68
|
-
import { RatingField } from '../widgets/RatingField';
|
|
69
|
-
import { FileField } from '../widgets/FileField';
|
|
70
|
-
import { MultiSelectField } from '../widgets/MultiSelectField';
|
|
71
|
-
import { TextField } from '../widgets/TextField';
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* The role each widget's named surface answers with. Not uniform, and
|
|
75
|
-
* deliberately so:
|
|
76
|
-
*
|
|
77
|
-
* - `radio` keeps `radiogroup` — already a member of the group role family, more
|
|
78
|
-
* specific than `group`, and the correct carrier of `aria-invalid` for a set
|
|
79
|
-
* of radios (#3318). Overriding it with `group` would be a regression.
|
|
80
|
-
* - `file` is `button`: it is NOT a composite — it has exactly one control, the
|
|
81
|
-
* dropzone, which is a `div[role="button"]` (the keyboard path to the hidden
|
|
82
|
-
* file input). It needs IDREF labelling because a `div` cannot be
|
|
83
|
-
* `for`-labelled, not because it is a group, so no `role="group"` wrapper is
|
|
84
|
-
* invented for it.
|
|
85
|
-
* - `multiselect` is `group`, and arrived with objectui#3975 rather than #3961:
|
|
86
|
-
* #3961's probe covered the six above, and re-running it over the whole widget
|
|
87
|
-
* map afterwards put `multiselect` on the byte-identical `checkboxes` shape —
|
|
88
|
-
* host id kept, on the chip row's wrapper `div`, `for` inert. Measured on the
|
|
89
|
-
* tree that already had #3961's fix, i.e. this is its residual, not a
|
|
90
|
-
* regression of it:
|
|
91
|
-
*
|
|
92
|
-
* ```
|
|
93
|
-
* checkboxes for=(none) ownId=…-group-label byLabelText=1(div[role=group]) ← #3961
|
|
94
|
-
* multiselect for=…-form-item ownId=(none) byLabelText=0 ← #3975
|
|
95
|
-
* ```
|
|
96
|
-
*/
|
|
97
|
-
const HOSTED: Array<[type: string, role: string]> = [
|
|
98
|
-
['address', 'group'],
|
|
99
|
-
['geolocation', 'group'],
|
|
100
|
-
['checkboxes', 'group'],
|
|
101
|
-
['radio', 'radiogroup'],
|
|
102
|
-
['rating', 'group'],
|
|
103
|
-
['file', 'button'],
|
|
104
|
-
['multiselect', 'group'],
|
|
105
|
-
];
|
|
106
|
-
|
|
107
|
-
const OPTIONS = [
|
|
108
|
-
{ label: 'Alpha', value: 'a' },
|
|
109
|
-
{ label: 'Beta', value: 'b' },
|
|
110
|
-
];
|
|
111
|
-
|
|
112
|
-
const WIDGETS: Record<string, any> = {
|
|
113
|
-
address: AddressField,
|
|
114
|
-
geolocation: GeolocationField,
|
|
115
|
-
checkboxes: CheckboxesField,
|
|
116
|
-
radio: RadioField,
|
|
117
|
-
rating: RatingField,
|
|
118
|
-
file: FileField,
|
|
119
|
-
multiselect: MultiSelectField,
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
beforeAll(() => {
|
|
123
|
-
for (const [type, Component] of Object.entries(WIDGETS)) {
|
|
124
|
-
ComponentRegistry.register(type, Component as any, {
|
|
125
|
-
namespace: 'field',
|
|
126
|
-
skipFallback: true,
|
|
127
|
-
// The declaration under test, mirroring `FIELD_TYPES_GROUP_LABELLED`.
|
|
128
|
-
labelling: 'group',
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
// Positive control: an ordinary single-control widget, undeclared.
|
|
132
|
-
ComponentRegistry.register('text', TextField as any, { namespace: 'field', skipFallback: true });
|
|
133
|
-
}, 30000);
|
|
134
|
-
|
|
135
|
-
beforeEach(() => {
|
|
136
|
-
if (!(Element.prototype as any).scrollIntoView) {
|
|
137
|
-
(Element.prototype as any).scrollIntoView = () => {};
|
|
138
|
-
}
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
afterEach(() => {
|
|
142
|
-
cleanup();
|
|
143
|
-
vi.restoreAllMocks();
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
/** HTML's labelable elements — the only ones a `<label for>` can address. */
|
|
147
|
-
const LABELABLE = new Set(['button', 'input', 'meter', 'output', 'progress', 'select', 'textarea']);
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Every `<label for=…>` in the document, with what it resolves to and whether
|
|
151
|
-
* that target can legally be labelled. Both failure shapes of this issue appear
|
|
152
|
-
* here: `resolves: false` is the overwritten-id case, `labelable: false` the
|
|
153
|
-
* inert-`div` case.
|
|
154
|
-
*/
|
|
155
|
-
function labelTargets(): Array<{ text: string; forId: string; resolves: boolean; labelable: boolean }> {
|
|
156
|
-
return Array.from(document.querySelectorAll('label'))
|
|
157
|
-
.map((el) => {
|
|
158
|
-
const forId = el.getAttribute('for') ?? '';
|
|
159
|
-
const target = forId ? document.getElementById(forId) : null;
|
|
160
|
-
return {
|
|
161
|
-
text: (el.textContent ?? '').trim(),
|
|
162
|
-
forId,
|
|
163
|
-
resolves: !!target,
|
|
164
|
-
labelable: !!target && LABELABLE.has(target.tagName.toLowerCase()),
|
|
165
|
-
};
|
|
166
|
-
})
|
|
167
|
-
.filter((l) => l.forId !== '');
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function fieldConfig(type: string) {
|
|
171
|
-
const config: any = { name: `f_${type}`, label: `Group Label ${type}`, type };
|
|
172
|
-
if (type === 'checkboxes' || type === 'radio' || type === 'multiselect') config.options = OPTIONS;
|
|
173
|
-
return config;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** The real form renderer hosting one field — the #3961 reproduction. */
|
|
177
|
-
function renderForm(fields: any[]) {
|
|
178
|
-
const Form = ComponentRegistry.get('form')!;
|
|
179
|
-
return render(
|
|
180
|
-
<Form
|
|
181
|
-
schema={{
|
|
182
|
-
type: 'form',
|
|
183
|
-
mode: 'create',
|
|
184
|
-
showSubmit: false,
|
|
185
|
-
showCancel: false,
|
|
186
|
-
defaultValues: {},
|
|
187
|
-
fields,
|
|
188
|
-
}}
|
|
189
|
-
/>,
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const hostLabelOf = (name: string): HTMLLabelElement => {
|
|
194
|
-
const el = document.querySelector(`[data-field="${name}"] > label`);
|
|
195
|
-
if (!el) throw new Error(`no host label rendered for field "${name}"`);
|
|
196
|
-
return el as HTMLLabelElement;
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
describe('a form-hosted composite field is NAMED by its host label (objectui#3961)', () => {
|
|
200
|
-
it.each(HOSTED)('%s: the host label is the accessible name of the %s', (type, role) => {
|
|
201
|
-
renderForm([fieldConfig(type)]);
|
|
202
|
-
|
|
203
|
-
// The assertion that was 0 before this fix and is 1 after, for all six.
|
|
204
|
-
const named = screen.getAllByRole(role, { name: `Group Label ${type}` });
|
|
205
|
-
expect(named).toHaveLength(1);
|
|
206
|
-
// `getByLabelText` resolves the same association from the other side.
|
|
207
|
-
expect(screen.getAllByLabelText(`Group Label ${type}`)).toEqual(named);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it.each(HOSTED)('%s: the named element is the one the host handed its id to', (type, role) => {
|
|
211
|
-
// The host id and the host name must land on the SAME element. They came
|
|
212
|
-
// apart in both directions before: `address` dropped the id (overwritten by a
|
|
213
|
-
// sub-input's), `checkboxes` kept it on a container the label could not name.
|
|
214
|
-
renderForm([fieldConfig(type)]);
|
|
215
|
-
|
|
216
|
-
const named = screen.getByRole(role, { name: `Group Label ${type}` });
|
|
217
|
-
expect(named.getAttribute('id')).toMatch(/-form-item$/);
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it.each(HOSTED)('%s: the host label emits an id and no `for`', (type) => {
|
|
221
|
-
renderForm([fieldConfig(type)]);
|
|
222
|
-
|
|
223
|
-
const label = hostLabelOf(`f_${type}`);
|
|
224
|
-
expect(label).not.toHaveAttribute('for');
|
|
225
|
-
expect(label.id).not.toBe('');
|
|
226
|
-
// The IDREF is not dangling — `aria-labelledby` naming a missing id fails
|
|
227
|
-
// silently, which is indistinguishable from success in the markup.
|
|
228
|
-
expect(document.getElementById(label.id)).toBe(label);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it.each(HOSTED)('%s: no label in the field is left with an inert or dangling `for`', (type) => {
|
|
232
|
-
// The class-wide invariant this issue is an instance of, and the one that can
|
|
233
|
-
// only be stated once the group labels stop emitting `for` at all: EVERY
|
|
234
|
-
// remaining `for` (the widgets' own sub-labels) must resolve to a real
|
|
235
|
-
// labelable control.
|
|
236
|
-
renderForm([fieldConfig(type)]);
|
|
237
|
-
|
|
238
|
-
expect(labelTargets().filter((l) => !l.resolves)).toEqual([]);
|
|
239
|
-
expect(labelTargets().filter((l) => !l.labelable)).toEqual([]);
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
it('all of them in ONE form: every group is named, every `for` still resolves', () => {
|
|
243
|
-
renderForm(HOSTED.map(([type]) => fieldConfig(type)));
|
|
244
|
-
|
|
245
|
-
for (const [type, role] of HOSTED) {
|
|
246
|
-
expect(screen.getAllByRole(role, { name: `Group Label ${type}` })).toHaveLength(1);
|
|
247
|
-
}
|
|
248
|
-
expect(labelTargets().filter((l) => !l.resolves || !l.labelable)).toEqual([]);
|
|
249
|
-
});
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
describe('the group name does not swallow the sub-controls\' own names (objectui#3961)', () => {
|
|
253
|
-
it('address sub-inputs keep their own labels', () => {
|
|
254
|
-
// Why `aria-labelledby` is held back from the first sub-input rather than
|
|
255
|
-
// riding along with the rest of the DOM pass-through: `aria-labelledby`
|
|
256
|
-
// OVERRIDES a control's `<label for>`. Spread onto the street box, the group
|
|
257
|
-
// name would REPLACE "Street Address" — the "one input, two labels,
|
|
258
|
-
// concatenated name" outcome the issue rejected option 2 for.
|
|
259
|
-
renderForm([fieldConfig('address')]);
|
|
260
|
-
|
|
261
|
-
expect(screen.getByLabelText('Street Address')).toHaveAccessibleName('Street Address');
|
|
262
|
-
expect(screen.getByLabelText('City')).toHaveAccessibleName('City');
|
|
263
|
-
expect(screen.getByLabelText('Street Address')).not.toHaveAttribute('aria-labelledby');
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it('the description stays on the first focusable sub-input, not on the container', () => {
|
|
267
|
-
// #3318's deliberate choice, preserved: only the `id` moved out to the
|
|
268
|
-
// container. A description or error must be announced when focus lands on
|
|
269
|
-
// something focusable, and a group container is not focusable — so
|
|
270
|
-
// `aria-describedby` stays where a screen reader will actually reach it.
|
|
271
|
-
renderForm([{ ...fieldConfig('address'), description: 'Where we ship it' }]);
|
|
272
|
-
|
|
273
|
-
const street = screen.getByLabelText('Street Address');
|
|
274
|
-
expect(street.getAttribute('aria-describedby')).toContain('-form-item-description');
|
|
275
|
-
const group = screen.getByRole('group', { name: 'Group Label address' });
|
|
276
|
-
expect(group).not.toHaveAttribute('aria-describedby');
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
it('checkboxes options keep their per-option labels', () => {
|
|
280
|
-
renderForm([fieldConfig('checkboxes')]);
|
|
281
|
-
|
|
282
|
-
expect(screen.getByLabelText('Alpha')).toHaveAttribute('role', 'checkbox');
|
|
283
|
-
expect(screen.getByLabelText('Beta')).toHaveAttribute('role', 'checkbox');
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it('multiselect chips keep their own names (objectui#3975)', () => {
|
|
287
|
-
// The chips name themselves from their text CONTENT, not from a sub-label,
|
|
288
|
-
// so the group's `aria-labelledby` sits one level up and must not reach them.
|
|
289
|
-
// `aria-labelledby` on a chip would REPLACE "Alpha" with the group name —
|
|
290
|
-
// the same override this file refuses for `address`'s street box.
|
|
291
|
-
renderForm([fieldConfig('multiselect')]);
|
|
292
|
-
|
|
293
|
-
const alpha = screen.getByRole('button', { name: 'Alpha' });
|
|
294
|
-
expect(alpha).toHaveAccessibleName('Alpha');
|
|
295
|
-
expect(alpha).not.toHaveAttribute('aria-labelledby');
|
|
296
|
-
expect(screen.getByRole('button', { name: 'Beta' })).toBeTruthy();
|
|
297
|
-
// The group is the CONTAINER, not one of the chips.
|
|
298
|
-
expect(screen.getByRole('group', { name: 'Group Label multiselect' })).toBe(alpha.parentElement);
|
|
299
|
-
});
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
describe('an undeclared single-control field keeps the plain `for` association (objectui#3961)', () => {
|
|
303
|
-
it('text: the label still points at the input, with no second naming channel', () => {
|
|
304
|
-
// The positive control for the DECLARATION. `text` is not in
|
|
305
|
-
// `FIELD_TYPES_GROUP_LABELLED`, so it must travel the unchanged path: a
|
|
306
|
-
// working `for`, and NO `aria-labelledby` — two channels naming one control
|
|
307
|
-
// is what #3290 / #3222 / #3952 each refused.
|
|
308
|
-
renderForm([{ name: 'f_text', label: 'Plain Text', type: 'text' }]);
|
|
309
|
-
|
|
310
|
-
const input = screen.getByLabelText('Plain Text');
|
|
311
|
-
expect(input.tagName.toLowerCase()).toBe('input');
|
|
312
|
-
expect(input).not.toHaveAttribute('aria-labelledby');
|
|
313
|
-
const label = hostLabelOf('f_text');
|
|
314
|
-
expect(label.getAttribute('for')).toBe(input.getAttribute('id'));
|
|
315
|
-
expect(label.id).toBe('');
|
|
316
|
-
expect(screen.queryAllByRole('group')).toHaveLength(0);
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
it('a group field and a control field in one form each get their own treatment', () => {
|
|
320
|
-
renderForm([{ name: 'f_text', label: 'Plain Text', type: 'text' }, fieldConfig('rating')]);
|
|
321
|
-
|
|
322
|
-
expect(hostLabelOf('f_text')).toHaveAttribute('for');
|
|
323
|
-
expect(hostLabelOf('f_rating')).not.toHaveAttribute('for');
|
|
324
|
-
expect(screen.getByRole('group', { name: 'Group Label rating' })).toBeTruthy();
|
|
325
|
-
expect(screen.getByLabelText('Plain Text').tagName.toLowerCase()).toBe('input');
|
|
326
|
-
});
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
describe('STANDALONE composite widgets are unchanged (objectui#3961)', () => {
|
|
330
|
-
// `FieldEditWidget` (the grid's inline cell editor) and bare SDUI nodes render
|
|
331
|
-
// these widgets with no id and no host label. There is nothing to associate, so
|
|
332
|
-
// nothing may be emitted: an unnamed `role="group"` adds no information and
|
|
333
|
-
// announces a container that has no name to give.
|
|
334
|
-
|
|
335
|
-
it('address: no group role, and every sub-label still resolves to its input', () => {
|
|
336
|
-
render(
|
|
337
|
-
<AddressField
|
|
338
|
-
value={{ street: '1 Main St' }}
|
|
339
|
-
onChange={() => {}}
|
|
340
|
-
field={{ name: 'shipping', label: 'Shipping Address', type: 'address' } as any}
|
|
341
|
-
/>,
|
|
342
|
-
);
|
|
343
|
-
|
|
344
|
-
expect(screen.queryAllByRole('group')).toHaveLength(0);
|
|
345
|
-
expect(document.querySelector('[aria-labelledby]')).toBeNull();
|
|
346
|
-
expect(labelTargets().filter((l) => !l.resolves || !l.labelable)).toEqual([]);
|
|
347
|
-
// The sub-input ids of objectui#3343 are untouched: only the (absent) HOST id
|
|
348
|
-
// changed place.
|
|
349
|
-
expect(screen.getByLabelText('Street Address')).toHaveValue('1 Main St');
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
it('geolocation: no group role, sub-labels intact', () => {
|
|
353
|
-
render(
|
|
354
|
-
<GeolocationField
|
|
355
|
-
value={{ latitude: 1, longitude: 2 }}
|
|
356
|
-
onChange={() => {}}
|
|
357
|
-
field={{ name: 'where', label: 'Where', type: 'geolocation' } as any}
|
|
358
|
-
/>,
|
|
359
|
-
);
|
|
360
|
-
|
|
361
|
-
expect(screen.queryAllByRole('group')).toHaveLength(0);
|
|
362
|
-
expect(document.querySelector('[aria-labelledby]')).toBeNull();
|
|
363
|
-
expect(labelTargets().filter((l) => !l.resolves || !l.labelable)).toEqual([]);
|
|
364
|
-
expect(screen.getByLabelText('Latitude')).toHaveValue(1);
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
it('checkboxes: the container stays a plain div', () => {
|
|
368
|
-
render(
|
|
369
|
-
<CheckboxesField
|
|
370
|
-
value={[]}
|
|
371
|
-
onChange={() => {}}
|
|
372
|
-
field={{ name: 'tags', label: 'Tags', type: 'checkboxes', options: OPTIONS } as any}
|
|
373
|
-
/>,
|
|
374
|
-
);
|
|
375
|
-
|
|
376
|
-
expect(screen.queryAllByRole('group')).toHaveLength(0);
|
|
377
|
-
expect(screen.getByTestId('checkboxes-tags')).not.toHaveAttribute('role');
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
it('multiselect: the chip row stays a plain div (objectui#3975)', () => {
|
|
381
|
-
render(
|
|
382
|
-
<MultiSelectField
|
|
383
|
-
value={[]}
|
|
384
|
-
onChange={() => {}}
|
|
385
|
-
field={{ name: 'tags', label: 'Tags', type: 'multiselect', options: OPTIONS } as any}
|
|
386
|
-
/>,
|
|
387
|
-
);
|
|
388
|
-
|
|
389
|
-
expect(screen.queryAllByRole('group')).toHaveLength(0);
|
|
390
|
-
expect(screen.getByTestId('multiselect-tags')).not.toHaveAttribute('role');
|
|
391
|
-
// The chips are still reachable by their own names with no group around them.
|
|
392
|
-
expect(screen.getByRole('button', { name: 'Alpha' })).toBeTruthy();
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
it('rating: the container stays a plain div', () => {
|
|
396
|
-
const { container } = render(
|
|
397
|
-
<RatingField
|
|
398
|
-
value={3}
|
|
399
|
-
onChange={() => {}}
|
|
400
|
-
field={{ name: 'score', label: 'Score', type: 'rating' } as any}
|
|
401
|
-
/>,
|
|
402
|
-
);
|
|
403
|
-
|
|
404
|
-
expect(screen.queryAllByRole('group')).toHaveLength(0);
|
|
405
|
-
expect(container.firstElementChild).not.toHaveAttribute('role');
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
it('radio: the radiogroup survives without a name', () => {
|
|
409
|
-
render(
|
|
410
|
-
<RadioField
|
|
411
|
-
value={undefined as any}
|
|
412
|
-
onChange={() => {}}
|
|
413
|
-
field={{ name: 'plan', label: 'Plan', type: 'radio', options: OPTIONS } as any}
|
|
414
|
-
/>,
|
|
415
|
-
);
|
|
416
|
-
|
|
417
|
-
// Radix owns this role; nothing in this change may take it away.
|
|
418
|
-
const group = screen.getByRole('radiogroup');
|
|
419
|
-
expect(group).not.toHaveAttribute('aria-labelledby');
|
|
420
|
-
expect(labelTargets().filter((l) => !l.resolves || !l.labelable)).toEqual([]);
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
it('file: the dropzone keeps role=button and gains no name it was not given', () => {
|
|
424
|
-
render(
|
|
425
|
-
<FileField
|
|
426
|
-
value={null}
|
|
427
|
-
onChange={() => {}}
|
|
428
|
-
field={{ name: 'attachment', label: 'Attachment', type: 'file' } as any}
|
|
429
|
-
/>,
|
|
430
|
-
);
|
|
431
|
-
|
|
432
|
-
const dropzone = screen.getByRole('button');
|
|
433
|
-
expect(dropzone).not.toHaveAttribute('aria-labelledby');
|
|
434
|
-
expect(screen.queryAllByRole('group')).toHaveLength(0);
|
|
435
|
-
});
|
|
436
|
-
});
|