@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,195 +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
|
-
* `RichTextField` honours `mobile_fullscreen` (objectui#3301).
|
|
11
|
-
*
|
|
12
|
-
* `ObjectFormSchema.mobile.fullscreenLongText` has always promised, in its own
|
|
13
|
-
* JSDoc, that "textarea/rich-text get an expand button", and `ObjectForm` has
|
|
14
|
-
* always stamped `mobile_fullscreen` onto `field:markdown` / `field:html`
|
|
15
|
-
* fields to deliver it. This widget — the one both of those types resolve to —
|
|
16
|
-
* simply never read the flag. Producer present, consumer absent: the flag
|
|
17
|
-
* arrived at the widget and nothing happened, with nothing anywhere reporting
|
|
18
|
-
* that half the documented feature was inert.
|
|
19
|
-
*
|
|
20
|
-
* So the assertions below are about the READ and the interaction it drives.
|
|
21
|
-
* The sibling file `TextAreaField.mobileFullscreen.test.tsx` pins the same
|
|
22
|
-
* contract for the other widget; both now go through the shared
|
|
23
|
-
* `FullscreenFieldEditor`, and the pair is what keeps one form-level setting
|
|
24
|
-
* producing one behaviour. Divergence in either widget fails one of the two.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
28
|
-
import { render, screen, fireEvent } from '@testing-library/react';
|
|
29
|
-
import '@testing-library/jest-dom';
|
|
30
|
-
|
|
31
|
-
import { RichTextField } from '../RichTextField';
|
|
32
|
-
import type { FieldWidgetComponentProps } from '../types';
|
|
33
|
-
import type { FieldMetadata } from '@object-ui/types';
|
|
34
|
-
|
|
35
|
-
const fieldMeta = (extra: Record<string, unknown> = {}) =>
|
|
36
|
-
({
|
|
37
|
-
name: 'notes',
|
|
38
|
-
label: 'Notes',
|
|
39
|
-
type: 'markdown',
|
|
40
|
-
...extra,
|
|
41
|
-
}) as unknown as FieldMetadata;
|
|
42
|
-
|
|
43
|
-
describe('RichTextField mobile fullscreen — the metadata flag is the only source', () => {
|
|
44
|
-
it('renders the expand affordance when the field metadata sets mobile_fullscreen', () => {
|
|
45
|
-
render(
|
|
46
|
-
<RichTextField
|
|
47
|
-
value="# hello"
|
|
48
|
-
onChange={() => {}}
|
|
49
|
-
field={fieldMeta({ mobile_fullscreen: true })}
|
|
50
|
-
/>,
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
expect(screen.getByTestId('richtext-fullscreen-toggle')).toBeInTheDocument();
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('renders no affordance when the metadata does not opt in', () => {
|
|
57
|
-
render(<RichTextField value="# hello" onChange={() => {}} field={fieldMeta()} />);
|
|
58
|
-
|
|
59
|
-
expect(screen.queryByTestId('richtext-fullscreen-toggle')).not.toBeInTheDocument();
|
|
60
|
-
expect(screen.queryByTestId('richtext-fullscreen-dialog')).not.toBeInTheDocument();
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('opens the fullscreen dialog and commits the edited draft', () => {
|
|
64
|
-
const onChange = vi.fn();
|
|
65
|
-
render(
|
|
66
|
-
<RichTextField
|
|
67
|
-
value="# hello"
|
|
68
|
-
onChange={onChange}
|
|
69
|
-
field={fieldMeta({ mobile_fullscreen: true })}
|
|
70
|
-
/>,
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
// Closed until the affordance is used — the dialog is not just mounted.
|
|
74
|
-
expect(screen.queryByTestId('richtext-fullscreen-dialog')).not.toBeInTheDocument();
|
|
75
|
-
|
|
76
|
-
fireEvent.click(screen.getByTestId('richtext-fullscreen-toggle'));
|
|
77
|
-
expect(screen.getByTestId('richtext-fullscreen-dialog')).toBeInTheDocument();
|
|
78
|
-
|
|
79
|
-
const dialogInput = screen.getByTestId('richtext-fullscreen-input');
|
|
80
|
-
expect(dialogInput).toHaveValue('# hello');
|
|
81
|
-
|
|
82
|
-
fireEvent.change(dialogInput, { target: { value: '# hello from fullscreen' } });
|
|
83
|
-
// The draft is local until "Done" — the host is not notified per keystroke,
|
|
84
|
-
// so a react-hook-form field is not marked dirty by an edit the user may
|
|
85
|
-
// still cancel.
|
|
86
|
-
expect(onChange).not.toHaveBeenCalled();
|
|
87
|
-
|
|
88
|
-
fireEvent.click(screen.getByTestId('richtext-fullscreen-save'));
|
|
89
|
-
expect(onChange).toHaveBeenCalledWith('# hello from fullscreen');
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('discards the draft on Cancel', () => {
|
|
93
|
-
const onChange = vi.fn();
|
|
94
|
-
render(
|
|
95
|
-
<RichTextField
|
|
96
|
-
value="# hello"
|
|
97
|
-
onChange={onChange}
|
|
98
|
-
field={fieldMeta({ mobile_fullscreen: true })}
|
|
99
|
-
/>,
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
fireEvent.click(screen.getByTestId('richtext-fullscreen-toggle'));
|
|
103
|
-
fireEvent.change(screen.getByTestId('richtext-fullscreen-input'), {
|
|
104
|
-
target: { value: 'thrown away' },
|
|
105
|
-
});
|
|
106
|
-
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
|
107
|
-
|
|
108
|
-
expect(onChange).not.toHaveBeenCalled();
|
|
109
|
-
expect(screen.queryByTestId('richtext-fullscreen-dialog')).not.toBeInTheDocument();
|
|
110
|
-
|
|
111
|
-
// …and re-opening seeds from the COMMITTED value, not from the abandoned
|
|
112
|
-
// draft. Holding the draft across opens would silently resurrect an edit
|
|
113
|
-
// the user explicitly cancelled.
|
|
114
|
-
fireEvent.click(screen.getByTestId('richtext-fullscreen-toggle'));
|
|
115
|
-
expect(screen.getByTestId('richtext-fullscreen-input')).toHaveValue('# hello');
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('puts the REAL editor in the dialog, not a degraded copy of it', () => {
|
|
119
|
-
// The acceptance criterion that the dialog holds the rich editor itself:
|
|
120
|
-
// the same surface renders in both positions (same format indicator, same
|
|
121
|
-
// editor), so whatever this widget's editor grows into, the fullscreen
|
|
122
|
-
// view gets it too rather than drifting into a bare textarea.
|
|
123
|
-
render(
|
|
124
|
-
<RichTextField
|
|
125
|
-
value=""
|
|
126
|
-
onChange={() => {}}
|
|
127
|
-
field={fieldMeta({ mobile_fullscreen: true, format: 'html', placeholder: 'Write HTML' })}
|
|
128
|
-
/>,
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
expect(screen.getAllByText('Format: html')).toHaveLength(1);
|
|
132
|
-
|
|
133
|
-
fireEvent.click(screen.getByTestId('richtext-fullscreen-toggle'));
|
|
134
|
-
|
|
135
|
-
// Inline + dialog: the indicator and the placeholder are present twice.
|
|
136
|
-
expect(screen.getAllByText('Format: html')).toHaveLength(2);
|
|
137
|
-
expect(screen.getAllByPlaceholderText('Write HTML')).toHaveLength(2);
|
|
138
|
-
expect(screen.getByTestId('richtext-fullscreen-input')).toBeEnabled();
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('titles the dialog and the affordance with the field label', () => {
|
|
142
|
-
render(
|
|
143
|
-
<RichTextField
|
|
144
|
-
value=""
|
|
145
|
-
onChange={() => {}}
|
|
146
|
-
field={fieldMeta({ mobile_fullscreen: true, label: 'Release notes' })}
|
|
147
|
-
/>,
|
|
148
|
-
);
|
|
149
|
-
|
|
150
|
-
expect(
|
|
151
|
-
screen.getByRole('button', { name: 'Edit Release notes fullscreen' }),
|
|
152
|
-
).toBeInTheDocument();
|
|
153
|
-
|
|
154
|
-
fireEvent.click(screen.getByTestId('richtext-fullscreen-toggle'));
|
|
155
|
-
expect(screen.getByRole('heading', { name: 'Release notes' })).toBeInTheDocument();
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('renders no affordance at all in readonly mode', () => {
|
|
159
|
-
render(
|
|
160
|
-
<RichTextField
|
|
161
|
-
value="# hello"
|
|
162
|
-
onChange={() => {}}
|
|
163
|
-
readonly
|
|
164
|
-
field={fieldMeta({ mobile_fullscreen: true })}
|
|
165
|
-
/>,
|
|
166
|
-
);
|
|
167
|
-
|
|
168
|
-
expect(screen.queryByTestId('richtext-fullscreen-toggle')).not.toBeInTheDocument();
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
describe('RichTextField mobile fullscreen — no second carrier', () => {
|
|
173
|
-
it('ignores the flag on prop spellings a host might invent', () => {
|
|
174
|
-
// Same convergence `TextAreaField` went through in objectui#3232/#3233:
|
|
175
|
-
// the metadata is the ONE carrier. A host passing the flag as a prop must
|
|
176
|
-
// get nothing — a tolerant `??` chain here is exactly where a misspelled
|
|
177
|
-
// AI-authored flag would hide and look supported.
|
|
178
|
-
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
179
|
-
try {
|
|
180
|
-
const hostProps = {
|
|
181
|
-
mobileFullscreen: true,
|
|
182
|
-
mobile_fullscreen: true,
|
|
183
|
-
schema: fieldMeta({ mobile_fullscreen: true }),
|
|
184
|
-
} as unknown as Partial<FieldWidgetComponentProps<string>>;
|
|
185
|
-
|
|
186
|
-
render(
|
|
187
|
-
<RichTextField value="" onChange={() => {}} field={fieldMeta()} {...hostProps} />,
|
|
188
|
-
);
|
|
189
|
-
|
|
190
|
-
expect(screen.queryByTestId('richtext-fullscreen-toggle')).not.toBeInTheDocument();
|
|
191
|
-
} finally {
|
|
192
|
-
consoleError.mockRestore();
|
|
193
|
-
}
|
|
194
|
-
});
|
|
195
|
-
});
|
|
@@ -1,417 +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
|
-
* HOW OFTEN the textarea character counter speaks, and what a screen reader can
|
|
11
|
-
* find out without typing at all — objectui#3408.
|
|
12
|
-
*
|
|
13
|
-
* objectui#3406 keyed the counter's sentence and deliberately changed no
|
|
14
|
-
* behaviour. This file is the behaviour half.
|
|
15
|
-
*
|
|
16
|
-
* ## The measurement this replaces
|
|
17
|
-
*
|
|
18
|
-
* Probe run against `origin/main` (zh session, `maxLength: 500`, the sentence
|
|
19
|
-
* "Follow up with the customer about the renewal quote." typed one character at
|
|
20
|
-
* a time — reproduced verbatim by `speaks nothing across the issue's 52
|
|
21
|
-
* keystroke probe` below):
|
|
22
|
-
*
|
|
23
|
-
* ```json
|
|
24
|
-
* { "keystrokes": 52, "announcementsFired": 52, "distinctAnnouncements": 52,
|
|
25
|
-
* "totalCharsSpoken": 979, "regionIsAriaHidden": null,
|
|
26
|
-
* "textareaDescribedBy": null }
|
|
27
|
-
* ```
|
|
28
|
-
*
|
|
29
|
-
* 979 spoken characters for 52 typed ones — ~19x — every one of them cutting
|
|
30
|
-
* off the screen reader's echo of the letter the user had just pressed. The
|
|
31
|
-
* counter element was simultaneously the visible digits, the translated
|
|
32
|
-
* sentence and the live region, so "re-render" and "announce" were the same
|
|
33
|
-
* event.
|
|
34
|
-
*
|
|
35
|
-
* ## The shape being pinned (GOV.UK character-count, three nodes)
|
|
36
|
-
*
|
|
37
|
-
* 1. the visible `{n}/{max}` digits, `aria-hidden` — decorative;
|
|
38
|
-
* 2. a visually-hidden DESCRIPTION carrying the full sentence, reached through
|
|
39
|
-
* the textarea's `aria-describedby` — read once on focus;
|
|
40
|
-
* 3. a visually-hidden `aria-live="polite"` STATUS region, silent until the
|
|
41
|
-
* value is inside the last 10% / 20 characters of the cap, and updated only
|
|
42
|
-
* after the typist pauses.
|
|
43
|
-
*
|
|
44
|
-
* ## Why fake timers
|
|
45
|
-
*
|
|
46
|
-
* The debounce is 1000ms. Ten real pauses would add ten seconds of wall clock
|
|
47
|
-
* to every CI run and would still be a race under load (AGENTS.md's flaky-test
|
|
48
|
-
* section). `vi.useFakeTimers()` makes the pause an assertion instead of a
|
|
49
|
-
* wait: `advanceTimersByTime(999)` proving silence is a statement no sleep can
|
|
50
|
-
* make.
|
|
51
|
-
*
|
|
52
|
-
* ## Counting rule used throughout
|
|
53
|
-
*
|
|
54
|
-
* A screen reader speaks when a live region's content CHANGES to something
|
|
55
|
-
* non-empty. Emptying it says nothing. `announcementsOf()` below counts exactly
|
|
56
|
-
* that, so the numbers here are comparable to the probe's.
|
|
57
|
-
*/
|
|
58
|
-
import React from 'react';
|
|
59
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
60
|
-
import { render, screen, fireEvent, act } from '@testing-library/react';
|
|
61
|
-
import '@testing-library/jest-dom';
|
|
62
|
-
import { I18nProvider } from '@object-ui/i18n';
|
|
63
|
-
|
|
64
|
-
import { TextAreaField } from '../TextAreaField';
|
|
65
|
-
import type { FieldWidgetComponentProps } from '../types';
|
|
66
|
-
import type { FieldMetadata } from '@object-ui/types';
|
|
67
|
-
|
|
68
|
-
const DEBOUNCE_MS = 1000;
|
|
69
|
-
|
|
70
|
-
const textareaField = (extra: Record<string, unknown> = {}) =>
|
|
71
|
-
({ name: 'notes', type: 'textarea', ...extra }) as unknown as FieldMetadata;
|
|
72
|
-
|
|
73
|
-
/** The `aria-live` node — now the ONLY one in this widget, by construction. */
|
|
74
|
-
const statusRegion = () => document.querySelector('[aria-live="polite"]');
|
|
75
|
-
const statusText = () => statusRegion()?.textContent ?? null;
|
|
76
|
-
const visibleCounter = () => document.querySelector('[data-testid="textarea-character-count"]');
|
|
77
|
-
const textarea = () => screen.getByRole('textbox');
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Resolve the textarea's accessible DESCRIPTION the way an assistive technology
|
|
81
|
-
* does — follow `aria-describedby`, in order, and concatenate the referenced
|
|
82
|
-
* nodes' text. Asserting on this rather than on a node found by test id is what
|
|
83
|
-
* makes "the association actually works" part of the assertion.
|
|
84
|
-
*/
|
|
85
|
-
const describedText = () => {
|
|
86
|
-
const ids = (textarea().getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
|
|
87
|
-
return ids.map((id) => document.getElementById(id)?.textContent ?? '').join(' ').trim();
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
/** A host that owns the value, so `fireEvent.change` really is a keystroke. */
|
|
91
|
-
// `maxLength` has NO default on purpose: a default would swallow the
|
|
92
|
-
// no-cap cases, which pass it as `undefined`.
|
|
93
|
-
function Host({
|
|
94
|
-
initial = '',
|
|
95
|
-
maxLength,
|
|
96
|
-
...rest
|
|
97
|
-
}: { initial?: string; maxLength?: number } & Partial<FieldWidgetComponentProps<string>>) {
|
|
98
|
-
const [v, setV] = React.useState(initial);
|
|
99
|
-
return (
|
|
100
|
-
<TextAreaField value={v} onChange={setV} field={textareaField({ maxLength })} {...rest} />
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const renderIn = (language: string, element: React.ReactElement) =>
|
|
105
|
-
render(
|
|
106
|
-
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
|
|
107
|
-
{element}
|
|
108
|
-
</I18nProvider>,
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
const pause = (ms = DEBOUNCE_MS) => act(() => { vi.advanceTimersByTime(ms); });
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Type `text` one character at a time onto `prefix`, sampling the status region
|
|
115
|
-
* after every keystroke, and return every distinct non-empty sentence it was
|
|
116
|
-
* changed to. `pauseEvery` = how many keystrokes between 1s pauses (0 = the
|
|
117
|
-
* issue's probe: continuous typing, never pausing).
|
|
118
|
-
*/
|
|
119
|
-
function announcementsOf(prefix: string, text: string, pauseEvery = 0): string[] {
|
|
120
|
-
const spoken: string[] = [];
|
|
121
|
-
let last = statusText();
|
|
122
|
-
const record = () => {
|
|
123
|
-
const now = statusText();
|
|
124
|
-
if (now !== last) {
|
|
125
|
-
if (now) spoken.push(now);
|
|
126
|
-
last = now;
|
|
127
|
-
}
|
|
128
|
-
};
|
|
129
|
-
for (let i = 1; i <= text.length; i++) {
|
|
130
|
-
fireEvent.change(textarea(), { target: { value: prefix + text.slice(0, i) } });
|
|
131
|
-
record();
|
|
132
|
-
if (pauseEvery && i % pauseEvery === 0) {
|
|
133
|
-
pause();
|
|
134
|
-
record();
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
return spoken;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
beforeEach(() => vi.useFakeTimers());
|
|
141
|
-
afterEach(() => vi.useRealTimers());
|
|
142
|
-
|
|
143
|
-
describe('the counter no longer narrates every keystroke (objectui#3408)', () => {
|
|
144
|
-
const SENTENCE = 'Follow up with the customer about the renewal quote.';
|
|
145
|
-
|
|
146
|
-
it('speaks nothing across the issue`s 52-keystroke probe', () => {
|
|
147
|
-
// The probe, verbatim: zh, maxLength 500, 52 characters typed one at a
|
|
148
|
-
// time. Measured before this change: 52 announcements / 979 characters.
|
|
149
|
-
expect(SENTENCE).toHaveLength(52);
|
|
150
|
-
renderIn('zh', <Host maxLength={500} />);
|
|
151
|
-
|
|
152
|
-
const spoken = announcementsOf('', SENTENCE);
|
|
153
|
-
|
|
154
|
-
expect(spoken).toEqual([]);
|
|
155
|
-
expect(spoken.reduce((n, s) => n + s.length, 0)).toBe(0);
|
|
156
|
-
// The value really was typed — otherwise the silence above proves nothing.
|
|
157
|
-
expect(visibleCounter()).toHaveTextContent('52/500');
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it('stays silent even if the typist pauses a full second after EVERY keystroke', () => {
|
|
161
|
-
// The pessimistic variant, and the one that separates the two mechanisms:
|
|
162
|
-
// with a flush after every character the debounce can suppress nothing, so
|
|
163
|
-
// anything still silent here is silent because of the THRESHOLD. 52 of 500
|
|
164
|
-
// used leaves 448 remaining, nowhere near the last 50.
|
|
165
|
-
renderIn('zh', <Host maxLength={500} />);
|
|
166
|
-
|
|
167
|
-
expect(announcementsOf('', SENTENCE, 1)).toEqual([]);
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it('speaks a single-digit number of times over a run that ends AT the cap', () => {
|
|
171
|
-
// The realistic worst case: the same 52 keystrokes, but landing exactly on
|
|
172
|
-
// a 500-character cap, with a pause every 10 characters. Every pause from
|
|
173
|
-
// here on is inside the warning band, so this is the run that produces the
|
|
174
|
-
// most speech the design allows — and it is still 5, against the 52 the
|
|
175
|
-
// probe measured for a run that never came near the limit at all.
|
|
176
|
-
renderIn('zh', <Host maxLength={500} initial={'x'.repeat(448)} />);
|
|
177
|
-
|
|
178
|
-
const spoken = announcementsOf('x'.repeat(448), SENTENCE, 10);
|
|
179
|
-
|
|
180
|
-
expect(spoken.length).toBeLessThan(10);
|
|
181
|
-
expect(spoken).toEqual([
|
|
182
|
-
'还可输入 42 个字符',
|
|
183
|
-
'还可输入 32 个字符',
|
|
184
|
-
'还可输入 22 个字符',
|
|
185
|
-
'还可输入 12 个字符',
|
|
186
|
-
'还可输入 2 个字符',
|
|
187
|
-
]);
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
describe('the status region is debounced (objectui#3408)', () => {
|
|
192
|
-
it('says nothing until the typing stops', () => {
|
|
193
|
-
// maxLength 100 → the warning band is the last 20 characters, so every
|
|
194
|
-
// keystroke below is inside it and only the PAUSE is holding speech back.
|
|
195
|
-
renderIn('en', <Host maxLength={100} initial={'x'.repeat(80)} />);
|
|
196
|
-
expect(statusText()).toBe('');
|
|
197
|
-
|
|
198
|
-
announcementsOf('x'.repeat(80), 'abcde');
|
|
199
|
-
|
|
200
|
-
// Five keystrokes inside the warning band, still nothing spoken.
|
|
201
|
-
expect(statusText()).toBe('');
|
|
202
|
-
pause(DEBOUNCE_MS - 1);
|
|
203
|
-
// 999ms of quiet is not a pause. Asserting the near-miss is the difference
|
|
204
|
-
// between pinning a debounce and pinning "it eventually says something".
|
|
205
|
-
expect(statusText()).toBe('');
|
|
206
|
-
|
|
207
|
-
pause(1);
|
|
208
|
-
expect(statusText()).toBe('Characters remaining: 15');
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it('collapses a burst into ONE announcement, not one per keystroke', () => {
|
|
212
|
-
renderIn('en', <Host maxLength={100} initial={'x'.repeat(80)} />);
|
|
213
|
-
|
|
214
|
-
const spoken = announcementsOf('x'.repeat(80), 'abcdefghij');
|
|
215
|
-
pause();
|
|
216
|
-
|
|
217
|
-
expect(spoken).toEqual([]);
|
|
218
|
-
expect(statusText()).toBe('Characters remaining: 10');
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
it('does not re-announce a count the user typed their way back to', () => {
|
|
222
|
-
// Type one character, pause, delete it, pause. The sentence is identical
|
|
223
|
-
// both times, so the DOM does not change and nothing is spoken twice.
|
|
224
|
-
renderIn('en', <Host maxLength={100} initial={'x'.repeat(85)} />);
|
|
225
|
-
pause();
|
|
226
|
-
expect(statusText()).toBe('Characters remaining: 15');
|
|
227
|
-
|
|
228
|
-
fireEvent.change(textarea(), { target: { value: 'x'.repeat(86) } });
|
|
229
|
-
pause();
|
|
230
|
-
expect(statusText()).toBe('Characters remaining: 14');
|
|
231
|
-
|
|
232
|
-
fireEvent.change(textarea(), { target: { value: 'x'.repeat(85) } });
|
|
233
|
-
pause();
|
|
234
|
-
expect(statusText()).toBe('Characters remaining: 15');
|
|
235
|
-
});
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
describe('the status region is threshold-gated (objectui#3408)', () => {
|
|
239
|
-
it('is silent one character OUTSIDE the band and speaks one character inside it', () => {
|
|
240
|
-
// 500-character cap → 10% is 50, which the typist reaches before the flat
|
|
241
|
-
// 20, so 50 is the boundary. Both directions asserted: a gate that never
|
|
242
|
-
// opens and a gate that is always open both pass a one-sided check.
|
|
243
|
-
const { unmount } = renderIn('en', <Host maxLength={500} initial={'x'.repeat(449)} />);
|
|
244
|
-
pause();
|
|
245
|
-
expect(statusText()).toBe('');
|
|
246
|
-
unmount();
|
|
247
|
-
|
|
248
|
-
renderIn('en', <Host maxLength={500} initial={'x'.repeat(450)} />);
|
|
249
|
-
pause();
|
|
250
|
-
expect(statusText()).toBe('Characters remaining: 50');
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it('falls back to the flat 20 characters when 10% of the cap is smaller', () => {
|
|
254
|
-
// 100-character cap: 10% is 10, but 20 remaining arrives FIRST, so 20 is
|
|
255
|
-
// the boundary. This is the case that pins `max()` — take `min()` and the
|
|
256
|
-
// warning arrives ten characters too late, which no assertion on the 500
|
|
257
|
-
// case above can see.
|
|
258
|
-
const { unmount } = renderIn('en', <Host maxLength={100} initial={'x'.repeat(79)} />);
|
|
259
|
-
pause();
|
|
260
|
-
expect(statusText()).toBe('');
|
|
261
|
-
unmount();
|
|
262
|
-
|
|
263
|
-
renderIn('en', <Host maxLength={100} initial={'x'.repeat(80)} />);
|
|
264
|
-
pause();
|
|
265
|
-
expect(statusText()).toBe('Characters remaining: 20');
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
it('goes quiet again — immediately — when the user deletes back out of the band', () => {
|
|
269
|
-
renderIn('en', <Host maxLength={500} initial={'x'.repeat(460)} />);
|
|
270
|
-
pause();
|
|
271
|
-
expect(statusText()).toBe('Characters remaining: 40');
|
|
272
|
-
|
|
273
|
-
fireEvent.change(textarea(), { target: { value: 'x'.repeat(400) } });
|
|
274
|
-
|
|
275
|
-
// No pause needed: emptying a live region announces nothing, so silence is
|
|
276
|
-
// free and should not wait a second to take effect.
|
|
277
|
-
expect(statusText()).toBe('');
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
it('never re-announces the STALE count when the user types back into the band', () => {
|
|
281
|
-
// The excursion case. Reach the band, be announced, delete far out of it,
|
|
282
|
-
// then type back in at a different length. The naive "show the last settled
|
|
283
|
-
// sentence whenever we are in the band" renders the count from BEFORE the
|
|
284
|
-
// excursion for a full second before the timer corrects it — a wrong
|
|
285
|
-
// number, spoken, which is worse than the silence it replaces.
|
|
286
|
-
renderIn('en', <Host maxLength={500} initial={'x'.repeat(460)} />);
|
|
287
|
-
pause();
|
|
288
|
-
expect(statusText()).toBe('Characters remaining: 40');
|
|
289
|
-
|
|
290
|
-
fireEvent.change(textarea(), { target: { value: 'x'.repeat(300) } });
|
|
291
|
-
expect(statusText()).toBe('');
|
|
292
|
-
|
|
293
|
-
fireEvent.change(textarea(), { target: { value: 'x'.repeat(455) } });
|
|
294
|
-
// The moment of the bug: 45 is the truth, 40 is what was cached.
|
|
295
|
-
expect(statusText()).toBe('');
|
|
296
|
-
pause();
|
|
297
|
-
expect(statusText()).toBe('Characters remaining: 45');
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
it('clamps an over-long value to zero remaining rather than counting backwards', () => {
|
|
301
|
-
// Reachable when a cap is lowered after the record was saved. "-3 remaining"
|
|
302
|
-
// is not a sentence, and the textarea's own maxLength blocks further input,
|
|
303
|
-
// so 0 is the true actionable number. The full, honest count stays in the
|
|
304
|
-
// description.
|
|
305
|
-
renderIn('en', <Host maxLength={500} initial={'x'.repeat(503)} />);
|
|
306
|
-
pause();
|
|
307
|
-
|
|
308
|
-
expect(statusText()).toBe('Characters remaining: 0');
|
|
309
|
-
expect(describedText()).toBe('Character count: 503 of 500');
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
it('treats a very small cap as near-limit from the first keystroke', () => {
|
|
313
|
-
// 10-character cap: the flat 20 exceeds the whole field, so every state is
|
|
314
|
-
// "nearly full". That is correct, and the debounce still bounds it to one
|
|
315
|
-
// announcement per pause.
|
|
316
|
-
renderIn('en', <Host maxLength={10} initial="ab" />);
|
|
317
|
-
pause();
|
|
318
|
-
|
|
319
|
-
expect(statusText()).toBe('Characters remaining: 8');
|
|
320
|
-
});
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
describe('the counter is reachable without typing (objectui#3408)', () => {
|
|
324
|
-
it('describes the textarea, so focus announces the cap once', () => {
|
|
325
|
-
// Measured before this change: `textareaDescribedBy: null` — a screen
|
|
326
|
-
// reader user learned there was a limit only by bumping into it.
|
|
327
|
-
renderIn('en', <Host maxLength={500} initial="hello" />);
|
|
328
|
-
|
|
329
|
-
expect(textarea()).toHaveAttribute('aria-describedby');
|
|
330
|
-
expect(describedText()).toBe('Character count: 5 of 500');
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
it('APPENDS to the host`s aria-describedby instead of replacing it', () => {
|
|
334
|
-
// `<FormControl>` is a Radix Slot: it hands the control an
|
|
335
|
-
// `aria-describedby` naming the field description and error message, and
|
|
336
|
-
// it arrives here through `toDomProps`. Clobbering it would trade "no cap
|
|
337
|
-
// announced" for "no ERROR announced".
|
|
338
|
-
render(
|
|
339
|
-
<>
|
|
340
|
-
<span id="host-desc">Internal notes only</span>
|
|
341
|
-
<Host maxLength={500} initial="hello" aria-describedby="host-desc" />
|
|
342
|
-
</>,
|
|
343
|
-
);
|
|
344
|
-
|
|
345
|
-
const ids = textarea().getAttribute('aria-describedby')!.split(' ');
|
|
346
|
-
expect(ids[0]).toBe('host-desc');
|
|
347
|
-
expect(ids).toHaveLength(2);
|
|
348
|
-
expect(describedText()).toBe('Internal notes only Character count: 5 of 500');
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
it('keeps the visible digits out of the accessibility tree', () => {
|
|
352
|
-
// Measured before: `regionIsAriaHidden: null`. The digits are a glance
|
|
353
|
-
// affordance; unhidden they make a screen reader read "5 slash 500" on top
|
|
354
|
-
// of the description that says it in words.
|
|
355
|
-
renderIn('en', <Host maxLength={500} initial="hello" />);
|
|
356
|
-
|
|
357
|
-
expect(visibleCounter()).toHaveAttribute('aria-hidden', 'true');
|
|
358
|
-
expect(visibleCounter()).toHaveTextContent('5/500');
|
|
359
|
-
expect(visibleCounter()).not.toHaveAttribute('aria-live');
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
it('renders the live region EMPTY at mount rather than conditionally', () => {
|
|
363
|
-
// A live region inserted at the same moment as its text is not reliably
|
|
364
|
-
// announced — it has to be in the DOM first. `{status && <span …>}` would
|
|
365
|
-
// pass every "is it silent?" assertion above and break the one case the
|
|
366
|
-
// whole gate exists to serve.
|
|
367
|
-
renderIn('en', <Host maxLength={500} />);
|
|
368
|
-
|
|
369
|
-
expect(statusRegion()).not.toBeNull();
|
|
370
|
-
expect(statusText()).toBe('');
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
it('adds neither node when the field declares no cap', () => {
|
|
374
|
-
render(<Host maxLength={undefined} />);
|
|
375
|
-
|
|
376
|
-
expect(statusRegion()).toBeNull();
|
|
377
|
-
expect(visibleCounter()).toBeNull();
|
|
378
|
-
expect(textarea()).not.toHaveAttribute('aria-describedby');
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
it('leaves a host describedby alone when there is no cap to describe', () => {
|
|
382
|
-
render(
|
|
383
|
-
<>
|
|
384
|
-
<span id="host-desc">Internal notes only</span>
|
|
385
|
-
<Host maxLength={undefined} aria-describedby="host-desc" />
|
|
386
|
-
</>,
|
|
387
|
-
);
|
|
388
|
-
|
|
389
|
-
expect(textarea()).toHaveAttribute('aria-describedby', 'host-desc');
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
it('gives two textareas on one form distinct description ids', () => {
|
|
393
|
-
// The ids come from `useId()`, per instance. Shared ids would point both
|
|
394
|
-
// fields at whichever description rendered last.
|
|
395
|
-
render(
|
|
396
|
-
<>
|
|
397
|
-
<Host maxLength={500} initial="a" />
|
|
398
|
-
<Host maxLength={300} initial="bb" />
|
|
399
|
-
</>,
|
|
400
|
-
);
|
|
401
|
-
|
|
402
|
-
const [first, second] = screen.getAllByRole('textbox');
|
|
403
|
-
const idOf = (el: HTMLElement) => el.getAttribute('aria-describedby');
|
|
404
|
-
expect(idOf(first)).not.toBe(idOf(second));
|
|
405
|
-
expect(document.getElementById(idOf(first)!)!.textContent).toBe('Character count: 1 of 500');
|
|
406
|
-
expect(document.getElementById(idOf(second)!)!.textContent).toBe('Character count: 2 of 300');
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
it('renders no counter, no status region and no description when readonly', () => {
|
|
410
|
-
renderIn('en', <TextAreaField value="hello" onChange={vi.fn()} readonly field={textareaField({ maxLength: 500 })} />);
|
|
411
|
-
|
|
412
|
-
expect(statusRegion()).toBeNull();
|
|
413
|
-
expect(visibleCounter()).toBeNull();
|
|
414
|
-
expect(screen.queryByRole('textbox')).toBeNull();
|
|
415
|
-
expect(screen.getByText('hello')).toBeInTheDocument();
|
|
416
|
-
});
|
|
417
|
-
});
|