@vaadin/field-highlighter 25.3.0-dev.3a3c2d7d2a → 25.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/package.json +24 -21
- package/src/styles/vaadin-ai-field-marker-base-styles.d.ts +10 -0
- package/src/styles/vaadin-ai-field-marker-base-styles.js +279 -0
- package/src/vaadin-ai-field-marker.d.ts +172 -0
- package/src/vaadin-ai-field-marker.js +995 -0
- package/src/vaadin-field-highlighter.js +3 -1
- package/src/vaadin-field-outline.js +3 -1
- package/src/vaadin-user-tag.js +3 -1
- package/src/vaadin-user-tags.js +3 -1
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright (c) 2021 - 2026 Vaadin Ltd.
|
|
4
|
+
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
|
|
5
|
+
*/
|
|
6
|
+
import '@vaadin/popover/src/vaadin-popover.js';
|
|
7
|
+
import '@vaadin/tooltip/src/vaadin-tooltip.js';
|
|
8
|
+
import { html, LitElement, nothing } from 'lit';
|
|
9
|
+
import { announce } from '@vaadin/a11y-base/src/announce.js';
|
|
10
|
+
import { getDeepActiveElement, getTabbableElements, isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js';
|
|
11
|
+
import { registerCSSProperty } from '@vaadin/component-base/src/css-utils.js';
|
|
12
|
+
import { defineCustomElement } from '@vaadin/component-base/src/define.js';
|
|
13
|
+
import { DirMixin } from '@vaadin/component-base/src/dir-mixin.js';
|
|
14
|
+
import {
|
|
15
|
+
addValuesToAttribute,
|
|
16
|
+
hasNodeContent,
|
|
17
|
+
removeValuesFromAttribute,
|
|
18
|
+
} from '@vaadin/component-base/src/dom-utils.js';
|
|
19
|
+
import { I18nMixin } from '@vaadin/component-base/src/i18n-mixin.js';
|
|
20
|
+
import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js';
|
|
21
|
+
import { SlotStylesMixin } from '@vaadin/component-base/src/slot-styles-mixin.js';
|
|
22
|
+
import { generateUniqueId } from '@vaadin/component-base/src/unique-id-utils.js';
|
|
23
|
+
import { aiFieldMarkerHostStyles, aiFieldMarkerStyles } from './styles/vaadin-ai-field-marker-base-styles.js';
|
|
24
|
+
|
|
25
|
+
const DEFAULT_I18N = {
|
|
26
|
+
message: 'This field value was modified by AI.',
|
|
27
|
+
revert: 'Revert Value',
|
|
28
|
+
badgeLabel: 'AI-provided value',
|
|
29
|
+
badgeTooltip: 'Field value modified by AI.\nClick for details',
|
|
30
|
+
confidence: {
|
|
31
|
+
low: 'Low confidence',
|
|
32
|
+
medium: 'Medium confidence',
|
|
33
|
+
high: 'High confidence',
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Half of the 1s working shimmer slide (`--vaadin-ai-field-marker-slide` in
|
|
38
|
+
// the base styles), so that held-back values land — and the read-only lock
|
|
39
|
+
// lifts — in the middle of a slide instead of at its edge.
|
|
40
|
+
const HALF_WORKING_SLIDE_MS = 500;
|
|
41
|
+
|
|
42
|
+
const MARKER_SLOT = 'ai-field-marker';
|
|
43
|
+
|
|
44
|
+
/** Marks the `<style>` element the marker injects into a field's shadow root. */
|
|
45
|
+
const MARKER_STYLE_ATTRIBUTE = 'ai-field-marker-styles';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The class name of the confidence indicator the marker adds to the field's
|
|
49
|
+
* light DOM; the level goes on a suffixed class name of its own. Prefixed
|
|
50
|
+
* with `ai-`, since the indicator sits among the application's own children
|
|
51
|
+
* of the field, where a plain `confidence` or `low` would be ambiguous.
|
|
52
|
+
*/
|
|
53
|
+
const CONFIDENCE_CLASS = 'ai-confidence';
|
|
54
|
+
|
|
55
|
+
// The position the shimmer's mask is at, animated by the marker's keyframes.
|
|
56
|
+
// Registered here rather than with an @property rule in the marker stylesheet,
|
|
57
|
+
// which is injected into the field's root node: a registration only takes effect
|
|
58
|
+
// at document scope, and that root node is a shadow root for a field nested
|
|
59
|
+
// inside another component.
|
|
60
|
+
registerCSSProperty({
|
|
61
|
+
name: '--vaadin-ai-field-marker-mask-pos',
|
|
62
|
+
syntax: '<length-percentage>',
|
|
63
|
+
inherits: false,
|
|
64
|
+
initialValue: '0px',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Adds the marker's keyframes to the field's own shadow root, where the
|
|
69
|
+
* animation names used by the `::part()` rules above have to resolve, since
|
|
70
|
+
* keyframes are looked up in the tree scope of the animated element.
|
|
71
|
+
*
|
|
72
|
+
* Injected as a `<style>` element rather than an adopted stylesheet because the
|
|
73
|
+
* themable infrastructure replaces `adoptedStyleSheets` wholesale — on a Lumo
|
|
74
|
+
* stylesheet load or a theme switch — which would silently drop the keyframes
|
|
75
|
+
* and leave the field's input masked but never animating.
|
|
76
|
+
*
|
|
77
|
+
* @param {HTMLElement} field
|
|
78
|
+
*/
|
|
79
|
+
function injectMarkerHostStyles(field) {
|
|
80
|
+
if (field.shadowRoot.querySelector(`style[${MARKER_STYLE_ATTRIBUTE}]`)) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const style = document.createElement('style');
|
|
85
|
+
style.setAttribute(MARKER_STYLE_ATTRIBUTE, '');
|
|
86
|
+
style.textContent = aiFieldMarkerHostStyles.cssText;
|
|
87
|
+
field.shadowRoot.appendChild(style);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Holds back value assignments on a field, so that the value an AI fills in
|
|
92
|
+
* lands halfway through the marker's slide animation instead of instantly.
|
|
93
|
+
*
|
|
94
|
+
* While installed, the field's own `value` accessor is replaced with one that
|
|
95
|
+
* queues an assignment and applies it after the delay; a further assignment
|
|
96
|
+
* supersedes a queued one. Uninstalling restores the field's own accessor and
|
|
97
|
+
* applies a queued assignment right away, since it carries the value the
|
|
98
|
+
* marker was working on and nothing may land after the accessor is restored —
|
|
99
|
+
* a late-landing value would overwrite one the host has set since.
|
|
100
|
+
*/
|
|
101
|
+
class DelayedFieldValue {
|
|
102
|
+
/** The intercepted accessor, found on the field's prototype chain. */
|
|
103
|
+
#descriptor;
|
|
104
|
+
|
|
105
|
+
#field;
|
|
106
|
+
|
|
107
|
+
#delay;
|
|
108
|
+
|
|
109
|
+
#timer = null;
|
|
110
|
+
|
|
111
|
+
/** The queued value, while `#timer` is pending. */
|
|
112
|
+
#queuedValue;
|
|
113
|
+
|
|
114
|
+
/** Whether the field's own `value` accessor is currently replaced. */
|
|
115
|
+
#installed = false;
|
|
116
|
+
|
|
117
|
+
constructor(field, delay) {
|
|
118
|
+
this.#field = field;
|
|
119
|
+
this.#delay = delay;
|
|
120
|
+
|
|
121
|
+
let descriptor = null;
|
|
122
|
+
for (let proto = Object.getPrototypeOf(field); proto && !descriptor; proto = Object.getPrototypeOf(proto)) {
|
|
123
|
+
descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
124
|
+
}
|
|
125
|
+
this.#descriptor = descriptor?.get && descriptor.set ? descriptor : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Whether the field exposes a `value` accessor that can be intercepted.
|
|
130
|
+
* Without one there is nothing to delegate to, and defining an own `value`
|
|
131
|
+
* would make `'value' in field` report a value the field does not have.
|
|
132
|
+
*
|
|
133
|
+
* @return {boolean}
|
|
134
|
+
*/
|
|
135
|
+
get supported() {
|
|
136
|
+
return this.#descriptor != null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The value the field ends up with: a queued value while one is pending,
|
|
141
|
+
* otherwise the field's current value. Reading `field.value` instead would
|
|
142
|
+
* return the value that the queued assignment is about to replace.
|
|
143
|
+
*
|
|
144
|
+
* @return {unknown}
|
|
145
|
+
*/
|
|
146
|
+
get latestValue() {
|
|
147
|
+
return this.#timer != null ? this.#queuedValue : this.#field.value;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Starts holding back value assignments. Keeps a queued assignment. */
|
|
151
|
+
install() {
|
|
152
|
+
const field = this.#field;
|
|
153
|
+
if (!this.supported || Object.getOwnPropertyDescriptor(field, 'value')) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const descriptor = this.#descriptor;
|
|
158
|
+
Object.defineProperty(field, 'value', {
|
|
159
|
+
configurable: true,
|
|
160
|
+
get: () => descriptor.get.call(field),
|
|
161
|
+
set: (value) => {
|
|
162
|
+
this.#queuedValue = value;
|
|
163
|
+
clearTimeout(this.#timer);
|
|
164
|
+
this.#timer = setTimeout(() => this.#flush(), this.#delay);
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
this.#installed = true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Restores the field's own accessor, applying a queued value right away. */
|
|
171
|
+
uninstall() {
|
|
172
|
+
// Only remove an own property that this instance defined, so that a field
|
|
173
|
+
// keeping its value in an own property instead of an accessor — which
|
|
174
|
+
// `install()` leaves alone — does not lose it.
|
|
175
|
+
if (this.#installed) {
|
|
176
|
+
delete this.#field.value;
|
|
177
|
+
this.#installed = false;
|
|
178
|
+
}
|
|
179
|
+
this.#flush();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Applies a queued value right away. Applied through the intercepted
|
|
184
|
+
* accessor, so an installed hold-back stays in place for further
|
|
185
|
+
* assignments — this is also how a queued value lands on its deadline.
|
|
186
|
+
*/
|
|
187
|
+
#flush() {
|
|
188
|
+
if (this.#timer == null) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
clearTimeout(this.#timer);
|
|
193
|
+
this.#timer = null;
|
|
194
|
+
const value = this.#queuedValue;
|
|
195
|
+
this.#queuedValue = null;
|
|
196
|
+
this.#descriptor.set.call(this.#field, value);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* An element used internally by Vaadin. Not intended to be used separately.
|
|
202
|
+
*
|
|
203
|
+
* Annotates a field as AI-filled: appended as a direct child of the field,
|
|
204
|
+
* it slots itself into the field via a slot injected into the field's shadow
|
|
205
|
+
* root, draws an "AI" badge anchored to the field, and offers a popover that
|
|
206
|
+
* explains the AI fill and lets the user revert the value.
|
|
207
|
+
*
|
|
208
|
+
* The marker manages the annotation through its own lifecycle: adding it to
|
|
209
|
+
* the field marks the field, removing it clears the mark:
|
|
210
|
+
*
|
|
211
|
+
* ```js
|
|
212
|
+
* const marker = document.createElement('vaadin-ai-field-marker');
|
|
213
|
+
* marker.i18n = { message: 'Filled based on the uploaded document.' };
|
|
214
|
+
* field.appendChild(marker);
|
|
215
|
+
* // ...
|
|
216
|
+
* marker.remove();
|
|
217
|
+
* ```
|
|
218
|
+
*
|
|
219
|
+
* While an AI fill is in progress, set the `working` property to show an
|
|
220
|
+
* "AI is working" shimmer on the field along with a client-side read-only
|
|
221
|
+
* guard. An existing mark is hidden for the duration, since the value it
|
|
222
|
+
* annotates is about to be replaced; setting `working` back to `false`
|
|
223
|
+
* brings it back, so a cancelled or failed fill leaves the mark intact.
|
|
224
|
+
*
|
|
225
|
+
* The popover can show custom content — such as a summary of what the AI
|
|
226
|
+
* based the value on — below the explanation, given as a DOM node through
|
|
227
|
+
* the `content` property.
|
|
228
|
+
*
|
|
229
|
+
* The pieces that construct the marker — the badge, its tooltip and the
|
|
230
|
+
* popover with the explanation and the revert control — are rendered
|
|
231
|
+
* directly into the marker's light DOM, so that document-level themes
|
|
232
|
+
* and user stylesheets can reach them.
|
|
233
|
+
*
|
|
234
|
+
* Set the `confidence` property to show the confidence level of the filled
|
|
235
|
+
* value (`low`, `medium` or `high`) as an indicator in the field's helper
|
|
236
|
+
* text section, ahead of a helper the field itself may have. While the
|
|
237
|
+
* indicator is shown, the field is marked with `has-helper`, so that the
|
|
238
|
+
* helper text section is laid out the same as for a helper of its own.
|
|
239
|
+
*
|
|
240
|
+
* ### Styling
|
|
241
|
+
*
|
|
242
|
+
* The following state attributes are set on the field element for styling:
|
|
243
|
+
*
|
|
244
|
+
* Attribute | Description
|
|
245
|
+
* ----------------|-------------
|
|
246
|
+
* `ai-working` | Set while an AI is working on the field.
|
|
247
|
+
*
|
|
248
|
+
* The confidence indicator is rendered into the field's light DOM as a
|
|
249
|
+
* `<span>` with the `ai-confidence` class name and the level as an additional
|
|
250
|
+
* `ai-confidence-low`, `ai-confidence-medium` or `ai-confidence-high` one.
|
|
251
|
+
*
|
|
252
|
+
* The following custom CSS properties are available for styling:
|
|
253
|
+
*
|
|
254
|
+
* Custom CSS property |
|
|
255
|
+
* :----------------------------------------------------|
|
|
256
|
+
* `--vaadin-ai-field-marker-badge-icon-color` |
|
|
257
|
+
* `--vaadin-ai-field-marker-confidence-high-color` |
|
|
258
|
+
* `--vaadin-ai-field-marker-confidence-low-color` |
|
|
259
|
+
* `--vaadin-ai-field-marker-confidence-medium-color` |
|
|
260
|
+
*
|
|
261
|
+
* See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation.
|
|
262
|
+
*
|
|
263
|
+
* @fires {CustomEvent} ai-field-revert - Fired from the field element when the user activates the revert control. The host restores the value.
|
|
264
|
+
*
|
|
265
|
+
* @customElement vaadin-ai-field-marker
|
|
266
|
+
* @extends HTMLElement
|
|
267
|
+
* @private
|
|
268
|
+
*/
|
|
269
|
+
class AiFieldMarker extends SlotStylesMixin(I18nMixin(DirMixin(PolylitMixin(LitElement)))) {
|
|
270
|
+
static get is() {
|
|
271
|
+
return 'vaadin-ai-field-marker';
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
static get defaultI18n() {
|
|
275
|
+
return DEFAULT_I18N;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
static get properties() {
|
|
279
|
+
return {
|
|
280
|
+
/**
|
|
281
|
+
* A DOM node to show in the popover, between the message and the revert
|
|
282
|
+
* control — for example a summary of what the AI based the value on.
|
|
283
|
+
*
|
|
284
|
+
* The node is rendered as given, and moved into the marker's own light
|
|
285
|
+
* DOM. The host owns it: setting the property to another node or to
|
|
286
|
+
* `null` removes the previous node from the popover.
|
|
287
|
+
*
|
|
288
|
+
* ```js
|
|
289
|
+
* const source = document.createElement('a');
|
|
290
|
+
* source.href = '/documents/invoice.pdf';
|
|
291
|
+
* source.textContent = 'invoice.pdf';
|
|
292
|
+
* marker.content = source;
|
|
293
|
+
* ```
|
|
294
|
+
*/
|
|
295
|
+
content: {
|
|
296
|
+
type: Object,
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Whether an AI is currently working on the field. While `true`, the
|
|
301
|
+
* field shows an "AI is working" shimmer and is made read-only on the
|
|
302
|
+
* client so the user cannot edit a value the AI is about to overwrite;
|
|
303
|
+
* only the client-side `readonly` state is touched, and setting the
|
|
304
|
+
* property back to `false` restores it. The marker badge is hidden for
|
|
305
|
+
* the duration, since the value it annotates is about to be replaced.
|
|
306
|
+
* For assistive technology, the field is marked with `aria-busy`.
|
|
307
|
+
*/
|
|
308
|
+
working: {
|
|
309
|
+
type: Boolean,
|
|
310
|
+
value: false,
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The confidence level of the AI-filled value, shown as an indicator
|
|
315
|
+
* in the field's helper text section. Possible values are `low`,
|
|
316
|
+
* `medium` and `high`; when not set, no indicator is shown. The
|
|
317
|
+
* indicator texts can be localized with the `i18n` property.
|
|
318
|
+
*/
|
|
319
|
+
confidence: {
|
|
320
|
+
type: String,
|
|
321
|
+
value: null,
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The field the marker annotates: its parent element. Set while the marker
|
|
328
|
+
* is connected to a field with a shadow root.
|
|
329
|
+
*/
|
|
330
|
+
#field = null;
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The hidden description node added to the marker's light DOM and linked
|
|
334
|
+
* to the field's input via `aria-describedby`.
|
|
335
|
+
*/
|
|
336
|
+
#descNode = null;
|
|
337
|
+
|
|
338
|
+
/** The element whose `aria-describedby` references the description node. */
|
|
339
|
+
#describedElement = null;
|
|
340
|
+
|
|
341
|
+
/** The field value captured for the revert event detail. */
|
|
342
|
+
#capturedValue;
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The confidence indicator added to the field's light DOM and rendered
|
|
346
|
+
* in the field's helper text section. Set while `confidence` is set on
|
|
347
|
+
* a marked field.
|
|
348
|
+
*/
|
|
349
|
+
#confidenceNode = null;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Observes the field's `has-helper` attribute while the confidence
|
|
353
|
+
* indicator is shown, so that the marker can re-assert it if the field
|
|
354
|
+
* recomputes it from its own helper content. Created on first use.
|
|
355
|
+
*/
|
|
356
|
+
#helperStateObserver = null;
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* While in the working state, the elements whose client-side `readonly`
|
|
360
|
+
* state was overridden — the field itself and, for a `vaadin-custom-field`,
|
|
361
|
+
* its inputs — with their original values, so leaving the working state can
|
|
362
|
+
* restore them. `null` once the state has been restored.
|
|
363
|
+
*/
|
|
364
|
+
#lockedElements = null;
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The pending restore of the captured read-only state, scheduled when the
|
|
368
|
+
* working state ends so the field stays locked for the shimmer wind-down.
|
|
369
|
+
* Non-`null` only while winding down.
|
|
370
|
+
*/
|
|
371
|
+
#restoreTimer = null;
|
|
372
|
+
|
|
373
|
+
/** Set when the AI-fill announcement should be made on the next update. */
|
|
374
|
+
#announcePending = false;
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Holds back the field's value assignments while working. Kept across
|
|
378
|
+
* working states, so a new assignment supersedes a still-queued one.
|
|
379
|
+
*/
|
|
380
|
+
#valueDelay = null;
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Stable badge id: generating it in render() would re-target the tooltip
|
|
384
|
+
* and popover on every re-render.
|
|
385
|
+
*/
|
|
386
|
+
#badgeId = `vaadin-ai-field-marker-${generateUniqueId()}`;
|
|
387
|
+
|
|
388
|
+
constructor() {
|
|
389
|
+
super();
|
|
390
|
+
|
|
391
|
+
// The marker and its popover content live in the field's light DOM, so a
|
|
392
|
+
// click on the badge or inside the popover bubbles to the field host.
|
|
393
|
+
// Fields that open their overlay on any host click (date-picker,
|
|
394
|
+
// multi-select-combo-box) would act on it as if the field itself had been
|
|
395
|
+
// clicked. Keep marker clicks to the marker. The popover and tooltip bind
|
|
396
|
+
// their listeners on the badge, which is a descendant, so they still fire
|
|
397
|
+
// before this bubble-phase listener.
|
|
398
|
+
this.addEventListener('click', (event) => event.stopPropagation());
|
|
399
|
+
|
|
400
|
+
// Close the popover when focus moves on, e.g. by tabbing to the next
|
|
401
|
+
// field: a click-triggered popover only closes itself on outside pointer
|
|
402
|
+
// interaction or Esc, so popovers of several marked fields could pile up.
|
|
403
|
+
// Where focus ended up is read only once the transition has settled — mid
|
|
404
|
+
// transition the document has no focused element, which the popover
|
|
405
|
+
// overlay reads as focus not having left it (see
|
|
406
|
+
// OverlayFocusMixin._shouldRestoreFocus) and restores focus to the badge,
|
|
407
|
+
// stealing it from the input the user clicked.
|
|
408
|
+
this.addEventListener('focusout', () => {
|
|
409
|
+
setTimeout(() => {
|
|
410
|
+
if (!this.contains(getDeepActiveElement())) {
|
|
411
|
+
this.#closePopover();
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Render into the light DOM instead of a shadow root: the themes can only
|
|
419
|
+
* reach a nested Vaadin component (the tooltip, the popover) there, since
|
|
420
|
+
* Aura selects components by tag name at document scope and has no way
|
|
421
|
+
* into another component's shadow root. The tooltip and popover target the
|
|
422
|
+
* badge by id, which resolves in the light-DOM scope shared by all three.
|
|
423
|
+
*
|
|
424
|
+
* @protected
|
|
425
|
+
* @override
|
|
426
|
+
*/
|
|
427
|
+
createRenderRoot() {
|
|
428
|
+
return this;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The object used to localize this component. To change the default
|
|
433
|
+
* localization, replace this with an object that provides all properties, or
|
|
434
|
+
* just the individual properties you want to change.
|
|
435
|
+
*
|
|
436
|
+
* The object has the following JSON structure and default values:
|
|
437
|
+
*
|
|
438
|
+
* ```
|
|
439
|
+
* {
|
|
440
|
+
* // The message shown in the popover explaining the AI fill.
|
|
441
|
+
* message: 'This field value was modified by AI.',
|
|
442
|
+
* // The label of the revert control.
|
|
443
|
+
* revert: 'Revert Value',
|
|
444
|
+
* // The accessible label of the badge button and the popover dialog.
|
|
445
|
+
* badgeLabel: 'AI-provided value',
|
|
446
|
+
* // The tooltip text of the badge button.
|
|
447
|
+
* badgeTooltip: 'Field value modified by AI.\nClick for details',
|
|
448
|
+
* // The texts of the confidence indicator.
|
|
449
|
+
* confidence: {
|
|
450
|
+
* low: 'Low confidence',
|
|
451
|
+
* medium: 'Medium confidence',
|
|
452
|
+
* high: 'High confidence'
|
|
453
|
+
* }
|
|
454
|
+
* }
|
|
455
|
+
* ```
|
|
456
|
+
*
|
|
457
|
+
* @return {!Object}
|
|
458
|
+
*/
|
|
459
|
+
get i18n() {
|
|
460
|
+
return super.i18n;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
set i18n(value) {
|
|
464
|
+
super.i18n = value;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Override getter from `SlotStylesMixin` to insert the marker styles into
|
|
469
|
+
* the field's root node — the marker's own root node, since the marker is
|
|
470
|
+
* a child of the field — so the badge, popover and working-shimmer styles
|
|
471
|
+
* apply to the field.
|
|
472
|
+
*
|
|
473
|
+
* `SlotStylesMixin` inserts them as a `<style>` element rather than an
|
|
474
|
+
* adopted stylesheet, which matters here: the themable infrastructure
|
|
475
|
+
* replaces `adoptedStyleSheets` wholesale — on a Lumo stylesheet load or a
|
|
476
|
+
* theme switch — which, for a field nested in another component's shadow
|
|
477
|
+
* root, would silently drop the marker styles.
|
|
478
|
+
*
|
|
479
|
+
* @protected
|
|
480
|
+
* @override
|
|
481
|
+
* @return {string[]}
|
|
482
|
+
*/
|
|
483
|
+
get slotStyles() {
|
|
484
|
+
return [aiFieldMarkerStyles.cssText];
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Marks the parent field as AI-filled: injects the highlight + badge +
|
|
489
|
+
* popover into the field's shadow root, announces the change to screen
|
|
490
|
+
* readers, and associates the explanation with the field's input.
|
|
491
|
+
* Does nothing when the parent is not a field with a shadow root.
|
|
492
|
+
*
|
|
493
|
+
* @protected
|
|
494
|
+
* @override
|
|
495
|
+
*/
|
|
496
|
+
connectedCallback() {
|
|
497
|
+
super.connectedCallback();
|
|
498
|
+
|
|
499
|
+
const parent = this.parentElement;
|
|
500
|
+
if (parent?.shadowRoot) {
|
|
501
|
+
this.#field = parent;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Render now that the field is known: PolylitMixin's synchronous render on
|
|
505
|
+
// first connect ran before it was resolved, and on a reconnect no property
|
|
506
|
+
// change schedules an update. Requested even without a field, so that moving
|
|
507
|
+
// the marker to a parent that is not one clears the previous field's UI.
|
|
508
|
+
this.requestUpdate();
|
|
509
|
+
|
|
510
|
+
if (this.#field) {
|
|
511
|
+
this.#markField();
|
|
512
|
+
} else {
|
|
513
|
+
this.#markWhenUpgraded(parent);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Removes the AI-filled annotation from the field the marker was attached
|
|
519
|
+
* to: clears the working state (restoring the field's client-side
|
|
520
|
+
* read-only state), the input description and the injected slot.
|
|
521
|
+
*
|
|
522
|
+
* @protected
|
|
523
|
+
* @override
|
|
524
|
+
*/
|
|
525
|
+
disconnectedCallback() {
|
|
526
|
+
super.disconnectedCallback();
|
|
527
|
+
|
|
528
|
+
const field = this.#field;
|
|
529
|
+
if (!field) {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
this.#stopWorking(true);
|
|
534
|
+
|
|
535
|
+
this.#removeConfidenceNode();
|
|
536
|
+
|
|
537
|
+
if (this.#descNode) {
|
|
538
|
+
removeValuesFromAttribute(this.#describedElement, 'aria-describedby', this.#descNode.id);
|
|
539
|
+
this.#descNode.remove();
|
|
540
|
+
this.#descNode = null;
|
|
541
|
+
this.#describedElement = null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Remove the injected slot and styles unless another marker still uses them.
|
|
545
|
+
if (!field.querySelector(`:scope > ${AiFieldMarker.is}`)) {
|
|
546
|
+
field.shadowRoot.querySelector(`slot[name="${MARKER_SLOT}"]`)?.remove();
|
|
547
|
+
field.shadowRoot.querySelector(`style[${MARKER_STYLE_ATTRIBUTE}]`)?.remove();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
this.#field = null;
|
|
551
|
+
this.#valueDelay = null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* @protected
|
|
556
|
+
* @override
|
|
557
|
+
*/
|
|
558
|
+
updated(props) {
|
|
559
|
+
super.updated(props);
|
|
560
|
+
|
|
561
|
+
// Keep the hidden field description in sync with the current message.
|
|
562
|
+
if (props.has('__effectiveI18n') && this.#descNode) {
|
|
563
|
+
this.#descNode.textContent = this.__effectiveI18n.message;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const field = this.#field;
|
|
567
|
+
if (!field) {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (props.has('confidence') || props.has('__effectiveI18n')) {
|
|
572
|
+
this.#updateConfidence();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (props.has('working')) {
|
|
576
|
+
if (this.working) {
|
|
577
|
+
this.#startWorking();
|
|
578
|
+
} else if (this.#lockedElements) {
|
|
579
|
+
this.#stopWorking();
|
|
580
|
+
// The fill landed: the marker now annotates the current value, so
|
|
581
|
+
// re-capture it for the revert event and announce the mark again.
|
|
582
|
+
this.#capturedValue = this.#annotatedValue();
|
|
583
|
+
this.#announcePending = true;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// The indicator is hidden while working, so the helper text section is
|
|
587
|
+
// only claimed for it — and the indicator described — once the working
|
|
588
|
+
// state ends.
|
|
589
|
+
this.#updateConfidenceDescription();
|
|
590
|
+
this.#updateFieldHelperState();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Announce after the update so the announcement reflects a message set in
|
|
594
|
+
// the same batch as the append or the `working` toggle.
|
|
595
|
+
if (this.#announcePending && !this.working) {
|
|
596
|
+
this.#announcePending = false;
|
|
597
|
+
const { message } = this.__effectiveI18n;
|
|
598
|
+
const { label } = field;
|
|
599
|
+
announce(label ? `${label}: ${message}` : message);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** @protected */
|
|
604
|
+
render() {
|
|
605
|
+
if (!this.#field) {
|
|
606
|
+
return nothing;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const { message, revert, badgeLabel, badgeTooltip } = this.__effectiveI18n;
|
|
610
|
+
// Safari leaves buttons out of the tab order unless they set a tabindex.
|
|
611
|
+
return html`
|
|
612
|
+
<button id="${this.#badgeId}" class="badge" type="button" tabindex="0" aria-label="${badgeLabel}"></button>
|
|
613
|
+
<vaadin-tooltip for="${this.#badgeId}" text="${badgeTooltip}"></vaadin-tooltip>
|
|
614
|
+
<vaadin-popover for="${this.#badgeId}" aria-label="${badgeLabel}" autofocus theme="arrow" position="end-top">
|
|
615
|
+
<p class="message">${message}</p>
|
|
616
|
+
${this.content ?? nothing}
|
|
617
|
+
<div class="actions">
|
|
618
|
+
<button type="button" tabindex="0" @click="${this.#onRevert}">${revert}</button>
|
|
619
|
+
</div>
|
|
620
|
+
</vaadin-popover>
|
|
621
|
+
`;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Waits for the parent's custom element definition to load and marks it then,
|
|
626
|
+
* for a marker attached before the field was upgraded — at which point it had
|
|
627
|
+
* no shadow root to inject the marker into.
|
|
628
|
+
*
|
|
629
|
+
* @param {HTMLElement} parent
|
|
630
|
+
*/
|
|
631
|
+
#markWhenUpgraded(parent) {
|
|
632
|
+
const tagName = parent?.localName;
|
|
633
|
+
if (!tagName || !tagName.includes('-') || customElements.get(tagName)) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
customElements.whenDefined(tagName).then(() => {
|
|
638
|
+
// The marker may have been moved or removed while the field was loading,
|
|
639
|
+
// or already marked by a callback an earlier connect to the same parent
|
|
640
|
+
// left waiting.
|
|
641
|
+
if (!this.isConnected || this.#field || this.parentElement !== parent || !parent.shadowRoot) {
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
this.#field = parent;
|
|
646
|
+
this.requestUpdate();
|
|
647
|
+
this.#markField();
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Injects the marker into the resolved field and describes it as AI-filled.
|
|
653
|
+
*/
|
|
654
|
+
#markField() {
|
|
655
|
+
const field = this.#field;
|
|
656
|
+
|
|
657
|
+
injectMarkerHostStyles(field);
|
|
658
|
+
|
|
659
|
+
// Create a slot for the marker element inside the field's own shadow
|
|
660
|
+
// root (unless a previous marker already left one) and assign the marker
|
|
661
|
+
// to it, so the marker renders although the field defines no such slot.
|
|
662
|
+
if (!field.shadowRoot.querySelector(`slot[name="${MARKER_SLOT}"]`)) {
|
|
663
|
+
const markerSlot = document.createElement('slot');
|
|
664
|
+
markerSlot.setAttribute('name', MARKER_SLOT);
|
|
665
|
+
field.shadowRoot.appendChild(markerSlot);
|
|
666
|
+
}
|
|
667
|
+
this.slot = MARKER_SLOT;
|
|
668
|
+
|
|
669
|
+
// Add a hidden description node in the field's light DOM (so its id
|
|
670
|
+
// resolves in the described element's scope) and append its id to that
|
|
671
|
+
// element's aria-describedby. Appending — rather than using
|
|
672
|
+
// aria-description, which a screen reader ignores when aria-describedby is
|
|
673
|
+
// present — lets the field's own helper/error description and the AI note
|
|
674
|
+
// both get read.
|
|
675
|
+
//
|
|
676
|
+
// `ariaTarget` is where the field puts its own descriptions, and is the
|
|
677
|
+
// only one of the three for group and composite fields, which expose
|
|
678
|
+
// neither an input nor a focus element.
|
|
679
|
+
const describedElement = field.ariaTarget || field.inputElement || field.focusElement;
|
|
680
|
+
if (describedElement) {
|
|
681
|
+
const descNode = document.createElement('span');
|
|
682
|
+
descNode.id = `ai-field-marker-${generateUniqueId()}`;
|
|
683
|
+
descNode.className = 'description sr-only';
|
|
684
|
+
descNode.textContent = this.__effectiveI18n.message;
|
|
685
|
+
// Insert before Lit's rendered content so the node stays outside the
|
|
686
|
+
// range Lit manages (and may clear) in the light-DOM render root.
|
|
687
|
+
this.insertBefore(descNode, this.firstChild);
|
|
688
|
+
addValuesToAttribute(describedElement, 'aria-describedby', descNode.id);
|
|
689
|
+
this.#descNode = descNode;
|
|
690
|
+
this.#describedElement = describedElement;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Apply the confidence indicator directly: on a reconnect no property
|
|
694
|
+
// change triggers updated(), which handles the first connect.
|
|
695
|
+
this.#updateConfidence();
|
|
696
|
+
|
|
697
|
+
// Capture the AI-filled value so the revert event can carry it.
|
|
698
|
+
this.#capturedValue = this.#annotatedValue();
|
|
699
|
+
|
|
700
|
+
if (this.working) {
|
|
701
|
+
// Apply the working state directly: on a reconnect no `working`
|
|
702
|
+
// property change triggers updated(), which handles the first connect.
|
|
703
|
+
this.#startWorking();
|
|
704
|
+
} else {
|
|
705
|
+
this.#announcePending = true;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* The field value the mark annotates. A value the AI has already set but
|
|
711
|
+
* that the working state still holds back counts as the annotated one, since
|
|
712
|
+
* it is the value the field ends up showing.
|
|
713
|
+
*
|
|
714
|
+
* @return {unknown} the annotated value, or `undefined` for a field that has
|
|
715
|
+
* no value at all
|
|
716
|
+
*/
|
|
717
|
+
#annotatedValue() {
|
|
718
|
+
const field = this.#field;
|
|
719
|
+
return this.#valueDelay ? this.#valueDelay.latestValue : field.value;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Syncs the confidence indicator in the field's helper text section with
|
|
724
|
+
* the `confidence` property: a `<span>` slotted into the field's helper
|
|
725
|
+
* slot, with the `ai-confidence` and `ai-confidence-<level>` class names
|
|
726
|
+
* and the localized level text as content.
|
|
727
|
+
*/
|
|
728
|
+
#updateConfidence() {
|
|
729
|
+
const field = this.#field;
|
|
730
|
+
if (!field) {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const level = this.confidence;
|
|
735
|
+
if (!level) {
|
|
736
|
+
this.#removeConfidenceNode();
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (!this.#confidenceNode) {
|
|
741
|
+
const node = document.createElement('span');
|
|
742
|
+
node.setAttribute('slot', 'helper');
|
|
743
|
+
// Hide the indicator from the field's helper slot controller, which
|
|
744
|
+
// would otherwise evict the field's own helper element in favor of
|
|
745
|
+
// the indicator. The browser still renders it in the helper slot.
|
|
746
|
+
node.setAttribute('data-slot-ignore', '');
|
|
747
|
+
node.id = `ai-field-marker-confidence-${generateUniqueId()}`;
|
|
748
|
+
// Insert ahead of a helper the field already has, so that the indicator
|
|
749
|
+
// comes first in the helper text section. A helper added later ends up
|
|
750
|
+
// after the indicator, since the field appends it.
|
|
751
|
+
field.insertBefore(node, field.querySelector(':scope > [slot="helper"]'));
|
|
752
|
+
this.#confidenceNode = node;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
this.#confidenceNode.className = `${CONFIDENCE_CLASS} ${CONFIDENCE_CLASS}-${level}`;
|
|
756
|
+
this.#confidenceNode.textContent = this.__effectiveI18n.confidence[level] ?? '';
|
|
757
|
+
this.#updateConfidenceDescription();
|
|
758
|
+
this.#updateFieldHelperState();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Keeps the indicator's id in the described element's `aria-describedby`
|
|
763
|
+
* only while the indicator is shown: a visually hidden indicator would
|
|
764
|
+
* still get read as part of the field's description, although it describes
|
|
765
|
+
* a value the AI is about to replace.
|
|
766
|
+
*/
|
|
767
|
+
#updateConfidenceDescription() {
|
|
768
|
+
const node = this.#confidenceNode;
|
|
769
|
+
if (!node || !this.#describedElement) {
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (this.working) {
|
|
774
|
+
removeValuesFromAttribute(this.#describedElement, 'aria-describedby', node.id);
|
|
775
|
+
} else {
|
|
776
|
+
addValuesToAttribute(this.#describedElement, 'aria-describedby', node.id);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Keeps the field's `has-helper` attribute set while the indicator is
|
|
782
|
+
* shown, since it is content in the field's helper text section although
|
|
783
|
+
* the field's own helper is not what provides it. The attribute is what
|
|
784
|
+
* both the field and the themes key their helper text section styles on —
|
|
785
|
+
* from showing the section at all to placing it above the field for the
|
|
786
|
+
* `helper-above-field` theme.
|
|
787
|
+
*
|
|
788
|
+
* The field recomputes the attribute from its own helper content, which
|
|
789
|
+
* never includes the indicator, so a recomputation can drop it while the
|
|
790
|
+
* indicator is still shown. An observer re-asserts it in that case.
|
|
791
|
+
*/
|
|
792
|
+
#updateFieldHelperState() {
|
|
793
|
+
const field = this.#field;
|
|
794
|
+
|
|
795
|
+
// While the AI is working the indicator is hidden, so the field should
|
|
796
|
+
// only reserve the helper text section for a helper of its own.
|
|
797
|
+
if (this.#confidenceNode && !this.working) {
|
|
798
|
+
field.toggleAttribute('has-helper', true);
|
|
799
|
+
|
|
800
|
+
this.#helperStateObserver ??= new MutationObserver(() => {
|
|
801
|
+
// Read the field live: the observer is reused when the marker moves
|
|
802
|
+
// to another field, so a captured one could be a previous field.
|
|
803
|
+
const observedField = this.#field;
|
|
804
|
+
if (observedField && this.#confidenceNode && !this.working && !observedField.hasAttribute('has-helper')) {
|
|
805
|
+
observedField.toggleAttribute('has-helper', true);
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
this.#helperStateObserver.observe(field, { attributes: true, attributeFilter: ['has-helper'] });
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
this.#helperStateObserver?.disconnect();
|
|
813
|
+
|
|
814
|
+
// The field keeps the attribute when its own helper provides content,
|
|
815
|
+
// which it may have gained while the indicator was shown.
|
|
816
|
+
if (!this.#hasFieldHelper()) {
|
|
817
|
+
field.removeAttribute('has-helper');
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Whether the field has helper content of its own, i.e. helper slot content
|
|
823
|
+
* other than the indicator. Judged with the same content check the field
|
|
824
|
+
* itself uses for its `has-helper` attribute.
|
|
825
|
+
*
|
|
826
|
+
* @return {boolean}
|
|
827
|
+
*/
|
|
828
|
+
#hasFieldHelper() {
|
|
829
|
+
return [...this.#field.querySelectorAll(':scope > [slot="helper"]')].some(
|
|
830
|
+
(node) => node !== this.#confidenceNode && hasNodeContent(node),
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** Removes the confidence indicator. */
|
|
835
|
+
#removeConfidenceNode() {
|
|
836
|
+
const node = this.#confidenceNode;
|
|
837
|
+
if (!node) {
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (this.#describedElement) {
|
|
842
|
+
removeValuesFromAttribute(this.#describedElement, 'aria-describedby', node.id);
|
|
843
|
+
}
|
|
844
|
+
node.remove();
|
|
845
|
+
this.#confidenceNode = null;
|
|
846
|
+
this.#updateFieldHelperState();
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Enters the "AI is working" state: shows the shimmer and makes the field
|
|
851
|
+
* read-only on the client so the user cannot edit a value the AI is about
|
|
852
|
+
* to overwrite. Idempotent — keeps the state captured on entry.
|
|
853
|
+
*/
|
|
854
|
+
#startWorking() {
|
|
855
|
+
const field = this.#field;
|
|
856
|
+
if (!field || (this.#lockedElements && this.#restoreTimer == null)) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
this.#valueDelay ??= new DelayedFieldValue(field, HALF_WORKING_SLIDE_MS);
|
|
861
|
+
this.#valueDelay.install();
|
|
862
|
+
|
|
863
|
+
if (this.#restoreTimer != null) {
|
|
864
|
+
// The previous working state is still winding down. Cancel its restore
|
|
865
|
+
// and keep the read-only state it captured: the elements are locked right
|
|
866
|
+
// now, so capturing again would take the lock itself as the original.
|
|
867
|
+
clearTimeout(this.#restoreTimer);
|
|
868
|
+
this.#restoreTimer = null;
|
|
869
|
+
} else {
|
|
870
|
+
// A composite field does not propagate `readonly` to its inputs, so they
|
|
871
|
+
// are locked (and restored) individually alongside the field. Recognized
|
|
872
|
+
// by the `inputs` array rather than by tag name, which also covers a
|
|
873
|
+
// composite field shipped under its own tag name.
|
|
874
|
+
const locked = [field, ...(Array.isArray(field.inputs) ? field.inputs : [])];
|
|
875
|
+
this.#lockedElements = locked.map((element) => {
|
|
876
|
+
// A composite field also accepts native inputs, which spell the
|
|
877
|
+
// property `readOnly`.
|
|
878
|
+
const property = 'readonly' in element ? 'readonly' : 'readOnly';
|
|
879
|
+
return { element, property, value: element[property] };
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
field.setAttribute('ai-working', '');
|
|
884
|
+
// Expose the working state to assistive technology on the same element
|
|
885
|
+
// that carries the AI description: the shimmer alone is only visual.
|
|
886
|
+
this.#describedElement?.setAttribute('aria-busy', 'true');
|
|
887
|
+
this.#lockedElements.forEach(({ element, property }) => {
|
|
888
|
+
element[property] = true;
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Leaves the "AI is working" state: removes the shimmer and restores the
|
|
894
|
+
* field's previous client-side read-only state. A no-op when not working.
|
|
895
|
+
*
|
|
896
|
+
* @param {boolean} immediate restore the read-only state right away instead
|
|
897
|
+
* of after the shimmer wind-down (used on disconnect)
|
|
898
|
+
*/
|
|
899
|
+
#stopWorking(immediate = false) {
|
|
900
|
+
const field = this.#field;
|
|
901
|
+
|
|
902
|
+
if (this.#restoreTimer != null) {
|
|
903
|
+
// Already winding down. Finish it now when the marker is going away, so
|
|
904
|
+
// the restore cannot overwrite a read-only state set after this point
|
|
905
|
+
// and a value still queued from the working state cannot land after it.
|
|
906
|
+
if (immediate) {
|
|
907
|
+
this.#restoreLockedElements();
|
|
908
|
+
}
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (!this.#lockedElements) {
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
field.removeAttribute('ai-working');
|
|
917
|
+
this.#describedElement?.removeAttribute('aria-busy');
|
|
918
|
+
|
|
919
|
+
// The value hold-back stays installed for the wind-down: a queued value
|
|
920
|
+
// still lands on its own deadline, and a value the host sets before the
|
|
921
|
+
// wind-down finishes supersedes a queued one instead of being overwritten
|
|
922
|
+
// by it when its deadline passes. The restore then lifts the hold-back.
|
|
923
|
+
if (immediate) {
|
|
924
|
+
this.#restoreLockedElements();
|
|
925
|
+
} else {
|
|
926
|
+
this.#restoreTimer = setTimeout(() => this.#restoreLockedElements(), HALF_WORKING_SLIDE_MS);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Restores the read-only state captured when the working state was entered
|
|
932
|
+
* and stops holding back the field's value assignments, applying a value
|
|
933
|
+
* still queued at this point right away — rather than on its deadline, after
|
|
934
|
+
* the marker stopped controlling the field.
|
|
935
|
+
*/
|
|
936
|
+
#restoreLockedElements() {
|
|
937
|
+
clearTimeout(this.#restoreTimer);
|
|
938
|
+
this.#restoreTimer = null;
|
|
939
|
+
|
|
940
|
+
this.#valueDelay.uninstall();
|
|
941
|
+
|
|
942
|
+
const locked = this.#lockedElements;
|
|
943
|
+
this.#lockedElements = null;
|
|
944
|
+
locked.forEach(({ element, property, value }) => {
|
|
945
|
+
element[property] = value;
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** Closes the marker's popover, if rendered. */
|
|
950
|
+
#closePopover() {
|
|
951
|
+
const popover = this.querySelector(':scope > vaadin-popover');
|
|
952
|
+
if (popover) {
|
|
953
|
+
popover.opened = false;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
#onRevert() {
|
|
958
|
+
// Return focus to the field before closing the popover. The popover
|
|
959
|
+
// targets the badge for focus restoration, but the host may remove the
|
|
960
|
+
// marker on revert, which would drop focus to the body. Moving focus to
|
|
961
|
+
// the field first makes the overlay skip its own restore — it only
|
|
962
|
+
// restores while focus is still inside the overlay (see
|
|
963
|
+
// OverlayFocusMixin._shouldRestoreFocus).
|
|
964
|
+
//
|
|
965
|
+
// Focus the field's own focusable element rather than calling focus() on
|
|
966
|
+
// the host: a host focus() can carry component-specific semantics that a
|
|
967
|
+
// revert must not trigger — date-picker opens its overlay on focus while
|
|
968
|
+
// it has no usable text input (fullscreen, iOS, or no i18n.parseDate).
|
|
969
|
+
//
|
|
970
|
+
// The revert control can be activated by pointer as well as by keyboard,
|
|
971
|
+
// so the focus ring is left to the current interaction modality instead of
|
|
972
|
+
// being forced on, which is what a bare focus() does on a Vaadin field.
|
|
973
|
+
const field = this.#field;
|
|
974
|
+
if (field) {
|
|
975
|
+
const focusTarget = field.focusElement || field.inputElement || getTabbableElements(field)[0] || field;
|
|
976
|
+
focusTarget.focus({ focusVisible: isKeyboardActive() });
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
this.#closePopover();
|
|
980
|
+
|
|
981
|
+
if (field) {
|
|
982
|
+
field.dispatchEvent(
|
|
983
|
+
new CustomEvent('ai-field-revert', {
|
|
984
|
+
bubbles: true,
|
|
985
|
+
composed: true,
|
|
986
|
+
detail: { value: this.#capturedValue },
|
|
987
|
+
}),
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
defineCustomElement(AiFieldMarker);
|
|
994
|
+
|
|
995
|
+
export { AiFieldMarker };
|