@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.
@@ -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 };