diff --git a/dev/ai-field-marker.html b/dev/ai-field-marker.html index 084747bc025..0d035572107 100644 --- a/dev/ai-field-marker.html +++ b/dev/ai-field-marker.html @@ -74,6 +74,8 @@

AI-filled fields

// Show the "AI is working" state while the fill is in progress. const marker = document.createElement('vaadin-ai-field-marker'); marker.working = true; + // Show the confidence level of the filled value in the helper section. + marker.confidence = ['low', 'medium', 'high'][Math.floor(3 * random())]; field.appendChild(marker); }); diff --git a/packages/aura/aura.css b/packages/aura/aura.css index b51f91e5729..7b18f6a7d04 100644 --- a/packages/aura/aura.css +++ b/packages/aura/aura.css @@ -11,6 +11,7 @@ @import './src/components/input-container.css'; @import './src/components/accordion-details.css'; +@import './src/components/ai-field-marker.css'; @import './src/components/app-layout.css'; @import './src/components/avatar.css'; @import './src/components/badge.css'; diff --git a/packages/aura/src/components/ai-field-marker.css b/packages/aura/src/components/ai-field-marker.css new file mode 100644 index 00000000000..b0662401979 --- /dev/null +++ b/packages/aura/src/components/ai-field-marker.css @@ -0,0 +1,5 @@ +:has(> vaadin-ai-field-marker) { + --vaadin-ai-field-marker-confidence-low-color: var(--aura-red-text); + --vaadin-ai-field-marker-confidence-medium-color: var(--aura-yellow-text); + --vaadin-ai-field-marker-confidence-high-color: var(--aura-green-text); +} diff --git a/packages/field-highlighter/src/styles/vaadin-ai-field-marker-base-styles.js b/packages/field-highlighter/src/styles/vaadin-ai-field-marker-base-styles.js index 02e1fff5403..237411946eb 100644 --- a/packages/field-highlighter/src/styles/vaadin-ai-field-marker-base-styles.js +++ b/packages/field-highlighter/src/styles/vaadin-ai-field-marker-base-styles.js @@ -181,6 +181,65 @@ export const aiFieldMarkerStyles = css` } } + /* The confidence indicator: a sibling of the marker slotted into the + field's helper text section. The level class name picks the color and how + much of the pie icon is filled. */ + :has(> vaadin-ai-field-marker) > [slot='helper'].ai-confidence { + display: flex; + align-items: center; + gap: var(--vaadin-gap-s); + color: var(--_vaadin-ai-field-marker-confidence-color); + + &::before { + content: ''; + flex: none; + box-sizing: border-box; + width: var(--vaadin-icon-size, 1lh); + height: var(--vaadin-icon-size, 1lh); + /* The padding insets the pie from the ring: the wedge is clipped to the + content box, while the tint fills the whole circle behind it. */ + padding: calc(var(--vaadin-icon-size, 1lh) / 12); + border: 1px solid color-mix(in srgb, currentColor 50%, transparent); + border-radius: 50%; + background-color: color-mix(in srgb, currentColor 15%, transparent); + background-image: conic-gradient(currentColor var(--_vaadin-ai-field-marker-confidence-fill, 0%), #0000 0%); + background-clip: content-box, border-box; + } + + /* The levels are told apart by how much of the pie is filled, so the base + styles keep them in the same neutral color. The themes map the custom + properties to their own semantic colors. */ + &.ai-confidence-low { + --_vaadin-ai-field-marker-confidence-color: var( + --vaadin-ai-field-marker-confidence-low-color, + var(--vaadin-text-color-secondary) + ); + --_vaadin-ai-field-marker-confidence-fill: 25%; + } + + &.ai-confidence-medium { + --_vaadin-ai-field-marker-confidence-color: var( + --vaadin-ai-field-marker-confidence-medium-color, + var(--vaadin-text-color-secondary) + ); + --_vaadin-ai-field-marker-confidence-fill: 50%; + } + + &.ai-confidence-high { + --_vaadin-ai-field-marker-confidence-color: var( + --vaadin-ai-field-marker-confidence-high-color, + var(--vaadin-text-color-secondary) + ); + --_vaadin-ai-field-marker-confidence-fill: 75%; + } + } + + /* While the AI is working, the confidence describes a value that is about + to be replaced, so hide it along with the marker. */ + [ai-working] > [slot='helper'].ai-confidence { + display: none; + } + [ai-working], :has(> vaadin-ai-field-marker)::part(input-field), :has(> vaadin-ai-field-marker)::part(input-fields), diff --git a/packages/field-highlighter/src/vaadin-ai-field-marker.d.ts b/packages/field-highlighter/src/vaadin-ai-field-marker.d.ts index 47f251b9963..b91ca9f6dc9 100644 --- a/packages/field-highlighter/src/vaadin-ai-field-marker.d.ts +++ b/packages/field-highlighter/src/vaadin-ai-field-marker.d.ts @@ -28,6 +28,15 @@ export interface AiFieldMarkerI18n { * The tooltip text of the badge button. */ badgeTooltip?: string; + + /** + * The texts of the confidence indicator. + */ + confidence?: { + low?: string; + medium?: string; + high?: string; + }; } /** @@ -53,20 +62,32 @@ export type AiFieldRevertEvent = CustomEvent<{ value: unknown }>; * in progress, set the `working` property to show an "AI is working" shimmer * on the field along with a client-side read-only guard. * + * Set the `confidence` property to show the confidence level of the filled + * value (`low`, `medium` or `high`) as an indicator in the field's helper + * text section, ahead of a helper the field itself may have. While the + * indicator is shown, the field is marked with `has-helper`, so that the + * helper text section is laid out the same as for a helper of its own. + * * ### Styling * - * The following state attribute is set on the field element for styling: + * The following state attributes are set on the field element for styling: * - * Attribute | Description - * -------------|------------- - * `ai-working` | Set while an AI is working on the field. + * Attribute | Description + * ----------------|------------- + * `ai-working` | Set while an AI is working on the field. + * + * The confidence indicator is rendered into the field's light DOM as a + * `` with the `ai-confidence` class name and the level as an additional + * `ai-confidence-low`, `ai-confidence-medium` or `ai-confidence-high` one. * * The following custom CSS properties are available for styling: * - * Custom CSS property | - * :-------------------------------------------| - * `--vaadin-ai-field-marker-badge-icon-color` | - * `--vaadin-ai-field-marker-mask-pos` | + * Custom CSS property | + * :----------------------------------------------------| + * `--vaadin-ai-field-marker-badge-icon-color` | + * `--vaadin-ai-field-marker-confidence-high-color` | + * `--vaadin-ai-field-marker-confidence-low-color` | + * `--vaadin-ai-field-marker-confidence-medium-color` | * * See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation. * @@ -89,7 +110,13 @@ declare class AiFieldMarker extends I18nMixin` element the marker injects into a field's shadow root. */ const MARKER_STYLE_ATTRIBUTE = 'ai-field-marker-styles'; +/** + * The class name of the confidence indicator the marker adds to the field's + * light DOM; the level goes on a suffixed class name of its own. Prefixed + * with `ai-`, since the indicator sits among the application's own children + * of the field, where a plain `confidence` or `low` would be ambiguous. + */ +const CONFIDENCE_CLASS = 'ai-confidence'; + // The position the shimmer's mask is at, animated by the marker's keyframes. // Registered here rather than with an @property rule in the marker stylesheet, // which is injected into the field's root node: a registration only takes effect @@ -210,20 +227,32 @@ class DelayedFieldValue { * directly into the marker's light DOM, so that document-level themes * and user stylesheets can reach them. * + * Set the `confidence` property to show the confidence level of the filled + * value (`low`, `medium` or `high`) as an indicator in the field's helper + * text section, ahead of a helper the field itself may have. While the + * indicator is shown, the field is marked with `has-helper`, so that the + * helper text section is laid out the same as for a helper of its own. + * * ### Styling * - * The following state attribute is set on the field element for styling: + * The following state attributes are set on the field element for styling: + * + * Attribute | Description + * ----------------|------------- + * `ai-working` | Set while an AI is working on the field. * - * Attribute | Description - * -------------|------------- - * `ai-working` | Set while an AI is working on the field. + * The confidence indicator is rendered into the field's light DOM as a + * `` with the `ai-confidence` class name and the level as an additional + * `ai-confidence-low`, `ai-confidence-medium` or `ai-confidence-high` one. * * The following custom CSS properties are available for styling: * - * Custom CSS property | - * :-------------------------------------------| - * `--vaadin-ai-field-marker-badge-icon-color` | - * `--vaadin-ai-field-marker-mask-pos` | + * Custom CSS property | + * :----------------------------------------------------| + * `--vaadin-ai-field-marker-badge-icon-color` | + * `--vaadin-ai-field-marker-confidence-high-color` | + * `--vaadin-ai-field-marker-confidence-low-color` | + * `--vaadin-ai-field-marker-confidence-medium-color` | * * See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation. * @@ -257,6 +286,17 @@ class AiFieldMarker extends SlotStylesMixin(I18nMixin(DirMixin(PolylitMixin(LitE type: Boolean, value: false, }, + + /** + * The confidence level of the AI-filled value, shown as an indicator + * in the field's helper text section. Possible values are `low`, + * `medium` and `high`; when not set, no indicator is shown. The + * indicator texts can be localized with the `i18n` property. + */ + confidence: { + type: String, + value: null, + }, }; } @@ -278,6 +318,20 @@ class AiFieldMarker extends SlotStylesMixin(I18nMixin(DirMixin(PolylitMixin(LitE /** The field value captured for the revert event detail. */ #capturedValue; + /** + * The confidence indicator added to the field's light DOM and rendered + * in the field's helper text section. Set while `confidence` is set on + * a marked field. + */ + #confidenceNode = null; + + /** + * Observes the field's `has-helper` attribute while the confidence + * indicator is shown, so that the marker can re-assert it if the field + * recomputes it from its own helper content. Created on first use. + */ + #helperStateObserver = null; + /** * While in the working state, the elements whose client-side `readonly` * state was overridden — the field itself and, for a `vaadin-custom-field`, @@ -367,7 +421,13 @@ class AiFieldMarker extends SlotStylesMixin(I18nMixin(DirMixin(PolylitMixin(LitE * // The accessible label of the badge button and the popover dialog. * badgeLabel: 'AI-provided value', * // The tooltip text of the badge button. - * badgeTooltip: 'Field value modified by AI.\nClick for details' + * badgeTooltip: 'Field value modified by AI.\nClick for details', + * // The texts of the confidence indicator. + * confidence: { + * low: 'Low confidence', + * medium: 'Medium confidence', + * high: 'High confidence' + * } * } * ``` * @@ -449,6 +509,8 @@ class AiFieldMarker extends SlotStylesMixin(I18nMixin(DirMixin(PolylitMixin(LitE this.#stopWorking(true); + this.#removeConfidenceNode(); + if (this.#descNode) { removeValuesFromAttribute(this.#describedElement, 'aria-describedby', this.#descNode.id); this.#descNode.remove(); @@ -483,6 +545,10 @@ class AiFieldMarker extends SlotStylesMixin(I18nMixin(DirMixin(PolylitMixin(LitE return; } + if (props.has('confidence') || props.has('__effectiveI18n')) { + this.#updateConfidence(); + } + if (props.has('working')) { if (this.working) { this.#startWorking(); @@ -493,6 +559,12 @@ class AiFieldMarker extends SlotStylesMixin(I18nMixin(DirMixin(PolylitMixin(LitE this.#capturedValue = this.#annotatedValue(); this.#announcePending = true; } + + // The indicator is hidden while working, so the helper text section is + // only claimed for it — and the indicator described — once the working + // state ends. + this.#updateConfidenceDescription(); + this.#updateFieldHelperState(); } // Announce after the update so the announcement reflects a message set in @@ -594,6 +666,10 @@ class AiFieldMarker extends SlotStylesMixin(I18nMixin(DirMixin(PolylitMixin(LitE this.#describedElement = describedElement; } + // Apply the confidence indicator directly: on a reconnect no property + // change triggers updated(), which handles the first connect. + this.#updateConfidence(); + // Capture the AI-filled value so the revert event can carry it. this.#capturedValue = this.#annotatedValue(); @@ -619,6 +695,133 @@ class AiFieldMarker extends SlotStylesMixin(I18nMixin(DirMixin(PolylitMixin(LitE return this.#valueDelay ? this.#valueDelay.latestValue : field.value; } + /** + * Syncs the confidence indicator in the field's helper text section with + * the `confidence` property: a `` slotted into the field's helper + * slot, with the `ai-confidence` and `ai-confidence-` class names + * and the localized level text as content. + */ + #updateConfidence() { + const field = this.#field; + if (!field) { + return; + } + + const level = this.confidence; + if (!level) { + this.#removeConfidenceNode(); + return; + } + + if (!this.#confidenceNode) { + const node = document.createElement('span'); + node.setAttribute('slot', 'helper'); + // Hide the indicator from the field's helper slot controller, which + // would otherwise evict the field's own helper element in favor of + // the indicator. The browser still renders it in the helper slot. + node.setAttribute('data-slot-ignore', ''); + node.id = `ai-field-marker-confidence-${generateUniqueId()}`; + // Insert ahead of a helper the field already has, so that the indicator + // comes first in the helper text section. A helper added later ends up + // after the indicator, since the field appends it. + field.insertBefore(node, field.querySelector(':scope > [slot="helper"]')); + this.#confidenceNode = node; + } + + this.#confidenceNode.className = `${CONFIDENCE_CLASS} ${CONFIDENCE_CLASS}-${level}`; + this.#confidenceNode.textContent = this.__effectiveI18n.confidence[level] ?? ''; + this.#updateConfidenceDescription(); + this.#updateFieldHelperState(); + } + + /** + * Keeps the indicator's id in the described element's `aria-describedby` + * only while the indicator is shown: a visually hidden indicator would + * still get read as part of the field's description, although it describes + * a value the AI is about to replace. + */ + #updateConfidenceDescription() { + const node = this.#confidenceNode; + if (!node || !this.#describedElement) { + return; + } + + if (this.working) { + removeValuesFromAttribute(this.#describedElement, 'aria-describedby', node.id); + } else { + addValuesToAttribute(this.#describedElement, 'aria-describedby', node.id); + } + } + + /** + * Keeps the field's `has-helper` attribute set while the indicator is + * shown, since it is content in the field's helper text section although + * the field's own helper is not what provides it. The attribute is what + * both the field and the themes key their helper text section styles on — + * from showing the section at all to placing it above the field for the + * `helper-above-field` theme. + * + * The field recomputes the attribute from its own helper content, which + * never includes the indicator, so a recomputation can drop it while the + * indicator is still shown. An observer re-asserts it in that case. + */ + #updateFieldHelperState() { + const field = this.#field; + + // While the AI is working the indicator is hidden, so the field should + // only reserve the helper text section for a helper of its own. + if (this.#confidenceNode && !this.working) { + field.toggleAttribute('has-helper', true); + + this.#helperStateObserver ??= new MutationObserver(() => { + // Read the field live: the observer is reused when the marker moves + // to another field, so a captured one could be a previous field. + const observedField = this.#field; + if (observedField && this.#confidenceNode && !this.working && !observedField.hasAttribute('has-helper')) { + observedField.toggleAttribute('has-helper', true); + } + }); + this.#helperStateObserver.observe(field, { attributes: true, attributeFilter: ['has-helper'] }); + return; + } + + this.#helperStateObserver?.disconnect(); + + // The field keeps the attribute when its own helper provides content, + // which it may have gained while the indicator was shown. + if (!this.#hasFieldHelper()) { + field.removeAttribute('has-helper'); + } + } + + /** + * Whether the field has helper content of its own, i.e. helper slot content + * other than the indicator. Judged with the same content check the field + * itself uses for its `has-helper` attribute. + * + * @return {boolean} + */ + #hasFieldHelper() { + return [...this.#field.querySelectorAll(':scope > [slot="helper"]')].some( + (node) => node !== this.#confidenceNode && hasNodeContent(node), + ); + } + + /** Removes the confidence indicator. */ + #removeConfidenceNode() { + const node = this.#confidenceNode; + if (!node) { + return; + } + + if (this.#describedElement) { + removeValuesFromAttribute(this.#describedElement, 'aria-describedby', node.id); + } + node.remove(); + this.#confidenceNode = null; + this.#updateFieldHelperState(); + } + /** * Enters the "AI is working" state: shows the shimmer and makes the field * read-only on the client so the user cannot edit a value the AI is about diff --git a/packages/field-highlighter/test/ai-field-marker.test.ts b/packages/field-highlighter/test/ai-field-marker.test.ts index ed2aaa1e2ab..b020ef478c9 100644 --- a/packages/field-highlighter/test/ai-field-marker.test.ts +++ b/packages/field-highlighter/test/ai-field-marker.test.ts @@ -91,6 +91,19 @@ class FocusSensitiveField extends HTMLElement { customElements.define('focus-sensitive-field', FocusSensitiveField); +/** + * A helper element that renders its content in its shadow root, so it has + * neither light-DOM text nor children although it shows helper content. + */ +class ShadowHelper extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }).textContent = 'Shadow helper'; + } +} + +customElements.define('shadow-helper', ShadowHelper); + /** * A field that exposes none of the elements the marker can describe: no * `ariaTarget`, no `inputElement` and no `focusElement`. @@ -634,6 +647,438 @@ describe('ai field marker', () => { }); }); + describe('confidence', () => { + let marker: AiFieldMarker; + + function getConfidenceNode(host: HTMLElement = field): HTMLSpanElement | null { + return host.querySelector(':scope > [slot="helper"].ai-confidence'); + } + + beforeEach(async () => { + marker = mark(field, { confidence: 'low' }); + await nextRender(); + }); + + it('should have no confidence by default', () => { + expect(document.createElement('vaadin-ai-field-marker').confidence).to.be.null; + }); + + it('should render the confidence indicator into the field helper slot', () => { + const node = getConfidenceNode()!; + expect(node).to.exist; + expect(node.textContent).to.equal('Low confidence'); + expect(node.assignedSlot).to.exist; + expect(node.assignedSlot!.name).to.equal('helper'); + expect(node.assignedSlot!.getRootNode()).to.equal(field.shadowRoot); + }); + + it('should have the confidence level as a class name', () => { + expect(getConfidenceNode()!.classList.contains('ai-confidence-low')).to.be.true; + }); + + it('should show the helper text section for a field without a helper', () => { + const helperPart = field.shadowRoot!.querySelector('[part="helper-text"]')!; + expect(getComputedStyle(helperPart).display).to.not.equal('none'); + }); + + describe('has-helper', () => { + it('should set has-helper on the field while the indicator is shown', () => { + expect(field.hasAttribute('has-helper')).to.be.true; + }); + + it('should toggle has-helper when the confidence is set and cleared', async () => { + marker.confidence = null; + await nextUpdate(marker); + expect(field.hasAttribute('has-helper')).to.be.false; + + marker.confidence = 'high'; + await nextUpdate(marker); + expect(field.hasAttribute('has-helper')).to.be.true; + }); + + it('should remove has-helper when the marker is removed', async () => { + marker.remove(); + await nextRender(); + + expect(field.hasAttribute('has-helper')).to.be.false; + }); + + it('should not set has-helper while the AI is working', async () => { + marker.working = true; + await nextUpdate(marker); + expect(field.hasAttribute('has-helper')).to.be.false; + + marker.working = false; + await nextUpdate(marker); + expect(field.hasAttribute('has-helper')).to.be.true; + }); + + it('should keep has-helper for the field helper text while the AI is working', async () => { + field.helperText = 'Keep it short'; + await nextRender(); + + marker.working = true; + await nextUpdate(marker); + + expect(field.hasAttribute('has-helper')).to.be.true; + }); + + it('should keep has-helper for a helper with only element content', async () => { + const childField = fixtureSync( + `
`, + ); + await nextRender(); + const childMarker = mark(childField, { confidence: 'low' }); + await nextRender(); + + childMarker.working = true; + await nextUpdate(childMarker); + + expect(childField.hasAttribute('has-helper')).to.be.true; + }); + + it('should not keep has-helper for an empty slotted helper element', async () => { + const emptyField = fixtureSync( + ``, + ); + await nextRender(); + const emptyMarker = mark(emptyField, { confidence: 'low' }); + await nextRender(); + + emptyMarker.working = true; + await nextUpdate(emptyMarker); + + expect(emptyField.hasAttribute('has-helper')).to.be.false; + }); + + it('should not re-assert has-helper dropped as the working state begins', async () => { + field.helperText = 'Keep it short'; + await nextRender(); + + // The drop and the working state land in the same update cycle: the + // re-assert observer must judge against the live working state, not + // the state at observation time. + field.removeAttribute('has-helper'); + marker.working = true; + await nextUpdate(marker); + + expect(field.hasAttribute('has-helper')).to.be.false; + }); + + it('should keep has-helper for a field that has a helper of its own', async () => { + field.helperText = 'Keep it short'; + await nextRender(); + + marker.confidence = null; + await nextUpdate(marker); + + expect(field.hasAttribute('has-helper')).to.be.true; + }); + + it('should re-assert has-helper when the field drops it', async () => { + field.helperText = 'Keep it short'; + await nextRender(); + + // Clearing the helper makes the field recompute the attribute from its + // own helper content, which does not include the indicator. + field.helperText = ''; + await nextRender(); + + expect(field.hasAttribute('has-helper')).to.be.true; + }); + + describe('field with a helper rendering in its shadow root', () => { + let helperField: TextField; + + beforeEach(async () => { + // The field counts a defined custom element as helper content even + // without light-DOM text or children, since it may render content + // in its shadow root — the marker must judge it the same way. + helperField = fixtureSync( + ``, + ); + await nextRender(); + }); + + it('should keep has-helper while the AI is working', async () => { + const helperMarker = mark(helperField, { confidence: 'low' }); + await nextRender(); + + helperMarker.working = true; + await nextUpdate(helperMarker); + + expect(helperField.hasAttribute('has-helper')).to.be.true; + }); + + it('should keep has-helper when the confidence is cleared', async () => { + const helperMarker = mark(helperField, { confidence: 'low' }); + await nextRender(); + + helperMarker.confidence = null; + await nextUpdate(helperMarker); + + expect(helperField.hasAttribute('has-helper')).to.be.true; + }); + + it('should not touch has-helper when no indicator was ever shown', async () => { + const helperMarker = mark(helperField); + await nextRender(); + + helperMarker.working = true; + await nextUpdate(helperMarker); + helperMarker.working = false; + await nextUpdate(helperMarker); + + expect(helperField.hasAttribute('has-helper')).to.be.true; + }); + }); + + it('should stop re-asserting has-helper once the indicator is gone', async () => { + marker.confidence = null; + await nextUpdate(marker); + + field.helperText = 'Keep it short'; + await nextRender(); + field.helperText = ''; + await nextRender(); + + expect(field.hasAttribute('has-helper')).to.be.false; + }); + }); + + it('should update the indicator when the confidence changes', async () => { + marker.confidence = 'high'; + await nextUpdate(marker); + + const node = getConfidenceNode()!; + expect(node.classList.contains('ai-confidence-high')).to.be.true; + expect(node.classList.contains('ai-confidence-low')).to.be.false; + expect(node.textContent).to.equal('High confidence'); + }); + + it('should reuse the indicator element when the confidence changes', async () => { + const node = getConfidenceNode(); + marker.confidence = 'high'; + await nextUpdate(marker); + + expect(getConfidenceNode()).to.equal(node); + expect(field.querySelectorAll(':scope > [slot="helper"].ai-confidence')).to.have.lengthOf(1); + }); + + it('should clear the indicator on a field with no described element', async () => { + const bareField = fixtureSync(``); + await nextRender(); + const bareMarker = mark(bareField, { confidence: 'low' }); + await nextRender(); + expect(bareField.hasAttribute('has-helper')).to.be.true; + + bareMarker.confidence = null; + await nextUpdate(bareMarker); + + expect(bareField.querySelector('.ai-confidence')).to.be.null; + expect(bareField.hasAttribute('has-helper')).to.be.false; + }); + + it('should remove the indicator when the confidence is cleared', async () => { + marker.confidence = null; + await nextUpdate(marker); + + expect(getConfidenceNode()).to.be.null; + }); + + it('should remove the indicator when the marker is removed', async () => { + marker.remove(); + await nextRender(); + + expect(getConfidenceNode()).to.be.null; + }); + + it('should render the indicator when confidence is set after adding', async () => { + const plainField = fixtureSync(``); + await nextRender(); + const plainMarker = mark(plainField); + await nextRender(); + expect(getConfidenceNode(plainField)).to.be.null; + + plainMarker.confidence = 'medium'; + await nextUpdate(plainMarker); + + const node = getConfidenceNode(plainField)!; + expect(node.textContent).to.equal('Medium confidence'); + }); + + it('should render the indicator again when the marker is re-added', async () => { + marker.remove(); + await nextRender(); + field.appendChild(marker); + await nextRender(); + + expect(getConfidenceNode()).to.exist; + expect(getConfidenceNode()!.classList.contains('ai-confidence-low')).to.be.true; + }); + + it('should keep the field helper text alongside the indicator', async () => { + field.helperText = 'Keep it short'; + await nextRender(); + + const helper = field.querySelector(':scope > [slot="helper"]:not(.ai-confidence)')!; + expect(helper.textContent).to.equal('Keep it short'); + expect(helper.assignedSlot).to.exist; + expect(getConfidenceNode()!.assignedSlot).to.exist; + }); + + it('should not evict an existing field helper', async () => { + const helperField = fixtureSync( + ``, + ); + await nextRender(); + const helper = helperField.querySelector(':scope > [slot="helper"]')!; + + mark(helperField, { confidence: 'high' }); + await nextRender(); + + expect(helper.isConnected).to.be.true; + expect(helper.assignedSlot).to.exist; + expect(getConfidenceNode(helperField)).to.exist; + }); + + describe('order', () => { + let helperField: TextField; + + /** The helper slot content in rendered order. */ + function getHelperNodes(host: HTMLElement = helperField): Element[] { + return [...host.querySelectorAll(':scope > [slot="helper"]')]; + } + + beforeEach(async () => { + helperField = fixtureSync( + ``, + ); + await nextRender(); + }); + + it('should render the indicator before an existing field helper', async () => { + mark(helperField, { confidence: 'low' }); + await nextRender(); + + expect(getHelperNodes()[0]).to.equal(getConfidenceNode(helperField)); + expect(getHelperNodes().map((node) => node.textContent)).to.eql(['Low confidence', 'Keep it short']); + }); + + it('should render the indicator before a helper added after marking', async () => { + const plainField = fixtureSync(``); + await nextRender(); + mark(plainField, { confidence: 'low' }); + await nextRender(); + + plainField.helperText = 'Keep it short'; + await nextRender(); + + expect(getHelperNodes(plainField).map((node) => node.textContent)).to.eql(['Low confidence', 'Keep it short']); + }); + + it('should render the indicator before a custom slotted helper', async () => { + const customHelper = document.createElement('span'); + customHelper.setAttribute('slot', 'helper'); + customHelper.textContent = 'Custom helper'; + helperField.appendChild(customHelper); + await nextRender(); + + mark(helperField, { confidence: 'low' }); + await nextRender(); + + expect(getHelperNodes()[0]).to.equal(getConfidenceNode(helperField)); + }); + + it('should keep the indicator first when the field is marked again', async () => { + mark(helperField, { confidence: 'low' }).remove(); + // Re-marking right away is what a host does when a new AI fill starts. + mark(helperField, { confidence: 'high' }); + await nextRender(); + + expect(getHelperNodes().map((node) => node.textContent)).to.eql(['High confidence', 'Keep it short']); + }); + + it('should keep the indicator first across consecutive AI fills', async () => { + // A host filling the same field twice: unmark, re-mark as working, + // then let the fill land. + for (const confidence of ['low', 'high'] as const) { + helperField.querySelector('vaadin-ai-field-marker')?.remove(); + const marker = mark(helperField, { confidence, working: true }); + await nextRender(); + marker.working = false; + await nextUpdate(marker); + } + + expect(getHelperNodes().map((node) => node.textContent)).to.eql(['High confidence', 'Keep it short']); + }); + }); + + it('should describe the field input via aria-describedby', () => { + const ids = field.inputElement.getAttribute('aria-describedby')!.split(' '); + expect(ids).to.include(getConfidenceNode()!.id); + }); + + it('should remove the indicator description when the confidence is cleared', async () => { + const nodeId = getConfidenceNode()!.id; + marker.confidence = null; + await nextUpdate(marker); + + expect(field.inputElement.getAttribute('aria-describedby') || '').to.not.contain(nodeId); + }); + + it('should remove the indicator description while the AI is working', async () => { + // A hidden indicator would still get read as part of the field's + // description, although it describes a value about to be replaced. + const nodeId = getConfidenceNode()!.id; + + marker.working = true; + await nextUpdate(marker); + expect(field.inputElement.getAttribute('aria-describedby') || '').to.not.contain(nodeId); + + marker.working = false; + await nextUpdate(marker); + expect(field.inputElement.getAttribute('aria-describedby')!.split(' ')).to.include(nodeId); + }); + + it('should not describe an indicator shown while the AI is working', async () => { + marker.confidence = null; + await nextUpdate(marker); + marker.working = true; + await nextUpdate(marker); + + marker.confidence = 'high'; + await nextUpdate(marker); + const nodeId = getConfidenceNode()!.id; + expect(field.inputElement.getAttribute('aria-describedby') || '').to.not.contain(nodeId); + + marker.working = false; + await nextUpdate(marker); + expect(field.inputElement.getAttribute('aria-describedby')!.split(' ')).to.include(nodeId); + }); + + it('should hide the indicator while the AI is working', async () => { + marker.working = true; + await nextUpdate(marker); + + expect(getComputedStyle(getConfidenceNode()!).display).to.equal('none'); + }); + + it('should apply localized confidence texts', async () => { + marker.i18n = { confidence: { low: 'Matala luottamus' } }; + await nextUpdate(marker); + + expect(getConfidenceNode()!.textContent).to.equal('Matala luottamus'); + }); + + it('should keep the default texts for levels not provided', async () => { + marker.i18n = { confidence: { high: 'Korkea luottamus' } }; + await nextUpdate(marker); + + expect(getConfidenceNode()!.textContent).to.equal('Low confidence'); + }); + }); + describe('revert', () => { let marker: AiFieldMarker; let revertButton: HTMLButtonElement; @@ -995,6 +1440,27 @@ describe('ai field marker', () => { expect(spy.firstCall.args[0].detail.value).to.equal('Other value'); }); + it('should re-assert has-helper on the field it was moved to', async () => { + const otherField = fixtureSync(``); + await nextRender(); + const marker = mark(field, { confidence: 'low' }); + await nextRender(); + + marker.remove(); + otherField.appendChild(marker); + await nextRender(); + + // Make the new field recompute has-helper from its own helper content, + // which does not include the indicator. + otherField.helperText = 'Keep it short'; + await nextRender(); + otherField.helperText = ''; + await nextRender(); + + expect(otherField.hasAttribute('has-helper')).to.be.true; + expect(field.hasAttribute('has-helper')).to.be.false; + }); + describe('to a field with no described element', () => { // The marker keeps no state of the field it was attached to before, so a // field that provides none of its own can not end up with the previous diff --git a/packages/field-highlighter/test/dom/__snapshots__/ai-field-marker.test.snap.js b/packages/field-highlighter/test/dom/__snapshots__/ai-field-marker.test.snap.js index ef5b60ccb12..8e56e634264 100644 --- a/packages/field-highlighter/test/dom/__snapshots__/ai-field-marker.test.snap.js +++ b/packages/field-highlighter/test/dom/__snapshots__/ai-field-marker.test.snap.js @@ -69,3 +69,15 @@ snapshots["vaadin-ai-field-marker host unmarked"] = `; /* end snapshot vaadin-ai-field-marker host unmarked */ +snapshots["vaadin-ai-field-marker host confidence"] = +` + High confidence + +`; +/* end snapshot vaadin-ai-field-marker host confidence */ + diff --git a/packages/field-highlighter/test/dom/ai-field-marker.test.ts b/packages/field-highlighter/test/dom/ai-field-marker.test.ts index 5c952e1a957..f53b8f5fa2a 100644 --- a/packages/field-highlighter/test/dom/ai-field-marker.test.ts +++ b/packages/field-highlighter/test/dom/ai-field-marker.test.ts @@ -30,5 +30,13 @@ describe('vaadin-ai-field-marker', () => { await nextRender(); await expect(marker).dom.to.equalSnapshot(); }); + + it('confidence', async () => { + marker.confidence = 'high'; + await nextRender(); + // The indicator is a sibling of the marker slotted into the field's + // helper text section. + await expect(field.querySelector(':scope > [slot="helper"]')!).dom.to.equalSnapshot(); + }); }); }); diff --git a/packages/field-highlighter/test/typings/ai-field-marker.types.ts b/packages/field-highlighter/test/typings/ai-field-marker.types.ts index 65f250d663c..5f1a5bccd97 100644 --- a/packages/field-highlighter/test/typings/ai-field-marker.types.ts +++ b/packages/field-highlighter/test/typings/ai-field-marker.types.ts @@ -12,6 +12,10 @@ assertType(aiFieldMarkerHostStyles); const marker = document.createElement('vaadin-ai-field-marker'); assertType(marker.i18n); assertType(marker.working); +assertType<'high' | 'low' | 'medium' | null>(marker.confidence); +assertType(marker.i18n.confidence?.low); +assertType(marker.i18n.confidence?.medium); +assertType(marker.i18n.confidence?.high); // Revert event assertType>({} as AiFieldRevertEvent); diff --git a/packages/field-highlighter/test/visual/ai-field-marker.common.ts b/packages/field-highlighter/test/visual/ai-field-marker.common.ts index f87841d6ceb..08c03b54c98 100644 --- a/packages/field-highlighter/test/visual/ai-field-marker.common.ts +++ b/packages/field-highlighter/test/visual/ai-field-marker.common.ts @@ -87,6 +87,44 @@ describe('ai-field-marker', () => { }); }); + describe('confidence', () => { + beforeEach(async () => { + await createField(); + }); + + (['low', 'medium', 'high'] as const).forEach((confidence) => { + it(confidence, async () => { + mark({ confidence }); + await nextRender(); + await visualDiff(div, `ai-marker-confidence-${confidence}`); + }); + }); + + it('helper text', async () => { + // The indicator comes first in the helper text section. + field.helperText = 'Keep it short, just one value'; + mark({ confidence: 'medium' }); + await nextRender(); + await visualDiff(div, 'ai-marker-confidence-helper-text'); + }); + + it('helper above field', async () => { + // The helper text section, indicator included, moves above the field. + field.setAttribute('theme', 'helper-above-field'); + field.helperText = 'Keep it short, just one value'; + mark({ confidence: 'medium' }); + await nextRender(); + await visualDiff(div, 'ai-marker-confidence-helper-above-field'); + }); + + it('helper above field without helper text', async () => { + field.setAttribute('theme', 'helper-above-field'); + mark({ confidence: 'medium' }); + await nextRender(); + await visualDiff(div, 'ai-marker-confidence-helper-above-field-only'); + }); + }); + describe('working', () => { beforeEach(async () => { await createField(); diff --git a/packages/field-highlighter/test/visual/aura/screenshots/dark/ai-field-marker/baseline/ai-marker-confidence-helper-above-field-only.png b/packages/field-highlighter/test/visual/aura/screenshots/dark/ai-field-marker/baseline/ai-marker-confidence-helper-above-field-only.png new file mode 100644 index 00000000000..45dfe96d403 Binary files /dev/null and b/packages/field-highlighter/test/visual/aura/screenshots/dark/ai-field-marker/baseline/ai-marker-confidence-helper-above-field-only.png differ diff --git a/packages/field-highlighter/test/visual/aura/screenshots/dark/ai-field-marker/baseline/ai-marker-confidence-helper-above-field.png b/packages/field-highlighter/test/visual/aura/screenshots/dark/ai-field-marker/baseline/ai-marker-confidence-helper-above-field.png new file mode 100644 index 00000000000..b805f615087 Binary files /dev/null and b/packages/field-highlighter/test/visual/aura/screenshots/dark/ai-field-marker/baseline/ai-marker-confidence-helper-above-field.png differ diff --git a/packages/field-highlighter/test/visual/aura/screenshots/dark/ai-field-marker/baseline/ai-marker-confidence-helper-text.png b/packages/field-highlighter/test/visual/aura/screenshots/dark/ai-field-marker/baseline/ai-marker-confidence-helper-text.png new file mode 100644 index 00000000000..6c3e77006b4 Binary files /dev/null and b/packages/field-highlighter/test/visual/aura/screenshots/dark/ai-field-marker/baseline/ai-marker-confidence-helper-text.png differ diff --git a/packages/field-highlighter/test/visual/aura/screenshots/dark/ai-field-marker/baseline/ai-marker-confidence-high.png b/packages/field-highlighter/test/visual/aura/screenshots/dark/ai-field-marker/baseline/ai-marker-confidence-high.png new file mode 100644 index 00000000000..6b218c42692 Binary files /dev/null and b/packages/field-highlighter/test/visual/aura/screenshots/dark/ai-field-marker/baseline/ai-marker-confidence-high.png differ diff --git a/packages/field-highlighter/test/visual/aura/screenshots/dark/ai-field-marker/baseline/ai-marker-confidence-low.png b/packages/field-highlighter/test/visual/aura/screenshots/dark/ai-field-marker/baseline/ai-marker-confidence-low.png new file mode 100644 index 00000000000..fe9bbc1240b Binary files /dev/null and b/packages/field-highlighter/test/visual/aura/screenshots/dark/ai-field-marker/baseline/ai-marker-confidence-low.png differ diff --git a/packages/field-highlighter/test/visual/aura/screenshots/dark/ai-field-marker/baseline/ai-marker-confidence-medium.png b/packages/field-highlighter/test/visual/aura/screenshots/dark/ai-field-marker/baseline/ai-marker-confidence-medium.png new file mode 100644 index 00000000000..8a27fe85173 Binary files /dev/null and b/packages/field-highlighter/test/visual/aura/screenshots/dark/ai-field-marker/baseline/ai-marker-confidence-medium.png differ diff --git a/packages/field-highlighter/test/visual/aura/screenshots/default/ai-field-marker/baseline/ai-marker-confidence-helper-above-field-only.png b/packages/field-highlighter/test/visual/aura/screenshots/default/ai-field-marker/baseline/ai-marker-confidence-helper-above-field-only.png new file mode 100644 index 00000000000..61a340fd436 Binary files /dev/null and b/packages/field-highlighter/test/visual/aura/screenshots/default/ai-field-marker/baseline/ai-marker-confidence-helper-above-field-only.png differ diff --git a/packages/field-highlighter/test/visual/aura/screenshots/default/ai-field-marker/baseline/ai-marker-confidence-helper-above-field.png b/packages/field-highlighter/test/visual/aura/screenshots/default/ai-field-marker/baseline/ai-marker-confidence-helper-above-field.png new file mode 100644 index 00000000000..d7ffa2f7c26 Binary files /dev/null and b/packages/field-highlighter/test/visual/aura/screenshots/default/ai-field-marker/baseline/ai-marker-confidence-helper-above-field.png differ diff --git a/packages/field-highlighter/test/visual/aura/screenshots/default/ai-field-marker/baseline/ai-marker-confidence-helper-text.png b/packages/field-highlighter/test/visual/aura/screenshots/default/ai-field-marker/baseline/ai-marker-confidence-helper-text.png new file mode 100644 index 00000000000..b3f448422f2 Binary files /dev/null and b/packages/field-highlighter/test/visual/aura/screenshots/default/ai-field-marker/baseline/ai-marker-confidence-helper-text.png differ diff --git a/packages/field-highlighter/test/visual/aura/screenshots/default/ai-field-marker/baseline/ai-marker-confidence-high.png b/packages/field-highlighter/test/visual/aura/screenshots/default/ai-field-marker/baseline/ai-marker-confidence-high.png new file mode 100644 index 00000000000..28b4f01155e Binary files /dev/null and b/packages/field-highlighter/test/visual/aura/screenshots/default/ai-field-marker/baseline/ai-marker-confidence-high.png differ diff --git a/packages/field-highlighter/test/visual/aura/screenshots/default/ai-field-marker/baseline/ai-marker-confidence-low.png b/packages/field-highlighter/test/visual/aura/screenshots/default/ai-field-marker/baseline/ai-marker-confidence-low.png new file mode 100644 index 00000000000..f5b4228cb07 Binary files /dev/null and b/packages/field-highlighter/test/visual/aura/screenshots/default/ai-field-marker/baseline/ai-marker-confidence-low.png differ diff --git a/packages/field-highlighter/test/visual/aura/screenshots/default/ai-field-marker/baseline/ai-marker-confidence-medium.png b/packages/field-highlighter/test/visual/aura/screenshots/default/ai-field-marker/baseline/ai-marker-confidence-medium.png new file mode 100644 index 00000000000..df31f800746 Binary files /dev/null and b/packages/field-highlighter/test/visual/aura/screenshots/default/ai-field-marker/baseline/ai-marker-confidence-medium.png differ diff --git a/packages/field-highlighter/test/visual/base/screenshots/ai-field-marker/baseline/ai-marker-confidence-helper-above-field-only.png b/packages/field-highlighter/test/visual/base/screenshots/ai-field-marker/baseline/ai-marker-confidence-helper-above-field-only.png new file mode 100644 index 00000000000..787cb6de520 Binary files /dev/null and b/packages/field-highlighter/test/visual/base/screenshots/ai-field-marker/baseline/ai-marker-confidence-helper-above-field-only.png differ diff --git a/packages/field-highlighter/test/visual/base/screenshots/ai-field-marker/baseline/ai-marker-confidence-helper-above-field.png b/packages/field-highlighter/test/visual/base/screenshots/ai-field-marker/baseline/ai-marker-confidence-helper-above-field.png new file mode 100644 index 00000000000..869fd9edbba Binary files /dev/null and b/packages/field-highlighter/test/visual/base/screenshots/ai-field-marker/baseline/ai-marker-confidence-helper-above-field.png differ diff --git a/packages/field-highlighter/test/visual/base/screenshots/ai-field-marker/baseline/ai-marker-confidence-helper-text.png b/packages/field-highlighter/test/visual/base/screenshots/ai-field-marker/baseline/ai-marker-confidence-helper-text.png new file mode 100644 index 00000000000..eb6f41dd187 Binary files /dev/null and b/packages/field-highlighter/test/visual/base/screenshots/ai-field-marker/baseline/ai-marker-confidence-helper-text.png differ diff --git a/packages/field-highlighter/test/visual/base/screenshots/ai-field-marker/baseline/ai-marker-confidence-high.png b/packages/field-highlighter/test/visual/base/screenshots/ai-field-marker/baseline/ai-marker-confidence-high.png new file mode 100644 index 00000000000..affd4e75c9d Binary files /dev/null and b/packages/field-highlighter/test/visual/base/screenshots/ai-field-marker/baseline/ai-marker-confidence-high.png differ diff --git a/packages/field-highlighter/test/visual/base/screenshots/ai-field-marker/baseline/ai-marker-confidence-low.png b/packages/field-highlighter/test/visual/base/screenshots/ai-field-marker/baseline/ai-marker-confidence-low.png new file mode 100644 index 00000000000..f5b8a231a6f Binary files /dev/null and b/packages/field-highlighter/test/visual/base/screenshots/ai-field-marker/baseline/ai-marker-confidence-low.png differ diff --git a/packages/field-highlighter/test/visual/base/screenshots/ai-field-marker/baseline/ai-marker-confidence-medium.png b/packages/field-highlighter/test/visual/base/screenshots/ai-field-marker/baseline/ai-marker-confidence-medium.png new file mode 100644 index 00000000000..574cbe07f42 Binary files /dev/null and b/packages/field-highlighter/test/visual/base/screenshots/ai-field-marker/baseline/ai-marker-confidence-medium.png differ diff --git a/packages/field-highlighter/test/visual/lumo/screenshots/ai-field-marker/baseline/ai-marker-confidence-helper-above-field-only.png b/packages/field-highlighter/test/visual/lumo/screenshots/ai-field-marker/baseline/ai-marker-confidence-helper-above-field-only.png new file mode 100644 index 00000000000..2cb179fe788 Binary files /dev/null and b/packages/field-highlighter/test/visual/lumo/screenshots/ai-field-marker/baseline/ai-marker-confidence-helper-above-field-only.png differ diff --git a/packages/field-highlighter/test/visual/lumo/screenshots/ai-field-marker/baseline/ai-marker-confidence-helper-above-field.png b/packages/field-highlighter/test/visual/lumo/screenshots/ai-field-marker/baseline/ai-marker-confidence-helper-above-field.png new file mode 100644 index 00000000000..01dc2e983aa Binary files /dev/null and b/packages/field-highlighter/test/visual/lumo/screenshots/ai-field-marker/baseline/ai-marker-confidence-helper-above-field.png differ diff --git a/packages/field-highlighter/test/visual/lumo/screenshots/ai-field-marker/baseline/ai-marker-confidence-helper-text.png b/packages/field-highlighter/test/visual/lumo/screenshots/ai-field-marker/baseline/ai-marker-confidence-helper-text.png new file mode 100644 index 00000000000..13e901c8d04 Binary files /dev/null and b/packages/field-highlighter/test/visual/lumo/screenshots/ai-field-marker/baseline/ai-marker-confidence-helper-text.png differ diff --git a/packages/field-highlighter/test/visual/lumo/screenshots/ai-field-marker/baseline/ai-marker-confidence-high.png b/packages/field-highlighter/test/visual/lumo/screenshots/ai-field-marker/baseline/ai-marker-confidence-high.png new file mode 100644 index 00000000000..ee547088db6 Binary files /dev/null and b/packages/field-highlighter/test/visual/lumo/screenshots/ai-field-marker/baseline/ai-marker-confidence-high.png differ diff --git a/packages/field-highlighter/test/visual/lumo/screenshots/ai-field-marker/baseline/ai-marker-confidence-low.png b/packages/field-highlighter/test/visual/lumo/screenshots/ai-field-marker/baseline/ai-marker-confidence-low.png new file mode 100644 index 00000000000..7f881ce7c7f Binary files /dev/null and b/packages/field-highlighter/test/visual/lumo/screenshots/ai-field-marker/baseline/ai-marker-confidence-low.png differ diff --git a/packages/field-highlighter/test/visual/lumo/screenshots/ai-field-marker/baseline/ai-marker-confidence-medium.png b/packages/field-highlighter/test/visual/lumo/screenshots/ai-field-marker/baseline/ai-marker-confidence-medium.png new file mode 100644 index 00000000000..53f62e302bf Binary files /dev/null and b/packages/field-highlighter/test/visual/lumo/screenshots/ai-field-marker/baseline/ai-marker-confidence-medium.png differ diff --git a/packages/vaadin-lumo-styles/src/components/ai-field-marker.css b/packages/vaadin-lumo-styles/src/components/ai-field-marker.css index 481735dbd0b..278c33ffb6e 100644 --- a/packages/vaadin-lumo-styles/src/components/ai-field-marker.css +++ b/packages/vaadin-lumo-styles/src/components/ai-field-marker.css @@ -13,3 +13,9 @@ vaadin-ai-field-marker :is(.badge, .actions > button):focus-visible { top: calc(var(--lumo-space-m) - 6px); } } + +:has(> vaadin-ai-field-marker) { + --vaadin-ai-field-marker-confidence-low-color: var(--lumo-error-text-color); + --vaadin-ai-field-marker-confidence-medium-color: var(--lumo-warning-text-color); + --vaadin-ai-field-marker-confidence-high-color: var(--lumo-success-text-color); +}