@object-ui/fields 17.2.0 → 17.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +592 -0
- package/dist/__tests__/cell-truncation.test.d.ts +0 -0
- package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2881 -2566
- package/dist/widgets/AddressField.d.ts +1 -1
- package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/AvatarField.d.ts +1 -1
- package/dist/widgets/CharacterCount.d.ts +56 -0
- package/dist/widgets/CheckboxesField.d.ts +1 -1
- package/dist/widgets/CodeField.d.ts +1 -1
- package/dist/widgets/ColorField.d.ts +1 -1
- package/dist/widgets/CurrencyField.d.ts +1 -1
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +1 -1
- package/dist/widgets/FilterConditionField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +155 -0
- package/dist/widgets/GeolocationField.d.ts +1 -1
- package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +1 -1
- package/dist/widgets/LookupField.d.ts +1 -1
- package/dist/widgets/LookupField.gateHintLabel.test.d.ts +8 -0
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/ObjectField.d.ts +1 -1
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +45 -0
- package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
- package/dist/widgets/PercentField.d.ts +1 -1
- package/dist/widgets/PhoneField.d.ts +1 -1
- package/dist/widgets/QRCodeField.d.ts +1 -1
- package/dist/widgets/RadioField.d.ts +1 -1
- package/dist/widgets/RatingField.d.ts +1 -1
- package/dist/widgets/RecipientPickerField.d.ts +1 -1
- package/dist/widgets/RecordPickerDialog.d.ts +17 -1
- package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +8 -0
- package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +8 -0
- package/dist/widgets/RichTextField.d.ts +19 -1
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
- package/dist/widgets/TextAreaField.d.ts +44 -5
- package/dist/widgets/UrlField.d.ts +1 -1
- package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/toDomProps.d.ts +91 -0
- package/dist/widgets/types.d.ts +128 -33
- package/dist/withFieldCarrier.d.ts +56 -0
- package/package.json +13 -13
- package/src/__tests__/cell-truncation.test.tsx +96 -0
- package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
- package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
- package/src/__tests__/validation-feedback.test.tsx +22 -13
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +598 -0
- package/src/__tests__/widget-props-contract.test.tsx +22 -9
- package/src/index.tsx +74 -26
- package/src/widgets/AddressField.tsx +31 -12
- package/src/widgets/AddressField.uniqueIds.test.tsx +92 -0
- package/src/widgets/AvatarField.tsx +9 -2
- package/src/widgets/BooleanField.tsx +3 -3
- package/src/widgets/CharacterCount.tsx +263 -0
- package/src/widgets/CheckboxesField.tsx +31 -14
- package/src/widgets/CodeField.tsx +9 -2
- package/src/widgets/ColorField.tsx +11 -2
- package/src/widgets/CurrencyField.tsx +5 -4
- package/src/widgets/DateField.tsx +2 -2
- package/src/widgets/DateTimeField.tsx +2 -2
- package/src/widgets/EmailField.tsx +5 -5
- package/src/widgets/FileField.tsx +13 -3
- package/src/widgets/FilterConditionField.tsx +11 -0
- package/src/widgets/FormulaField.tsx +1 -1
- package/src/widgets/FullscreenFieldEditor.tsx +271 -0
- package/src/widgets/GeolocationField.tsx +21 -6
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
- package/src/widgets/GridField.tsx +1 -1
- package/src/widgets/ImageField.tsx +9 -2
- package/src/widgets/LocationField.tsx +9 -2
- package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
- package/src/widgets/LookupField.tsx +41 -6
- package/src/widgets/MasterDetailField.tsx +1 -1
- package/src/widgets/MultiSelectField.tsx +30 -14
- package/src/widgets/NumberField.tsx +3 -3
- package/src/widgets/ObjectField.tsx +17 -8
- package/src/widgets/ObjectRefField.tsx +10 -0
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
- package/src/widgets/OptionsEmptyState.test.tsx +169 -0
- package/src/widgets/OptionsEmptyState.tsx +83 -0
- package/src/widgets/PasswordField.tsx +3 -3
- package/src/widgets/PeoplePicker.tsx +7 -1
- package/src/widgets/PercentField.tsx +5 -4
- package/src/widgets/PhoneField.tsx +5 -5
- package/src/widgets/QRCodeField.tsx +9 -2
- package/src/widgets/RadioField.tsx +24 -14
- package/src/widgets/RatingField.tsx +18 -3
- package/src/widgets/RecipientPickerField.tsx +18 -0
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
- package/src/widgets/RecordPickerDialog.tsx +173 -8
- package/src/widgets/RichTextField.tsx +151 -24
- package/src/widgets/SelectField.tsx +48 -17
- package/src/widgets/SliderField.tsx +1 -1
- package/src/widgets/SummaryField.tsx +1 -1
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
- package/src/widgets/TagsField.placeholder.test.tsx +95 -0
- package/src/widgets/TagsField.tsx +23 -2
- package/src/widgets/TextAreaField.tsx +171 -93
- package/src/widgets/TextField.tsx +3 -3
- package/src/widgets/TimeField.tsx +2 -2
- package/src/widgets/UrlField.tsx +5 -5
- package/src/widgets/UserField.tsx +1 -1
- package/src/widgets/VectorField.tsx +1 -1
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
- package/src/widgets/toDomProps.ts +175 -0
- package/src/widgets/types.ts +135 -35
- package/src/widgets/useFieldTranslation.ts +60 -0
- package/src/withFieldCarrier.tsx +75 -0
|
@@ -0,0 +1,263 @@
|
|
|
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 React, { useEffect, useState } from 'react';
|
|
10
|
+
import { useFieldTranslation } from './useFieldTranslation';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The character counter a capped long-text field renders, in the GOV.UK
|
|
14
|
+
* character-count shape — extracted from `TextAreaField` so that widget's TWO
|
|
15
|
+
* editing surfaces (the inline textarea and the fullscreen dialog) render the
|
|
16
|
+
* SAME counting UI instead of two hand-written copies (objectui#3417).
|
|
17
|
+
*
|
|
18
|
+
* ## Why this is shared rather than copied
|
|
19
|
+
*
|
|
20
|
+
* objectui#3408 gave the inline counter three nodes — decorative digits, an
|
|
21
|
+
* accessible description, a gated status region. The fullscreen dialog's
|
|
22
|
+
* counter, passed to `FullscreenFieldEditor` as a `footer`, stayed what it had
|
|
23
|
+
* always been: a bare `{n}/{max}` `span` with no accessible name, no
|
|
24
|
+
* `aria-describedby` association with the dialog's textarea, and nothing
|
|
25
|
+
* `aria-live`. Same field, same cap, same user — and a screen reader in the
|
|
26
|
+
* fullscreen dialog got nothing at all, while browse mode read "5 slash 500".
|
|
27
|
+
*
|
|
28
|
+
* That is the failure mode this package keeps paying for (objectui#3301: the
|
|
29
|
+
* fullscreen flag reached `RichTextField` for as long as the feature existed
|
|
30
|
+
* and that widget never read it). A second hand-written copy of a counting UI
|
|
31
|
+
* is the same failure with an extra step: it drifts, and nothing reports the
|
|
32
|
+
* drift. So the counter has ONE implementation and each surface renders it.
|
|
33
|
+
*
|
|
34
|
+
* ## The three nodes, and why the third is a prop
|
|
35
|
+
*
|
|
36
|
+
* 1. the visible `{n}/{max}` digits, `aria-hidden` — a glance affordance;
|
|
37
|
+
* 2. a visually-hidden DESCRIPTION carrying the translated sentence. The host
|
|
38
|
+
* owns {@link CharacterCountProps.descriptionId} and MUST name it in the
|
|
39
|
+
* edited control's `aria-describedby`, which is what makes the sentence
|
|
40
|
+
* reachable on focus rather than only in browse mode;
|
|
41
|
+
* 3. a visually-hidden `aria-live="polite"` STATUS region, silent until the
|
|
42
|
+
* value is inside the last 10% / 20 characters of the cap and updated only
|
|
43
|
+
* after the typist pauses.
|
|
44
|
+
*
|
|
45
|
+
* Node 3 is opt-in per surface ({@link CharacterCountProps.announceNearLimit}),
|
|
46
|
+
* required rather than defaulted, because "should this surface interrupt the
|
|
47
|
+
* user while they type?" is a real decision and neither answer should be
|
|
48
|
+
* reached by leaving a prop off. The fullscreen dialog answers no: it is a
|
|
49
|
+
* modal the user opened deliberately to write at length, the description
|
|
50
|
+
* already tells them the cap on focus, and the dialog's own textarea carries
|
|
51
|
+
* the same native `maxLength` stop as the inline one. The inline surface
|
|
52
|
+
* answers yes, unchanged from objectui#3408.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* How long the typist must pause before the counter's status region is allowed
|
|
57
|
+
* to change (objectui#3408). The GOV.UK Design System character-count component
|
|
58
|
+
* uses the same shape and the same order of magnitude.
|
|
59
|
+
*
|
|
60
|
+
* The number this replaces was effectively 0: the counter WAS the live region,
|
|
61
|
+
* so every keystroke re-rendered it and every re-render was an announcement.
|
|
62
|
+
* Measured on `origin/main`, zh session, `maxLength: 500`, typing a 52-character
|
|
63
|
+
* sentence one character at a time: 52 keystrokes produced 52 distinct
|
|
64
|
+
* announcements totalling 979 spoken characters — ~19x the text the user was
|
|
65
|
+
* trying to write, each one interrupting the screen reader's echo of what they
|
|
66
|
+
* had just typed.
|
|
67
|
+
*/
|
|
68
|
+
const COUNTER_STATUS_DEBOUNCE_MS = 1000;
|
|
69
|
+
|
|
70
|
+
/** Announce inside the last 10% of the cap … */
|
|
71
|
+
const COUNTER_STATUS_REMAINING_RATIO = 0.1;
|
|
72
|
+
/** … or the last 20 characters — whichever the typist reaches FIRST. */
|
|
73
|
+
const COUNTER_STATUS_MIN_REMAINING = 20;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The remaining-character count at or below which the status region speaks.
|
|
77
|
+
*
|
|
78
|
+
* "10% remaining OR 20 characters remaining, whichever comes first" is a
|
|
79
|
+
* disjunction, and because `remaining` only ever counts DOWN as the user types,
|
|
80
|
+
* the branch that fires first is simply the LARGER of the two — hence `max`.
|
|
81
|
+
* A 500-character cap starts warning at 50 remaining; a 100-character cap at 20
|
|
82
|
+
* (10% of it would be 10, which the typist would reach later, not sooner).
|
|
83
|
+
*
|
|
84
|
+
* A cap smaller than {@link COUNTER_STATUS_MIN_REMAINING} is therefore "near
|
|
85
|
+
* the limit" from the first keystroke, which is correct: on a 10-character
|
|
86
|
+
* field every character genuinely is one of the last few. The debounce still
|
|
87
|
+
* bounds it to one announcement per pause.
|
|
88
|
+
*/
|
|
89
|
+
function counterStatusThreshold(maxLength: number): number {
|
|
90
|
+
return Math.max(
|
|
91
|
+
Math.ceil(maxLength * COUNTER_STATUS_REMAINING_RATIO),
|
|
92
|
+
COUNTER_STATUS_MIN_REMAINING,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface CharacterCountProps {
|
|
97
|
+
/**
|
|
98
|
+
* Length of the text this surface is counting. The inline surface passes the
|
|
99
|
+
* committed value's length; the fullscreen dialog passes the DRAFT's, which
|
|
100
|
+
* is the number that is true of what the user is looking at.
|
|
101
|
+
*/
|
|
102
|
+
length: number;
|
|
103
|
+
/**
|
|
104
|
+
* The declared cap. Callers render this component only when the field
|
|
105
|
+
* declares one, so it is non-optional here — a counter with no cap to count
|
|
106
|
+
* against has nothing to say and must not be mounted at all (an unconditional
|
|
107
|
+
* live region in every long-text field is the objectui#3408 defect back
|
|
108
|
+
* again).
|
|
109
|
+
*/
|
|
110
|
+
maxLength: number;
|
|
111
|
+
/**
|
|
112
|
+
* The description node's `id`, minted by the host from `useId()`.
|
|
113
|
+
*
|
|
114
|
+
* The host MUST also name it in the edited control's `aria-describedby` —
|
|
115
|
+
* this component renders the sentence, only the host knows which element is
|
|
116
|
+
* being described, and a description nothing references is a node no screen
|
|
117
|
+
* reader will ever reach. That association is asserted from the OUTSIDE (the
|
|
118
|
+
* tests resolve `aria-describedby` and concatenate the referenced nodes' text,
|
|
119
|
+
* exactly as an assistive technology does) rather than trusted here.
|
|
120
|
+
*
|
|
121
|
+
* Two surfaces of one widget therefore need two ids: the dialog's draft and
|
|
122
|
+
* the inline value diverge the moment the user types in the dialog, so one
|
|
123
|
+
* shared id would point both controls at whichever sentence rendered last.
|
|
124
|
+
*/
|
|
125
|
+
descriptionId: string;
|
|
126
|
+
/**
|
|
127
|
+
* Render node 3, the threshold-gated debounced `aria-live` status region.
|
|
128
|
+
*
|
|
129
|
+
* Required on purpose — see the component doc above. `false` removes the
|
|
130
|
+
* region from the DOM entirely (not merely empties it), which is the only
|
|
131
|
+
* form of "no live region" a test can tell apart from "a live region that
|
|
132
|
+
* happens to be silent right now".
|
|
133
|
+
*/
|
|
134
|
+
announceNearLimit: boolean;
|
|
135
|
+
/**
|
|
136
|
+
* Classes for the visible digits. Required because placement is entirely the
|
|
137
|
+
* surface's business — inline pins them to the textarea's bottom-right
|
|
138
|
+
* corner, the dialog lays them out as a footer item — and a default borrowed
|
|
139
|
+
* from either one would position the other wrongly while looking deliberate.
|
|
140
|
+
*/
|
|
141
|
+
className: string;
|
|
142
|
+
/**
|
|
143
|
+
* `data-testid` for the visible digits, namespaced per surface
|
|
144
|
+
* (`textarea-character-count`, `textarea-fullscreen-character-count`) so a
|
|
145
|
+
* test that finds digits can say WHICH surface it found them on. Same
|
|
146
|
+
* convention, same reason, as `FullscreenFieldEditor`'s `testIdPrefix`.
|
|
147
|
+
*/
|
|
148
|
+
testId: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function CharacterCount({
|
|
152
|
+
length,
|
|
153
|
+
maxLength,
|
|
154
|
+
descriptionId,
|
|
155
|
+
announceNearLimit,
|
|
156
|
+
className,
|
|
157
|
+
testId,
|
|
158
|
+
}: CharacterCountProps) {
|
|
159
|
+
const { t } = useFieldTranslation();
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The counter sentence as a DESCRIPTION (objectui#3408). Reached through the
|
|
163
|
+
* edited control's `aria-describedby`, so focusing it says "12 characters,
|
|
164
|
+
* 500 max" once and then shuts up. Before this the cap was announced only as
|
|
165
|
+
* a side effect of typing — focus told a screen reader user nothing at all
|
|
166
|
+
* that a sighted user could read off the corner of the box.
|
|
167
|
+
*/
|
|
168
|
+
const description = t('fields.textarea.characterCount', { count: length, max: maxLength });
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The near-limit warning, gated. Silent for the whole comfortable middle of
|
|
172
|
+
* the field — a count nobody is close to is not news — and phrased as what
|
|
173
|
+
* is LEFT rather than what has been typed, because that is the number the
|
|
174
|
+
* user is about to act on. Over-long values (a cap lowered after the record
|
|
175
|
+
* was saved) clamp to 0: the textarea's own `maxLength` blocks further
|
|
176
|
+
* input, so "0 left" is the true actionable state, and the description above
|
|
177
|
+
* still carries the honest `503 of 500`.
|
|
178
|
+
*
|
|
179
|
+
* Short-circuited by `announceNearLimit` rather than only hidden at render
|
|
180
|
+
* time, so a surface that renders no status region also schedules no timer —
|
|
181
|
+
* a debounce firing into a region that does not exist is a state update with
|
|
182
|
+
* no consumer, and in tests an `act()` warning with no cause.
|
|
183
|
+
*/
|
|
184
|
+
const pendingStatus =
|
|
185
|
+
announceNearLimit && maxLength - length <= counterStatusThreshold(maxLength)
|
|
186
|
+
? t('fields.textarea.charactersRemaining', { count: Math.max(maxLength - length, 0) })
|
|
187
|
+
: '';
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The last sentence a PAUSE settled on. Only ever written by the timer —
|
|
191
|
+
* never cleared synchronously — so this effect adds no cascading render on
|
|
192
|
+
* the keystrokes it is busy staying quiet through
|
|
193
|
+
* (`react-hooks/set-state-in-effect`).
|
|
194
|
+
*/
|
|
195
|
+
const [settledStatus, setSettledStatus] = useState('');
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (!pendingStatus) return;
|
|
199
|
+
const timer = setTimeout(() => setSettledStatus(pendingStatus), COUNTER_STATUS_DEBOUNCE_MS);
|
|
200
|
+
// Every keystroke cancels the previous pending announcement, so a typist
|
|
201
|
+
// who never pauses is never interrupted. The dependency is the SENTENCE,
|
|
202
|
+
// not the length: re-typing back to the same count produces the same string
|
|
203
|
+
// and therefore no DOM change and no second announcement.
|
|
204
|
+
return () => clearTimeout(timer);
|
|
205
|
+
}, [pendingStatus]);
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Speak the settled sentence only while it is still TRUE — i.e. while it is
|
|
209
|
+
* the one the current value would produce. Everything else renders empty,
|
|
210
|
+
* which costs no speech (emptying a live region announces nothing), and that
|
|
211
|
+
* is what makes leaving the warning band silent IMMEDIATELY rather than a
|
|
212
|
+
* second later.
|
|
213
|
+
*
|
|
214
|
+
* The obvious alternative, `pendingStatus ? settledStatus : ''`, is wrong in
|
|
215
|
+
* one specific way: delete back out of the band and type into it again, and
|
|
216
|
+
* the region re-announces the STALE count from before the excursion a full
|
|
217
|
+
* second before the timer corrects it. Announcing a wrong number is worse
|
|
218
|
+
* than announcing a right one twice, which is the bounded, sub-second,
|
|
219
|
+
* correct-content cost of the comparison below. Pinned by `never
|
|
220
|
+
* re-announces the STALE count when the user types back into the band`.
|
|
221
|
+
*/
|
|
222
|
+
const status = settledStatus === pendingStatus ? pendingStatus : '';
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<>
|
|
226
|
+
{/*
|
|
227
|
+
The VISIBLE counter, and visible ONLY (objectui#3408). It used to be
|
|
228
|
+
three things at once: the digits a sighted user glances at, the carrier
|
|
229
|
+
of the translated sentence (objectui#3406), and the live region itself —
|
|
230
|
+
so the sentence was re-announced on every single keystroke. Split into
|
|
231
|
+
the three nodes here, this one is decorative: `aria-hidden` keeps a
|
|
232
|
+
screen reader from reading "5 slash 200" on top of the description that
|
|
233
|
+
says it properly.
|
|
234
|
+
*/}
|
|
235
|
+
<div className={className} aria-hidden="true" data-testid={testId}>
|
|
236
|
+
{length}/{maxLength}
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
{/*
|
|
240
|
+
The DESCRIPTION. Referenced by the edited control's `aria-describedby`,
|
|
241
|
+
never announced on its own — a screen reader reads it when focus lands
|
|
242
|
+
on the control and not again. Visually hidden because the digits above
|
|
243
|
+
already say it to the eye.
|
|
244
|
+
*/}
|
|
245
|
+
<span id={descriptionId} className="sr-only">
|
|
246
|
+
{description}
|
|
247
|
+
</span>
|
|
248
|
+
|
|
249
|
+
{/*
|
|
250
|
+
The STATUS region, on the surfaces that opted in. Rendered
|
|
251
|
+
unconditionally there and starting EMPTY on purpose: a live region has
|
|
252
|
+
to be in the DOM before its content changes or the first change is not
|
|
253
|
+
announced at all. `aria-atomic` so the whole sentence is spoken rather
|
|
254
|
+
than the digits that differ from last time.
|
|
255
|
+
*/}
|
|
256
|
+
{announceNearLimit && (
|
|
257
|
+
<span className="sr-only" aria-live="polite" aria-atomic="true">
|
|
258
|
+
{status}
|
|
259
|
+
</span>
|
|
260
|
+
)}
|
|
261
|
+
</>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
@@ -2,6 +2,8 @@ import React, { useId, useEffect } from 'react';
|
|
|
2
2
|
import { Checkbox, Label, EmptyValue, Badge } from '@object-ui/components';
|
|
3
3
|
import type { OptionLike } from '@object-ui/core';
|
|
4
4
|
import { FieldWidgetComponentProps } from './types';
|
|
5
|
+
import { toDomProps } from './toDomProps';
|
|
6
|
+
import { OptionsEmptyState } from './OptionsEmptyState';
|
|
5
7
|
import { useCascadingOptions } from './useCascadingOptions';
|
|
6
8
|
|
|
7
9
|
type Option = OptionLike;
|
|
@@ -24,14 +26,14 @@ export function CheckboxesField({
|
|
|
24
26
|
field,
|
|
25
27
|
readonly,
|
|
26
28
|
className,
|
|
27
|
-
schema,
|
|
28
29
|
dependentValues,
|
|
29
30
|
dependsOn: dependsOnProp,
|
|
30
|
-
emptyHint
|
|
31
|
+
emptyHint,
|
|
31
32
|
dataSource: _dataSource,
|
|
33
|
+
error,
|
|
32
34
|
...props
|
|
33
35
|
}: FieldWidgetComponentProps<string[]>) {
|
|
34
|
-
const config =
|
|
36
|
+
const config = field as any;
|
|
35
37
|
const rawOptions: Option[] = config?.options || [];
|
|
36
38
|
const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
|
|
37
39
|
const groupId = useId();
|
|
@@ -70,19 +72,18 @@ export function CheckboxesField({
|
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
// No offered options is unfillable — surface a legible state instead of an
|
|
73
|
-
// empty checkbox list:
|
|
74
|
-
//
|
|
75
|
+
// empty checkbox list: the host's `emptyHint` when it computed one, else this
|
|
76
|
+
// widget's own translated copy. Shared with the select / multiselect / radio
|
|
77
|
+
// so the four cannot drift again (objectui#3231).
|
|
75
78
|
if (options.length === 0) {
|
|
76
|
-
const hint = gated
|
|
77
|
-
? `Select ${dependsOnFields.join(' / ')} first`
|
|
78
|
-
: 'No options available';
|
|
79
79
|
return (
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
{
|
|
85
|
-
|
|
80
|
+
<OptionsEmptyState
|
|
81
|
+
emptyHint={emptyHint}
|
|
82
|
+
gated={gated}
|
|
83
|
+
dependsOnFields={dependsOnFields}
|
|
84
|
+
testId={fieldName ? `checkboxes-empty-${fieldName}` : undefined}
|
|
85
|
+
className="min-h-9"
|
|
86
|
+
/>
|
|
86
87
|
);
|
|
87
88
|
}
|
|
88
89
|
|
|
@@ -91,8 +92,23 @@ export function CheckboxesField({
|
|
|
91
92
|
onChange(next);
|
|
92
93
|
};
|
|
93
94
|
|
|
95
|
+
// DOM pass-through (objectui#3318): the container carries the form
|
|
96
|
+
// renderer's id / aria-describedby, but NOT its `aria-invalid` — a plain
|
|
97
|
+
// wrapper div is not where assistive tech reads the invalid state. That
|
|
98
|
+
// state goes onto every focusable checkbox below (`checkbox` is an
|
|
99
|
+
// aria-invalid-supporting role), computed from the published `error` slot
|
|
100
|
+
// (#3222). The per-item ids below stay authoritative for their labels.
|
|
101
|
+
// `name` is withheld too: it is only DOM-legal on form controls, and on
|
|
102
|
+
// this div it is exactly the leak #3291 sweeps for.
|
|
103
|
+
const {
|
|
104
|
+
'aria-invalid': _hostAriaInvalid,
|
|
105
|
+
name: _domName,
|
|
106
|
+
...groupDomProps
|
|
107
|
+
} = toDomProps(props);
|
|
108
|
+
|
|
94
109
|
return (
|
|
95
110
|
<div
|
|
111
|
+
{...groupDomProps}
|
|
96
112
|
className={className}
|
|
97
113
|
data-testid={fieldName ? `checkboxes-${fieldName}` : undefined}
|
|
98
114
|
>
|
|
@@ -108,6 +124,7 @@ export function CheckboxesField({
|
|
|
108
124
|
checked={selected.includes(value)}
|
|
109
125
|
onCheckedChange={(checked) => toggle(value, !!checked)}
|
|
110
126
|
disabled={props.disabled}
|
|
127
|
+
aria-invalid={!!error}
|
|
111
128
|
data-testid={`checkboxes-option-${value}`}
|
|
112
129
|
/>
|
|
113
130
|
<Label htmlFor={id} className="font-normal">{opt.label}</Label>
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Textarea, cn, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetComponentProps } from './types';
|
|
4
|
+
import { toDomProps } from './toDomProps';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Code field widget - provides a code editor with syntax highlighting
|
|
7
8
|
* Uses a simple textarea with monospace font
|
|
8
9
|
* For advanced code editing, use the @object-ui/plugin-editor component
|
|
9
10
|
*/
|
|
10
|
-
export function CodeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
|
|
11
|
-
const config = field
|
|
11
|
+
export function CodeField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
|
|
12
|
+
const config = field;
|
|
12
13
|
// Get code-specific configuration from field metadata
|
|
13
14
|
const language = (config as any)?.language ?? 'javascript';
|
|
14
15
|
|
|
@@ -22,6 +23,8 @@ export function CodeField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
22
23
|
|
|
23
24
|
return (
|
|
24
25
|
<Textarea
|
|
26
|
+
// DOM pass-through onto the real focusable control (objectui#3318).
|
|
27
|
+
{...toDomProps(props)}
|
|
25
28
|
value={value || ''}
|
|
26
29
|
onChange={(e) => onChange(e.target.value)}
|
|
27
30
|
placeholder={config?.placeholder || `// Write ${language} code here...`}
|
|
@@ -29,6 +32,10 @@ export function CodeField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
29
32
|
className={cn("font-mono text-sm", props.className)}
|
|
30
33
|
rows={12}
|
|
31
34
|
spellCheck={false}
|
|
35
|
+
// AFTER the spread so this widget's own computation wins: `error` is
|
|
36
|
+
// the published validation slot (#3222), `!!undefined` → explicit
|
|
37
|
+
// "false".
|
|
38
|
+
aria-invalid={!!error}
|
|
32
39
|
/>
|
|
33
40
|
);
|
|
34
41
|
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetComponentProps } from './types';
|
|
4
|
+
import { toDomProps } from './toDomProps';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Color field widget - provides a color picker input
|
|
7
8
|
* Supports hex color values (e.g., #ff0000)
|
|
8
9
|
*/
|
|
9
|
-
export function ColorField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
|
|
10
|
-
const colorField =
|
|
10
|
+
export function ColorField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
|
|
11
|
+
const colorField = field as any;
|
|
11
12
|
|
|
12
13
|
if (readonly) {
|
|
13
14
|
return (
|
|
@@ -29,8 +30,14 @@ export function ColorField({ value, onChange, field, readonly, ...props }: Field
|
|
|
29
30
|
onChange={(e) => onChange(e.target.value)}
|
|
30
31
|
disabled={readonly || props.disabled}
|
|
31
32
|
className="w-10 h-10 rounded border border-input cursor-pointer"
|
|
33
|
+
// Both focusable halves of this widget announce the same validation
|
|
34
|
+
// state (objectui#3318).
|
|
35
|
+
aria-invalid={!!error}
|
|
32
36
|
/>
|
|
33
37
|
<Input
|
|
38
|
+
// DOM pass-through onto the primary text control (objectui#3318): the
|
|
39
|
+
// whitelist spread carries the form renderer's id / aria-describedby.
|
|
40
|
+
{...toDomProps(props)}
|
|
34
41
|
type="text"
|
|
35
42
|
value={value || ''}
|
|
36
43
|
onChange={(e) => onChange(e.target.value)}
|
|
@@ -38,6 +45,8 @@ export function ColorField({ value, onChange, field, readonly, ...props }: Field
|
|
|
38
45
|
disabled={readonly || props.disabled}
|
|
39
46
|
className={props.className}
|
|
40
47
|
pattern="^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"
|
|
48
|
+
// AFTER the spread so this widget's own computation wins (#3222).
|
|
49
|
+
aria-invalid={!!error}
|
|
41
50
|
/>
|
|
42
51
|
</div>
|
|
43
52
|
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetComponentProps } from './types';
|
|
4
|
+
import { toDomProps } from './toDomProps';
|
|
4
5
|
import { useLocalization } from '@object-ui/i18n';
|
|
5
6
|
import { resolveFieldCurrency } from '../currency';
|
|
6
7
|
|
|
@@ -36,8 +37,8 @@ const CURRENCY_SYMBOLS: Record<string, string> = {
|
|
|
36
37
|
USD: '$',
|
|
37
38
|
};
|
|
38
39
|
|
|
39
|
-
export function CurrencyField({ value, onChange, field, readonly,
|
|
40
|
-
const currencyField =
|
|
40
|
+
export function CurrencyField({ value, onChange, field, readonly, error, className, ...props }: FieldWidgetComponentProps<number>) {
|
|
41
|
+
const currencyField = field as any;
|
|
41
42
|
// Shared precedence: field currency → currencyConfig → tenant default (ADR-0053).
|
|
42
43
|
const { currency: tenantCurrency } = useLocalization();
|
|
43
44
|
const currency: string | undefined = resolveFieldCurrency(currencyField, tenantCurrency);
|
|
@@ -70,7 +71,7 @@ export function CurrencyField({ value, onChange, field, readonly, errorMessage,
|
|
|
70
71
|
</span>
|
|
71
72
|
)}
|
|
72
73
|
<Input
|
|
73
|
-
{...props}
|
|
74
|
+
{...toDomProps(props)}
|
|
74
75
|
type="number"
|
|
75
76
|
value={value ?? ''}
|
|
76
77
|
onChange={(e) => {
|
|
@@ -87,7 +88,7 @@ export function CurrencyField({ value, onChange, field, readonly, errorMessage,
|
|
|
87
88
|
min={typeof currencyField?.min === 'number' ? currencyField.min : undefined}
|
|
88
89
|
max={typeof currencyField?.max === 'number' ? currencyField.max : undefined}
|
|
89
90
|
step={Math.pow(10, -precision).toFixed(precision)}
|
|
90
|
-
aria-invalid={!!
|
|
91
|
+
aria-invalid={!!error}
|
|
91
92
|
/>
|
|
92
93
|
</div>
|
|
93
94
|
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetComponentProps } from './types';
|
|
4
|
+
import { toDomProps } from './toDomProps';
|
|
4
5
|
import { openNativePicker } from './openNativePicker';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -12,8 +13,7 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
12
13
|
return value ? <span className="text-sm">{new Date(value).toLocaleDateString()}</span> : <EmptyValue />;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
const { inputType, ...domProps } = props as any;
|
|
16
|
+
const domProps = toDomProps(props);
|
|
17
17
|
|
|
18
18
|
return (
|
|
19
19
|
<Input
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetComponentProps } from './types';
|
|
4
|
+
import { toDomProps } from './toDomProps';
|
|
4
5
|
import { openNativePicker } from './openNativePicker';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -18,8 +19,7 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi
|
|
|
18
19
|
);
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
const { inputType, ...domProps } = props as any;
|
|
22
|
+
const domProps = toDomProps(props);
|
|
23
23
|
|
|
24
24
|
return (
|
|
25
25
|
<Input
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetComponentProps } from './types';
|
|
4
|
+
import { toDomProps } from './toDomProps';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* EmailField - Email address input with mailto link in readonly mode
|
|
7
8
|
* Renders as a clickable mailto link when readonly, standard email input otherwise
|
|
8
9
|
*/
|
|
9
|
-
export function EmailField({ value, onChange, field, readonly,
|
|
10
|
-
const config = field
|
|
10
|
+
export function EmailField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
|
|
11
|
+
const config = field;
|
|
11
12
|
if (readonly) {
|
|
12
13
|
if (!value) return <EmptyValue />;
|
|
13
14
|
return (
|
|
@@ -20,8 +21,7 @@ export function EmailField({ value, onChange, field, readonly, errorMessage, ...
|
|
|
20
21
|
);
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
const { inputType, ...domProps } = props as any;
|
|
24
|
+
const domProps = toDomProps(props);
|
|
25
25
|
|
|
26
26
|
return (
|
|
27
27
|
<Input
|
|
@@ -31,7 +31,7 @@ export function EmailField({ value, onChange, field, readonly, errorMessage, ...
|
|
|
31
31
|
onChange={(e) => onChange(e.target.value)}
|
|
32
32
|
placeholder={config?.placeholder || 'email@example.com'}
|
|
33
33
|
disabled={readonly || domProps.disabled}
|
|
34
|
-
aria-invalid={!!
|
|
34
|
+
aria-invalid={!!error}
|
|
35
35
|
/>
|
|
36
36
|
);
|
|
37
37
|
}
|
|
@@ -4,6 +4,7 @@ import { useUpload } from '@object-ui/providers';
|
|
|
4
4
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
5
5
|
import { Upload, X, File as FileIcon, ImageIcon, Camera, Loader2 } from 'lucide-react';
|
|
6
6
|
import { FieldWidgetComponentProps } from './types';
|
|
7
|
+
import { toDomProps } from './toDomProps';
|
|
7
8
|
import { useUploadingSignal } from './useUploadingSignal';
|
|
8
9
|
import {
|
|
9
10
|
fileValueForSubmit,
|
|
@@ -115,11 +116,11 @@ function useFileUploads(opts: {
|
|
|
115
116
|
* Supports single and multiple file uploads with configurable accepted file types.
|
|
116
117
|
* L2: File size validation, per-file progress indicators, error messages.
|
|
117
118
|
*/
|
|
118
|
-
export function FileField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetComponentProps<any>) {
|
|
119
|
+
export function FileField({ value, onChange, field, readonly, onUploadingChange, error, ...props }: FieldWidgetComponentProps<any>) {
|
|
119
120
|
const { t } = useObjectTranslation();
|
|
120
121
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
121
122
|
const cameraRef = useRef<HTMLInputElement>(null);
|
|
122
|
-
const fileField =
|
|
123
|
+
const fileField = field as any;
|
|
123
124
|
const multiple = fileField?.multiple || false;
|
|
124
125
|
const accept = fileField?.accept ? fileField.accept.join(',') : undefined;
|
|
125
126
|
const maxSize = fileField?.maxSize as number | undefined; // bytes
|
|
@@ -209,6 +210,8 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
|
|
|
209
210
|
}
|
|
210
211
|
};
|
|
211
212
|
|
|
213
|
+
const { name: _domName, ...dropzoneDomProps } = toDomProps(props);
|
|
214
|
+
|
|
212
215
|
return (
|
|
213
216
|
<div className={props.className}>
|
|
214
217
|
<input
|
|
@@ -233,8 +236,13 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
|
|
|
233
236
|
)}
|
|
234
237
|
|
|
235
238
|
<div className="space-y-2">
|
|
236
|
-
{/* Drag-and-drop zone
|
|
239
|
+
{/* Drag-and-drop zone — the widget's real focusable control (it is the
|
|
240
|
+
keyboard path to the hidden file input), so the DOM pass-through
|
|
241
|
+
and the validation state land here (objectui#3318). `name` is
|
|
242
|
+
withheld: only DOM-legal on form controls, and on this div it is
|
|
243
|
+
exactly the leak #3291 sweeps for. */}
|
|
237
244
|
<div
|
|
245
|
+
{...dropzoneDomProps}
|
|
238
246
|
onDragOver={handleDragOver}
|
|
239
247
|
onDragLeave={handleDragLeave}
|
|
240
248
|
onDrop={handleDrop}
|
|
@@ -255,6 +263,8 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
|
|
|
255
263
|
inputRef.current?.click();
|
|
256
264
|
}
|
|
257
265
|
}}
|
|
266
|
+
// AFTER the spread so this widget's own computation wins (#3222).
|
|
267
|
+
aria-invalid={!!error}
|
|
258
268
|
>
|
|
259
269
|
<Upload className={`size-8 ${isDragOver ? 'text-primary' : 'text-muted-foreground'}`} />
|
|
260
270
|
<div className="text-center">
|
|
@@ -2,6 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import { FilterBuilder, cn } from '@object-ui/components';
|
|
3
3
|
import { SchemaRendererContext } from '@object-ui/react';
|
|
4
4
|
import type { FieldWidgetComponentProps } from './types';
|
|
5
|
+
import { toDomProps } from './toDomProps';
|
|
5
6
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -297,6 +298,7 @@ export function FilterConditionField({
|
|
|
297
298
|
onChange,
|
|
298
299
|
readonly,
|
|
299
300
|
className,
|
|
301
|
+
error,
|
|
300
302
|
...props
|
|
301
303
|
}: FieldWidgetComponentProps<string | object>) {
|
|
302
304
|
const ctx = React.useContext(SchemaRendererContext);
|
|
@@ -397,10 +399,19 @@ export function FilterConditionField({
|
|
|
397
399
|
{rawMode ? (
|
|
398
400
|
<>
|
|
399
401
|
<textarea
|
|
402
|
+
// DOM pass-through onto the raw-JSON editing surface
|
|
403
|
+
// (objectui#3318). NOTE this widget stays on the #3318 ledger
|
|
404
|
+
// regardless: its dependency-gated state (no `object_name` chosen
|
|
405
|
+
// yet — the state a fresh form and the registry sweep render) is a
|
|
406
|
+
// plain hint paragraph with no focusable control.
|
|
407
|
+
{...toDomProps(props)}
|
|
400
408
|
className="min-h-[96px] w-full rounded border bg-background px-2 py-1 font-mono text-xs"
|
|
401
409
|
value={rawValue}
|
|
402
410
|
placeholder='{ "type": "customer", "is_active": true }'
|
|
403
411
|
onChange={(e) => onChange(e.target.value as any)}
|
|
412
|
+
// The form's validation slot (#3222) OR this widget's own
|
|
413
|
+
// unparsable-JSON state, which already renders its red message.
|
|
414
|
+
aria-invalid={!!error || !parsed.ok}
|
|
404
415
|
/>
|
|
405
416
|
{!parsed.ok && (
|
|
406
417
|
<span className="text-xs text-destructive">{t('fields.filterCondition.invalidJson')}</span>
|
|
@@ -7,7 +7,7 @@ import { FieldWidgetComponentProps } from './types';
|
|
|
7
7
|
* Values are computed on the backend and cannot be edited
|
|
8
8
|
*/
|
|
9
9
|
export function FormulaField({ value, field, ...props }: FieldWidgetComponentProps<any>) {
|
|
10
|
-
const formulaField =
|
|
10
|
+
const formulaField = field as any;
|
|
11
11
|
const returnType = formulaField?.return_type || 'text';
|
|
12
12
|
|
|
13
13
|
if (value == null) {
|