@vaadin/field-highlighter 25.2.7 → 25.3.0-alpha10
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 +220 -0
- package/src/vaadin-ai-field-marker.d.ts +116 -0
- package/src/vaadin-ai-field-marker.js +773 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vaadin/field-highlighter",
|
|
3
|
-
"version": "25.
|
|
3
|
+
"version": "25.3.0-alpha10",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -32,30 +32,33 @@
|
|
|
32
32
|
"field"
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@vaadin/a11y-base": "
|
|
36
|
-
"@vaadin/component-base": "
|
|
37
|
-
"@vaadin/overlay": "
|
|
38
|
-
"@vaadin/
|
|
35
|
+
"@vaadin/a11y-base": "25.3.0-alpha10",
|
|
36
|
+
"@vaadin/component-base": "25.3.0-alpha10",
|
|
37
|
+
"@vaadin/overlay": "25.3.0-alpha10",
|
|
38
|
+
"@vaadin/popover": "25.3.0-alpha10",
|
|
39
|
+
"@vaadin/tooltip": "25.3.0-alpha10",
|
|
40
|
+
"@vaadin/vaadin-themable-mixin": "25.3.0-alpha10",
|
|
39
41
|
"lit": "^3.0.0"
|
|
40
42
|
},
|
|
41
43
|
"devDependencies": {
|
|
42
|
-
"@vaadin/aura": "
|
|
43
|
-
"@vaadin/chai-plugins": "
|
|
44
|
-
"@vaadin/checkbox": "
|
|
45
|
-
"@vaadin/checkbox-group": "
|
|
46
|
-
"@vaadin/
|
|
47
|
-
"@vaadin/date-
|
|
48
|
-
"@vaadin/
|
|
49
|
-
"@vaadin/
|
|
50
|
-
"@vaadin/
|
|
51
|
-
"@vaadin/
|
|
52
|
-
"@vaadin/
|
|
44
|
+
"@vaadin/aura": "25.3.0-alpha10",
|
|
45
|
+
"@vaadin/chai-plugins": "25.3.0-alpha10",
|
|
46
|
+
"@vaadin/checkbox": "25.3.0-alpha10",
|
|
47
|
+
"@vaadin/checkbox-group": "25.3.0-alpha10",
|
|
48
|
+
"@vaadin/custom-field": "25.3.0-alpha10",
|
|
49
|
+
"@vaadin/date-picker": "25.3.0-alpha10",
|
|
50
|
+
"@vaadin/date-time-picker": "25.3.0-alpha10",
|
|
51
|
+
"@vaadin/item": "25.3.0-alpha10",
|
|
52
|
+
"@vaadin/list-box": "25.3.0-alpha10",
|
|
53
|
+
"@vaadin/radio-group": "25.3.0-alpha10",
|
|
54
|
+
"@vaadin/select": "25.3.0-alpha10",
|
|
55
|
+
"@vaadin/test-runner-commands": "25.3.0-alpha10",
|
|
53
56
|
"@vaadin/testing-helpers": "^2.0.0",
|
|
54
|
-
"@vaadin/text-area": "
|
|
55
|
-
"@vaadin/text-field": "
|
|
56
|
-
"@vaadin/time-picker": "
|
|
57
|
-
"@vaadin/vaadin-lumo-styles": "
|
|
57
|
+
"@vaadin/text-area": "25.3.0-alpha10",
|
|
58
|
+
"@vaadin/text-field": "25.3.0-alpha10",
|
|
59
|
+
"@vaadin/time-picker": "25.3.0-alpha10",
|
|
60
|
+
"@vaadin/vaadin-lumo-styles": "25.3.0-alpha10",
|
|
58
61
|
"sinon": "^22.0.0"
|
|
59
62
|
},
|
|
60
|
-
"gitHead": "
|
|
63
|
+
"gitHead": "f2833abdf9b613fa0d0ed216830e3f4de87b7dac"
|
|
61
64
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
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 type { CSSResult } from 'lit';
|
|
7
|
+
|
|
8
|
+
export const aiFieldMarkerStyles: CSSResult;
|
|
9
|
+
|
|
10
|
+
export const aiFieldMarkerHostStyles: CSSResult;
|
|
@@ -0,0 +1,220 @@
|
|
|
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 { css } from 'lit';
|
|
7
|
+
import { screenReaderOnly } from '@vaadin/a11y-base/src/styles/sr-only-styles.js';
|
|
8
|
+
|
|
9
|
+
export const aiFieldMarkerHostStyles = css`
|
|
10
|
+
@keyframes --vaadin-ai-field-marker-slide {
|
|
11
|
+
0% {
|
|
12
|
+
--vaadin-ai-field-marker-mask-pos: -100px;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
100% {
|
|
16
|
+
--vaadin-ai-field-marker-mask-pos: calc(100% + 100px);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
@keyframes --vaadin-ai-field-marker-remove-mask {
|
|
21
|
+
100% {
|
|
22
|
+
mask-image: none;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Styles applied to the `<vaadin-ai-field-marker>` element itself: the badge
|
|
29
|
+
* button anchored to the field's top corner and the popover content.
|
|
30
|
+
*/
|
|
31
|
+
export const aiFieldMarkerStyles = css`
|
|
32
|
+
:has(> vaadin-ai-field-marker) {
|
|
33
|
+
position: relative;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* --vaadin-ai-field-marker-mask-pos is registered from JS: an @property rule only
|
|
37
|
+
takes effect at document scope, and this sheet is injected into the field's
|
|
38
|
+
root node, which is a shadow root for a nested field. */
|
|
39
|
+
|
|
40
|
+
vaadin-ai-field-marker[hidden] {
|
|
41
|
+
display: none !important;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
:where(vaadin-ai-field-marker) {
|
|
45
|
+
display: contents;
|
|
46
|
+
--_vaadin-ai-field-marker-color-1: light-dark(#932fffd9, #bc64ff);
|
|
47
|
+
--_vaadin-ai-field-marker-color-2: light-dark(#004cffcc, #539aff);
|
|
48
|
+
|
|
49
|
+
&::before {
|
|
50
|
+
content: '';
|
|
51
|
+
position: absolute;
|
|
52
|
+
inset: -6px;
|
|
53
|
+
border-radius: 9px;
|
|
54
|
+
z-index: -1;
|
|
55
|
+
pointer-events: none;
|
|
56
|
+
background-color: color-mix(in srgb, var(--_vaadin-ai-field-marker-color-2) 30%, transparent);
|
|
57
|
+
background-image:
|
|
58
|
+
radial-gradient(66.92% 123.25% at 100% 88.78%, var(--_vaadin-ai-field-marker-color-1) 0%, transparent 100%),
|
|
59
|
+
radial-gradient(42.57% 69.91% at 14.85% 33.33%, var(--_vaadin-ai-field-marker-color-2) 0%, transparent 100%);
|
|
60
|
+
opacity: 0.16;
|
|
61
|
+
mask-image: linear-gradient(
|
|
62
|
+
90deg,
|
|
63
|
+
#000,
|
|
64
|
+
#000 var(--vaadin-ai-field-marker-mask-pos),
|
|
65
|
+
transparent calc(var(--vaadin-ai-field-marker-mask-pos) + 20px),
|
|
66
|
+
transparent
|
|
67
|
+
);
|
|
68
|
+
animation: --vaadin-ai-field-marker-slide 700ms 200ms both;
|
|
69
|
+
animation-timing-function: cubic-bezier(0.78, 0, 0.22, 1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.badge {
|
|
73
|
+
all: initial;
|
|
74
|
+
position: absolute;
|
|
75
|
+
top: -6px;
|
|
76
|
+
inset-inline-end: -6px;
|
|
77
|
+
box-sizing: border-box;
|
|
78
|
+
display: inline-flex;
|
|
79
|
+
align-items: center;
|
|
80
|
+
padding: 4px;
|
|
81
|
+
border: none;
|
|
82
|
+
border-radius: var(--vaadin-radius-m);
|
|
83
|
+
margin: 0;
|
|
84
|
+
background: transparent;
|
|
85
|
+
color: var(--vaadin-ai-field-marker-badge-icon-color, var(--vaadin-text-color-secondary));
|
|
86
|
+
font: inherit;
|
|
87
|
+
font-size: 1rem;
|
|
88
|
+
line-height: 1;
|
|
89
|
+
cursor: pointer;
|
|
90
|
+
transition: color 200ms;
|
|
91
|
+
animation: --vaadin-ai-field-marker-fade-in 300ms 700ms backwards;
|
|
92
|
+
|
|
93
|
+
&:hover {
|
|
94
|
+
color: var(--vaadin-ai-field-marker-badge-icon-color, var(--vaadin-text-color));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
&::before {
|
|
98
|
+
content: '';
|
|
99
|
+
display: block;
|
|
100
|
+
width: 1lh;
|
|
101
|
+
height: 1lh;
|
|
102
|
+
background: currentColor;
|
|
103
|
+
--_icon-ai-badge: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none"><path d="M7.18848 8.48926H5.46289L6.32715 5.8623L7.18848 8.48926Z" fill="black"/><path fill-rule="evenodd" clip-rule="evenodd" d="M11 0C13.7614 0 16 2.23858 16 5V11C16 13.7614 13.7614 16 11 16H5C2.23858 16 0 13.7614 0 11V5C0 2.23858 2.23858 0 5 0H11ZM5.58203 4.52051L3.25977 11H4.63672L5.10742 9.56934H7.54297L8.01172 11H9.45215L7.12988 4.52051H5.58203ZM10.1211 4.52051V11H11.5068V4.52051H10.1211Z" fill="black"/></svg>');
|
|
104
|
+
mask-image: var(--_icon-ai-badge);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
&::after {
|
|
108
|
+
content: '';
|
|
109
|
+
position: absolute;
|
|
110
|
+
width: 24px;
|
|
111
|
+
height: 24px;
|
|
112
|
+
top: 50%;
|
|
113
|
+
left: 50%;
|
|
114
|
+
translate: -50% -50%;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.badge:focus-visible {
|
|
119
|
+
outline: var(--vaadin-focus-ring-width) solid var(--vaadin-focus-ring-color);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
> vaadin-popover::part(content) {
|
|
123
|
+
display: flex;
|
|
124
|
+
flex-direction: column;
|
|
125
|
+
gap: var(--vaadin-gap-s);
|
|
126
|
+
padding: var(--vaadin-padding-m);
|
|
127
|
+
max-width: 20em;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.message {
|
|
131
|
+
margin: 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* The hidden AI-fill description linked to the field's input via
|
|
135
|
+
aria-describedby: visually hidden but kept in the accessibility tree,
|
|
136
|
+
using the sr-only styles from @vaadin/a11y-base. */
|
|
137
|
+
${screenReaderOnly}
|
|
138
|
+
|
|
139
|
+
.actions {
|
|
140
|
+
display: flex;
|
|
141
|
+
gap: var(--vaadin-gap-xs);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.actions > button {
|
|
145
|
+
display: flex;
|
|
146
|
+
align-items: center;
|
|
147
|
+
gap: var(--vaadin-gap-s);
|
|
148
|
+
pointer-events: auto;
|
|
149
|
+
box-sizing: border-box;
|
|
150
|
+
padding: var(--vaadin-padding-block-container) var(--vaadin-padding-inline-container);
|
|
151
|
+
margin: calc(var(--vaadin-padding-block-container) * -1) calc(var(--vaadin-padding-inline-container) * -1);
|
|
152
|
+
border: 0;
|
|
153
|
+
border-radius: var(--vaadin-radius-m);
|
|
154
|
+
background: transparent;
|
|
155
|
+
color: var(--vaadin-text-color);
|
|
156
|
+
font: inherit;
|
|
157
|
+
font-weight: 500;
|
|
158
|
+
cursor: pointer;
|
|
159
|
+
transition: background-color 100ms;
|
|
160
|
+
|
|
161
|
+
&:hover {
|
|
162
|
+
background: var(--vaadin-background-container);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
&:active {
|
|
166
|
+
background: var(--vaadin-background-container-strong);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
&:focus-visible {
|
|
170
|
+
outline: var(--vaadin-focus-ring-width) solid var(--vaadin-focus-ring-color);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
&::before {
|
|
174
|
+
content: '';
|
|
175
|
+
display: inline-block;
|
|
176
|
+
width: 1em;
|
|
177
|
+
height: 1em;
|
|
178
|
+
background: currentColor;
|
|
179
|
+
mask: var(--_vaadin-icon-undo);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
[ai-working],
|
|
185
|
+
:has(> vaadin-ai-field-marker)::part(input-field),
|
|
186
|
+
:has(> vaadin-ai-field-marker)::part(input-fields),
|
|
187
|
+
:has(> vaadin-ai-field-marker)::part(group-field) {
|
|
188
|
+
mask-image: linear-gradient(
|
|
189
|
+
90deg,
|
|
190
|
+
#000 calc(var(--vaadin-ai-field-marker-mask-pos) - 100px),
|
|
191
|
+
rgba(0, 0, 0, 0.3) calc(var(--vaadin-ai-field-marker-mask-pos) - 70px),
|
|
192
|
+
rgba(0, 0, 0, 0.3) var(--vaadin-ai-field-marker-mask-pos),
|
|
193
|
+
#000 calc(var(--vaadin-ai-field-marker-mask-pos) + 70px),
|
|
194
|
+
#000 calc(var(--vaadin-ai-field-marker-mask-pos) + 100px)
|
|
195
|
+
);
|
|
196
|
+
animation:
|
|
197
|
+
--vaadin-ai-field-marker-slide 1s cubic-bezier(0.78, 0, 0.22, 1) forwards,
|
|
198
|
+
--vaadin-ai-field-marker-remove-mask 0s 1s forwards;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
[ai-working] {
|
|
202
|
+
animation: --vaadin-ai-field-marker-slide 1s ease-in-out infinite;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/* While the AI is working, the badge and glow describe a value that is about
|
|
206
|
+
to be replaced, so hide the marker until the field leaves the working
|
|
207
|
+
state. Hiding rather than unmarking keeps the previous mark for a fill that
|
|
208
|
+
is cancelled or fails. */
|
|
209
|
+
[ai-working] > vaadin-ai-field-marker {
|
|
210
|
+
display: none;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
@keyframes --vaadin-ai-field-marker-fade-in {
|
|
214
|
+
0% {
|
|
215
|
+
opacity: 0;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
${aiFieldMarkerHostStyles}
|
|
220
|
+
`;
|
|
@@ -0,0 +1,116 @@
|
|
|
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 { I18nMixin } from '@vaadin/component-base/src/i18n-mixin.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Localization texts for {@link AiFieldMarker}.
|
|
10
|
+
*/
|
|
11
|
+
export interface AiFieldMarkerI18n {
|
|
12
|
+
/**
|
|
13
|
+
* The message shown in the popover explaining the AI fill.
|
|
14
|
+
*/
|
|
15
|
+
message?: string;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The label of the revert control.
|
|
19
|
+
*/
|
|
20
|
+
revert?: string;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The accessible label of the badge button and the popover dialog.
|
|
24
|
+
*/
|
|
25
|
+
badgeLabel?: string;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The tooltip text of the badge button.
|
|
29
|
+
*/
|
|
30
|
+
badgeTooltip?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Fired from the field element when the user activates the revert control.
|
|
35
|
+
* The host is expected to restore the field's previous value.
|
|
36
|
+
*
|
|
37
|
+
* The event bubbles from the field rather than from the marker, so it is not
|
|
38
|
+
* declared on a listener map: listen for it on the field or a container, and
|
|
39
|
+
* annotate the listener with this type.
|
|
40
|
+
*/
|
|
41
|
+
export type AiFieldRevertEvent = CustomEvent<{ value: unknown }>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* An element used internally by Vaadin. Not intended to be used separately.
|
|
45
|
+
*
|
|
46
|
+
* Annotates a field as AI-filled: appended as a direct child of the field,
|
|
47
|
+
* it slots itself into the field via a slot injected into the field's shadow
|
|
48
|
+
* root, draws an "AI" badge anchored to the field, and offers a popover that
|
|
49
|
+
* explains the AI fill and lets the user revert the value.
|
|
50
|
+
*
|
|
51
|
+
* The marker manages the annotation through its own lifecycle: adding it to
|
|
52
|
+
* the field marks the field, removing it clears the mark. While an AI fill is
|
|
53
|
+
* in progress, set the `working` property to show an "AI is working" shimmer
|
|
54
|
+
* on the field along with a client-side read-only guard.
|
|
55
|
+
*
|
|
56
|
+
* ### Styling
|
|
57
|
+
*
|
|
58
|
+
* The following state attribute is set on the field element for styling:
|
|
59
|
+
*
|
|
60
|
+
* Attribute | Description
|
|
61
|
+
* -------------|-------------
|
|
62
|
+
* `ai-working` | Set while an AI is working on the field.
|
|
63
|
+
*
|
|
64
|
+
* The following custom CSS properties are available for styling:
|
|
65
|
+
*
|
|
66
|
+
* Custom CSS property |
|
|
67
|
+
* :-------------------------------------------|
|
|
68
|
+
* `--vaadin-ai-field-marker-badge-icon-color` |
|
|
69
|
+
* `--vaadin-ai-field-marker-mask-pos` |
|
|
70
|
+
*
|
|
71
|
+
* See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation.
|
|
72
|
+
*
|
|
73
|
+
* @fires {CustomEvent} ai-field-revert - Fired from the field element when the user activates the revert control. The host restores the value.
|
|
74
|
+
*/
|
|
75
|
+
declare class AiFieldMarker extends I18nMixin<typeof HTMLElement, AiFieldMarkerI18n>(HTMLElement) {
|
|
76
|
+
/**
|
|
77
|
+
* The object used to localize this component. To change the default
|
|
78
|
+
* localization, replace this with an object that provides all properties, or
|
|
79
|
+
* just the individual properties you want to change.
|
|
80
|
+
*
|
|
81
|
+
* The object has the following JSON structure and default values:
|
|
82
|
+
*
|
|
83
|
+
* ```
|
|
84
|
+
* {
|
|
85
|
+
* // The message shown in the popover explaining the AI fill.
|
|
86
|
+
* message: 'This field value was modified by AI.',
|
|
87
|
+
* // The label of the revert control.
|
|
88
|
+
* revert: 'Revert Value',
|
|
89
|
+
* // The accessible label of the badge button and the popover dialog.
|
|
90
|
+
* badgeLabel: 'AI-provided value',
|
|
91
|
+
* // The tooltip text of the badge button.
|
|
92
|
+
* badgeTooltip: 'Field value modified by AI.\nClick for details'
|
|
93
|
+
* }
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
i18n: AiFieldMarkerI18n;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether an AI is currently working on the field. While `true`, the field
|
|
100
|
+
* shows an "AI is working" shimmer and is made read-only on the client so
|
|
101
|
+
* the user cannot edit a value the AI is about to overwrite; only the
|
|
102
|
+
* client-side `readonly` state is touched, and setting the property back to
|
|
103
|
+
* `false` restores it. The marker badge is hidden for the duration, since
|
|
104
|
+
* the value it annotates is about to be replaced. For assistive technology,
|
|
105
|
+
* the field is marked with `aria-busy`.
|
|
106
|
+
*/
|
|
107
|
+
working: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
declare global {
|
|
111
|
+
interface HTMLElementTagNameMap {
|
|
112
|
+
'vaadin-ai-field-marker': AiFieldMarker;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export { AiFieldMarker };
|
|
@@ -0,0 +1,773 @@
|
|
|
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 { addValuesToAttribute, removeValuesFromAttribute } from '@vaadin/component-base/src/dom-utils.js';
|
|
15
|
+
import { I18nMixin } from '@vaadin/component-base/src/i18n-mixin.js';
|
|
16
|
+
import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js';
|
|
17
|
+
import { SlotStylesMixin } from '@vaadin/component-base/src/slot-styles-mixin.js';
|
|
18
|
+
import { generateUniqueId } from '@vaadin/component-base/src/unique-id-utils.js';
|
|
19
|
+
import { aiFieldMarkerHostStyles, aiFieldMarkerStyles } from './styles/vaadin-ai-field-marker-base-styles.js';
|
|
20
|
+
|
|
21
|
+
const DEFAULT_I18N = {
|
|
22
|
+
message: 'This field value was modified by AI.',
|
|
23
|
+
revert: 'Revert Value',
|
|
24
|
+
badgeLabel: 'AI-provided value',
|
|
25
|
+
badgeTooltip: 'Field value modified by AI.\nClick for details',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const POPOVER_TRIGGER = ['click'];
|
|
29
|
+
|
|
30
|
+
// Half of the 1s working shimmer slide (`--vaadin-ai-field-marker-slide` in
|
|
31
|
+
// the base styles), so that held-back values land — and the read-only lock
|
|
32
|
+
// lifts — in the middle of a slide instead of at its edge.
|
|
33
|
+
const HALF_WORKING_SLIDE_MS = 500;
|
|
34
|
+
|
|
35
|
+
const MARKER_SLOT = 'ai-field-marker';
|
|
36
|
+
|
|
37
|
+
/** Marks the `<style>` element the marker injects into a field's shadow root. */
|
|
38
|
+
const MARKER_STYLE_ATTRIBUTE = 'ai-field-marker-styles';
|
|
39
|
+
|
|
40
|
+
// The position the shimmer's mask is at, animated by the marker's keyframes.
|
|
41
|
+
// Registered here rather than with an @property rule in the marker stylesheet,
|
|
42
|
+
// which is injected into the field's root node: a registration only takes effect
|
|
43
|
+
// at document scope, and that root node is a shadow root for a field nested
|
|
44
|
+
// inside another component.
|
|
45
|
+
registerCSSProperty({
|
|
46
|
+
name: '--vaadin-ai-field-marker-mask-pos',
|
|
47
|
+
syntax: '<length-percentage>',
|
|
48
|
+
inherits: false,
|
|
49
|
+
initialValue: '0px',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Adds the marker's keyframes to the field's own shadow root, where the
|
|
54
|
+
* animation names used by the `::part()` rules above have to resolve, since
|
|
55
|
+
* keyframes are looked up in the tree scope of the animated element.
|
|
56
|
+
*
|
|
57
|
+
* Injected as a `<style>` element rather than an adopted stylesheet because the
|
|
58
|
+
* themable infrastructure replaces `adoptedStyleSheets` wholesale — on a Lumo
|
|
59
|
+
* stylesheet load or a theme switch — which would silently drop the keyframes
|
|
60
|
+
* and leave the field's input masked but never animating.
|
|
61
|
+
*
|
|
62
|
+
* @param {HTMLElement} field
|
|
63
|
+
*/
|
|
64
|
+
function injectMarkerHostStyles(field) {
|
|
65
|
+
if (field.shadowRoot.querySelector(`style[${MARKER_STYLE_ATTRIBUTE}]`)) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const style = document.createElement('style');
|
|
70
|
+
style.setAttribute(MARKER_STYLE_ATTRIBUTE, '');
|
|
71
|
+
style.textContent = aiFieldMarkerHostStyles.cssText;
|
|
72
|
+
field.shadowRoot.appendChild(style);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Holds back value assignments on a field, so that the value an AI fills in
|
|
77
|
+
* lands halfway through the marker's slide animation instead of instantly.
|
|
78
|
+
*
|
|
79
|
+
* While installed, the field's own `value` accessor is replaced with one that
|
|
80
|
+
* queues an assignment and applies it after the delay; a further assignment
|
|
81
|
+
* supersedes a queued one. Uninstalling restores the field's own accessor and
|
|
82
|
+
* applies a queued assignment right away, since it carries the value the
|
|
83
|
+
* marker was working on and nothing may land after the accessor is restored —
|
|
84
|
+
* a late-landing value would overwrite one the host has set since.
|
|
85
|
+
*/
|
|
86
|
+
class DelayedFieldValue {
|
|
87
|
+
/** The intercepted accessor, found on the field's prototype chain. */
|
|
88
|
+
#descriptor;
|
|
89
|
+
|
|
90
|
+
#field;
|
|
91
|
+
|
|
92
|
+
#delay;
|
|
93
|
+
|
|
94
|
+
#timer = null;
|
|
95
|
+
|
|
96
|
+
/** The queued value, while `#timer` is pending. */
|
|
97
|
+
#queuedValue;
|
|
98
|
+
|
|
99
|
+
constructor(field, delay) {
|
|
100
|
+
this.#field = field;
|
|
101
|
+
this.#delay = delay;
|
|
102
|
+
|
|
103
|
+
let descriptor = null;
|
|
104
|
+
for (let proto = Object.getPrototypeOf(field); proto && !descriptor; proto = Object.getPrototypeOf(proto)) {
|
|
105
|
+
descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
106
|
+
}
|
|
107
|
+
this.#descriptor = descriptor?.get && descriptor.set ? descriptor : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Whether the field exposes a `value` accessor that can be intercepted.
|
|
112
|
+
* Without one there is nothing to delegate to, and defining an own `value`
|
|
113
|
+
* would make `'value' in field` report a value the field does not have.
|
|
114
|
+
*
|
|
115
|
+
* @return {boolean}
|
|
116
|
+
*/
|
|
117
|
+
get supported() {
|
|
118
|
+
return this.#descriptor != null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The value the field ends up with: a queued value while one is pending,
|
|
123
|
+
* otherwise the field's current value. Reading `field.value` instead would
|
|
124
|
+
* return the value that the queued assignment is about to replace.
|
|
125
|
+
*
|
|
126
|
+
* @return {unknown}
|
|
127
|
+
*/
|
|
128
|
+
get latestValue() {
|
|
129
|
+
return this.#timer != null ? this.#queuedValue : this.#field.value;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Starts holding back value assignments. Keeps a queued assignment. */
|
|
133
|
+
install() {
|
|
134
|
+
const field = this.#field;
|
|
135
|
+
if (!this.supported || Object.getOwnPropertyDescriptor(field, 'value')) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const descriptor = this.#descriptor;
|
|
140
|
+
Object.defineProperty(field, 'value', {
|
|
141
|
+
configurable: true,
|
|
142
|
+
get: () => descriptor.get.call(field),
|
|
143
|
+
set: (value) => {
|
|
144
|
+
this.#queuedValue = value;
|
|
145
|
+
clearTimeout(this.#timer);
|
|
146
|
+
this.#timer = setTimeout(() => this.#flush(), this.#delay);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Restores the field's own accessor, applying a queued value right away. */
|
|
152
|
+
uninstall() {
|
|
153
|
+
if (Object.getOwnPropertyDescriptor(this.#field, 'value')) {
|
|
154
|
+
delete this.#field.value;
|
|
155
|
+
}
|
|
156
|
+
this.#flush();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Applies a queued value right away. Applied through the intercepted
|
|
161
|
+
* accessor, so an installed hold-back stays in place for further
|
|
162
|
+
* assignments — this is also how a queued value lands on its deadline.
|
|
163
|
+
*/
|
|
164
|
+
#flush() {
|
|
165
|
+
if (this.#timer == null) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
clearTimeout(this.#timer);
|
|
170
|
+
this.#timer = null;
|
|
171
|
+
const value = this.#queuedValue;
|
|
172
|
+
this.#queuedValue = null;
|
|
173
|
+
this.#descriptor.set.call(this.#field, value);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* An element used internally by Vaadin. Not intended to be used separately.
|
|
179
|
+
*
|
|
180
|
+
* Annotates a field as AI-filled: appended as a direct child of the field,
|
|
181
|
+
* it slots itself into the field via a slot injected into the field's shadow
|
|
182
|
+
* root, draws an "AI" badge anchored to the field, and offers a popover that
|
|
183
|
+
* explains the AI fill and lets the user revert the value.
|
|
184
|
+
*
|
|
185
|
+
* The marker manages the annotation through its own lifecycle: adding it to
|
|
186
|
+
* the field marks the field, removing it clears the mark:
|
|
187
|
+
*
|
|
188
|
+
* ```js
|
|
189
|
+
* const marker = document.createElement('vaadin-ai-field-marker');
|
|
190
|
+
* marker.i18n = { message: 'Filled based on the uploaded document.' };
|
|
191
|
+
* field.appendChild(marker);
|
|
192
|
+
* // ...
|
|
193
|
+
* marker.remove();
|
|
194
|
+
* ```
|
|
195
|
+
*
|
|
196
|
+
* While an AI fill is in progress, set the `working` property to show an
|
|
197
|
+
* "AI is working" shimmer on the field along with a client-side read-only
|
|
198
|
+
* guard. An existing mark is hidden for the duration, since the value it
|
|
199
|
+
* annotates is about to be replaced; setting `working` back to `false`
|
|
200
|
+
* brings it back, so a cancelled or failed fill leaves the mark intact.
|
|
201
|
+
*
|
|
202
|
+
* The pieces that construct the marker — the badge, its tooltip and the
|
|
203
|
+
* popover with the explanation and the revert control — are rendered
|
|
204
|
+
* directly into the marker's light DOM, so that document-level themes
|
|
205
|
+
* and user stylesheets can reach them.
|
|
206
|
+
*
|
|
207
|
+
* ### Styling
|
|
208
|
+
*
|
|
209
|
+
* The following state attribute is set on the field element for styling:
|
|
210
|
+
*
|
|
211
|
+
* Attribute | Description
|
|
212
|
+
* -------------|-------------
|
|
213
|
+
* `ai-working` | Set while an AI is working on the field.
|
|
214
|
+
*
|
|
215
|
+
* The following custom CSS properties are available for styling:
|
|
216
|
+
*
|
|
217
|
+
* Custom CSS property |
|
|
218
|
+
* :-------------------------------------------|
|
|
219
|
+
* `--vaadin-ai-field-marker-badge-icon-color` |
|
|
220
|
+
* `--vaadin-ai-field-marker-mask-pos` |
|
|
221
|
+
*
|
|
222
|
+
* See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation.
|
|
223
|
+
*
|
|
224
|
+
* @fires {CustomEvent} ai-field-revert - Fired from the field element when the user activates the revert control. The host restores the value.
|
|
225
|
+
*
|
|
226
|
+
* @customElement vaadin-ai-field-marker
|
|
227
|
+
* @extends HTMLElement
|
|
228
|
+
* @private
|
|
229
|
+
*/
|
|
230
|
+
class AiFieldMarker extends SlotStylesMixin(I18nMixin(DirMixin(PolylitMixin(LitElement)))) {
|
|
231
|
+
static get is() {
|
|
232
|
+
return 'vaadin-ai-field-marker';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
static get defaultI18n() {
|
|
236
|
+
return DEFAULT_I18N;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
static get properties() {
|
|
240
|
+
return {
|
|
241
|
+
/**
|
|
242
|
+
* Whether an AI is currently working on the field. While `true`, the
|
|
243
|
+
* field shows an "AI is working" shimmer and is made read-only on the
|
|
244
|
+
* client so the user cannot edit a value the AI is about to overwrite;
|
|
245
|
+
* only the client-side `readonly` state is touched, and setting the
|
|
246
|
+
* property back to `false` restores it. The marker badge is hidden for
|
|
247
|
+
* the duration, since the value it annotates is about to be replaced.
|
|
248
|
+
* For assistive technology, the field is marked with `aria-busy`.
|
|
249
|
+
*/
|
|
250
|
+
working: {
|
|
251
|
+
type: Boolean,
|
|
252
|
+
value: false,
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The field the marker annotates: its parent element. Set while the marker
|
|
259
|
+
* is connected to a field with a shadow root.
|
|
260
|
+
*/
|
|
261
|
+
#field = null;
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The hidden description node added to the marker's light DOM and linked
|
|
265
|
+
* to the field's input via `aria-describedby`.
|
|
266
|
+
*/
|
|
267
|
+
#descNode = null;
|
|
268
|
+
|
|
269
|
+
/** The element whose `aria-describedby` references the description node. */
|
|
270
|
+
#describedElement = null;
|
|
271
|
+
|
|
272
|
+
/** The field value captured for the revert event detail. */
|
|
273
|
+
#capturedValue;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* While in the working state, the elements whose client-side `readonly`
|
|
277
|
+
* state was overridden — the field itself and, for a `vaadin-custom-field`,
|
|
278
|
+
* its inputs — with their original values, so leaving the working state can
|
|
279
|
+
* restore them. `null` once the state has been restored.
|
|
280
|
+
*/
|
|
281
|
+
#lockedElements = null;
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The pending restore of the captured read-only state, scheduled when the
|
|
285
|
+
* working state ends so the field stays locked for the shimmer wind-down.
|
|
286
|
+
* Non-`null` only while winding down.
|
|
287
|
+
*/
|
|
288
|
+
#restoreTimer = null;
|
|
289
|
+
|
|
290
|
+
/** Set when the AI-fill announcement should be made on the next update. */
|
|
291
|
+
#announcePending = false;
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Holds back the field's value assignments while working. Kept across
|
|
295
|
+
* working states, so a new assignment supersedes a still-queued one.
|
|
296
|
+
*/
|
|
297
|
+
#valueDelay = null;
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Stable badge id: generating it in render() would re-target the tooltip
|
|
301
|
+
* and popover on every re-render.
|
|
302
|
+
*/
|
|
303
|
+
#badgeId = `vaadin-ai-field-marker-${generateUniqueId()}`;
|
|
304
|
+
|
|
305
|
+
constructor() {
|
|
306
|
+
super();
|
|
307
|
+
|
|
308
|
+
// The marker and its popover content live in the field's light DOM, so a
|
|
309
|
+
// click on the badge or inside the popover bubbles to the field host.
|
|
310
|
+
// Fields that open their overlay on any host click (date-picker,
|
|
311
|
+
// multi-select-combo-box) would act on it as if the field itself had been
|
|
312
|
+
// clicked. Keep marker clicks to the marker. The popover and tooltip bind
|
|
313
|
+
// their listeners on the badge, which is a descendant, so they still fire
|
|
314
|
+
// before this bubble-phase listener.
|
|
315
|
+
this.addEventListener('click', (event) => event.stopPropagation());
|
|
316
|
+
|
|
317
|
+
// Close the popover when focus moves on, e.g. by tabbing to the next
|
|
318
|
+
// field: a click-triggered popover only closes itself on outside pointer
|
|
319
|
+
// interaction or Esc, so popovers of several marked fields could pile up.
|
|
320
|
+
// Where focus ended up is read only once the transition has settled — mid
|
|
321
|
+
// transition the document has no focused element, which the popover
|
|
322
|
+
// overlay reads as focus not having left it (see
|
|
323
|
+
// OverlayFocusMixin._shouldRestoreFocus) and restores focus to the badge,
|
|
324
|
+
// stealing it from the input the user clicked.
|
|
325
|
+
this.addEventListener('focusout', () => {
|
|
326
|
+
setTimeout(() => {
|
|
327
|
+
if (!this.contains(getDeepActiveElement())) {
|
|
328
|
+
this.#closePopover();
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Render into the light DOM instead of a shadow root: the themes can only
|
|
336
|
+
* reach a nested Vaadin component (the tooltip, the popover) there, since
|
|
337
|
+
* Aura selects components by tag name at document scope and has no way
|
|
338
|
+
* into another component's shadow root. The tooltip and popover target the
|
|
339
|
+
* badge by id, which resolves in the light-DOM scope shared by all three.
|
|
340
|
+
*
|
|
341
|
+
* @protected
|
|
342
|
+
* @override
|
|
343
|
+
*/
|
|
344
|
+
createRenderRoot() {
|
|
345
|
+
return this;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The object used to localize this component. To change the default
|
|
350
|
+
* localization, replace this with an object that provides all properties, or
|
|
351
|
+
* just the individual properties you want to change.
|
|
352
|
+
*
|
|
353
|
+
* The object has the following JSON structure and default values:
|
|
354
|
+
*
|
|
355
|
+
* ```
|
|
356
|
+
* {
|
|
357
|
+
* // The message shown in the popover explaining the AI fill.
|
|
358
|
+
* message: 'This field value was modified by AI.',
|
|
359
|
+
* // The label of the revert control.
|
|
360
|
+
* revert: 'Revert Value',
|
|
361
|
+
* // The accessible label of the badge button and the popover dialog.
|
|
362
|
+
* badgeLabel: 'AI-provided value',
|
|
363
|
+
* // The tooltip text of the badge button.
|
|
364
|
+
* badgeTooltip: 'Field value modified by AI.\nClick for details'
|
|
365
|
+
* }
|
|
366
|
+
* ```
|
|
367
|
+
*
|
|
368
|
+
* @return {!Object}
|
|
369
|
+
*/
|
|
370
|
+
get i18n() {
|
|
371
|
+
return super.i18n;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
set i18n(value) {
|
|
375
|
+
super.i18n = value;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Override getter from `SlotStylesMixin` to insert the marker styles into
|
|
380
|
+
* the field's root node — the marker's own root node, since the marker is
|
|
381
|
+
* a child of the field — so the badge, popover and working-shimmer styles
|
|
382
|
+
* apply to the field.
|
|
383
|
+
*
|
|
384
|
+
* `SlotStylesMixin` inserts them as a `<style>` element rather than an
|
|
385
|
+
* adopted stylesheet, which matters here: the themable infrastructure
|
|
386
|
+
* replaces `adoptedStyleSheets` wholesale — on a Lumo stylesheet load or a
|
|
387
|
+
* theme switch — which, for a field nested in another component's shadow
|
|
388
|
+
* root, would silently drop the marker styles.
|
|
389
|
+
*
|
|
390
|
+
* @protected
|
|
391
|
+
* @override
|
|
392
|
+
* @return {string[]}
|
|
393
|
+
*/
|
|
394
|
+
get slotStyles() {
|
|
395
|
+
return [aiFieldMarkerStyles.cssText];
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Marks the parent field as AI-filled: injects the highlight + badge +
|
|
400
|
+
* popover into the field's shadow root, announces the change to screen
|
|
401
|
+
* readers, and associates the explanation with the field's input.
|
|
402
|
+
* Does nothing when the parent is not a field with a shadow root.
|
|
403
|
+
*
|
|
404
|
+
* @protected
|
|
405
|
+
* @override
|
|
406
|
+
*/
|
|
407
|
+
connectedCallback() {
|
|
408
|
+
super.connectedCallback();
|
|
409
|
+
|
|
410
|
+
const parent = this.parentElement;
|
|
411
|
+
if (parent?.shadowRoot) {
|
|
412
|
+
this.#field = parent;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Render now that the field is known: PolylitMixin's synchronous render on
|
|
416
|
+
// first connect ran before it was resolved, and on a reconnect no property
|
|
417
|
+
// change schedules an update. Requested even without a field, so that moving
|
|
418
|
+
// the marker to a parent that is not one clears the previous field's UI.
|
|
419
|
+
this.requestUpdate();
|
|
420
|
+
|
|
421
|
+
if (this.#field) {
|
|
422
|
+
this.#markField();
|
|
423
|
+
} else {
|
|
424
|
+
this.#markWhenUpgraded(parent);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Removes the AI-filled annotation from the field the marker was attached
|
|
430
|
+
* to: clears the working state (restoring the field's client-side
|
|
431
|
+
* read-only state), the input description and the injected slot.
|
|
432
|
+
*
|
|
433
|
+
* @protected
|
|
434
|
+
* @override
|
|
435
|
+
*/
|
|
436
|
+
disconnectedCallback() {
|
|
437
|
+
super.disconnectedCallback();
|
|
438
|
+
|
|
439
|
+
const field = this.#field;
|
|
440
|
+
if (!field) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
this.#stopWorking(true);
|
|
445
|
+
|
|
446
|
+
if (this.#descNode) {
|
|
447
|
+
removeValuesFromAttribute(this.#describedElement, 'aria-describedby', this.#descNode.id);
|
|
448
|
+
this.#descNode.remove();
|
|
449
|
+
this.#descNode = null;
|
|
450
|
+
this.#describedElement = null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Remove the injected slot and styles unless another marker still uses them.
|
|
454
|
+
if (!field.querySelector(`:scope > ${AiFieldMarker.is}`)) {
|
|
455
|
+
field.shadowRoot.querySelector(`slot[name="${MARKER_SLOT}"]`)?.remove();
|
|
456
|
+
field.shadowRoot.querySelector(`style[${MARKER_STYLE_ATTRIBUTE}]`)?.remove();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
this.#field = null;
|
|
460
|
+
this.#valueDelay = null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* @protected
|
|
465
|
+
* @override
|
|
466
|
+
*/
|
|
467
|
+
updated(props) {
|
|
468
|
+
super.updated(props);
|
|
469
|
+
|
|
470
|
+
// Keep the hidden field description in sync with the current message.
|
|
471
|
+
if (props.has('__effectiveI18n') && this.#descNode) {
|
|
472
|
+
this.#descNode.textContent = this.__effectiveI18n.message;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const field = this.#field;
|
|
476
|
+
if (!field) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (props.has('working')) {
|
|
481
|
+
if (this.working) {
|
|
482
|
+
this.#startWorking();
|
|
483
|
+
} else if (this.#lockedElements) {
|
|
484
|
+
this.#stopWorking();
|
|
485
|
+
// The fill landed: the marker now annotates the current value, so
|
|
486
|
+
// re-capture it for the revert event and announce the mark again.
|
|
487
|
+
this.#capturedValue = this.#annotatedValue();
|
|
488
|
+
this.#announcePending = true;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Announce after the update so the announcement reflects a message set in
|
|
493
|
+
// the same batch as the append or the `working` toggle.
|
|
494
|
+
if (this.#announcePending && !this.working) {
|
|
495
|
+
this.#announcePending = false;
|
|
496
|
+
const { message } = this.__effectiveI18n;
|
|
497
|
+
const { label } = field;
|
|
498
|
+
announce(label ? `${label}: ${message}` : message);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** @protected */
|
|
503
|
+
render() {
|
|
504
|
+
if (!this.#field) {
|
|
505
|
+
return nothing;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const { message, revert, badgeLabel, badgeTooltip } = this.__effectiveI18n;
|
|
509
|
+
// Safari leaves buttons out of the tab order unless they set a tabindex.
|
|
510
|
+
return html`
|
|
511
|
+
<button id="${this.#badgeId}" class="badge" type="button" tabindex="0" aria-label="${badgeLabel}"></button>
|
|
512
|
+
<vaadin-tooltip for="${this.#badgeId}" text="${badgeTooltip}"></vaadin-tooltip>
|
|
513
|
+
<vaadin-popover
|
|
514
|
+
for="${this.#badgeId}"
|
|
515
|
+
role="dialog"
|
|
516
|
+
aria-label="${badgeLabel}"
|
|
517
|
+
.trigger="${POPOVER_TRIGGER}"
|
|
518
|
+
autofocus
|
|
519
|
+
theme="arrow"
|
|
520
|
+
position="end-top"
|
|
521
|
+
>
|
|
522
|
+
<p class="message">${message}</p>
|
|
523
|
+
<div class="actions">
|
|
524
|
+
<button type="button" tabindex="0" @click="${this.#onRevert}">${revert}</button>
|
|
525
|
+
</div>
|
|
526
|
+
</vaadin-popover>
|
|
527
|
+
`;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Waits for the parent's custom element definition to load and marks it then,
|
|
532
|
+
* for a marker attached before the field was upgraded — at which point it had
|
|
533
|
+
* no shadow root to inject the marker into.
|
|
534
|
+
*
|
|
535
|
+
* @param {HTMLElement} parent
|
|
536
|
+
*/
|
|
537
|
+
#markWhenUpgraded(parent) {
|
|
538
|
+
const tagName = parent?.localName;
|
|
539
|
+
if (!tagName || !tagName.includes('-') || customElements.get(tagName)) {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
customElements.whenDefined(tagName).then(() => {
|
|
544
|
+
// The marker may have been moved or removed while the field was loading,
|
|
545
|
+
// or already marked by a callback an earlier connect to the same parent
|
|
546
|
+
// left waiting.
|
|
547
|
+
if (!this.isConnected || this.#field || this.parentElement !== parent || !parent.shadowRoot) {
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
this.#field = parent;
|
|
552
|
+
this.requestUpdate();
|
|
553
|
+
this.#markField();
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Injects the marker into the resolved field and describes it as AI-filled.
|
|
559
|
+
*/
|
|
560
|
+
#markField() {
|
|
561
|
+
const field = this.#field;
|
|
562
|
+
|
|
563
|
+
injectMarkerHostStyles(field);
|
|
564
|
+
|
|
565
|
+
// Create a slot for the marker element inside the field's own shadow
|
|
566
|
+
// root (unless a previous marker already left one) and assign the marker
|
|
567
|
+
// to it, so the marker renders although the field defines no such slot.
|
|
568
|
+
if (!field.shadowRoot.querySelector(`slot[name="${MARKER_SLOT}"]`)) {
|
|
569
|
+
const markerSlot = document.createElement('slot');
|
|
570
|
+
markerSlot.setAttribute('name', MARKER_SLOT);
|
|
571
|
+
field.shadowRoot.appendChild(markerSlot);
|
|
572
|
+
}
|
|
573
|
+
this.slot = MARKER_SLOT;
|
|
574
|
+
|
|
575
|
+
// Add a hidden description node in the field's light DOM (so its id
|
|
576
|
+
// resolves in the described element's scope) and append its id to that
|
|
577
|
+
// element's aria-describedby. Appending — rather than using
|
|
578
|
+
// aria-description, which a screen reader ignores when aria-describedby is
|
|
579
|
+
// present — lets the field's own helper/error description and the AI note
|
|
580
|
+
// both get read.
|
|
581
|
+
//
|
|
582
|
+
// `ariaTarget` is where the field puts its own descriptions, and is the
|
|
583
|
+
// only one of the three for group and composite fields, which expose
|
|
584
|
+
// neither an input nor a focus element.
|
|
585
|
+
const describedElement = field.ariaTarget || field.inputElement || field.focusElement;
|
|
586
|
+
if (describedElement) {
|
|
587
|
+
const descNode = document.createElement('span');
|
|
588
|
+
descNode.id = `ai-field-marker-${generateUniqueId()}`;
|
|
589
|
+
descNode.className = 'description sr-only';
|
|
590
|
+
descNode.textContent = this.__effectiveI18n.message;
|
|
591
|
+
// Insert before Lit's rendered content so the node stays outside the
|
|
592
|
+
// range Lit manages (and may clear) in the light-DOM render root.
|
|
593
|
+
this.insertBefore(descNode, this.firstChild);
|
|
594
|
+
addValuesToAttribute(describedElement, 'aria-describedby', descNode.id);
|
|
595
|
+
this.#descNode = descNode;
|
|
596
|
+
this.#describedElement = describedElement;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Capture the AI-filled value so the revert event can carry it.
|
|
600
|
+
this.#capturedValue = this.#annotatedValue();
|
|
601
|
+
|
|
602
|
+
if (this.working) {
|
|
603
|
+
// Apply the working state directly: on a reconnect no `working`
|
|
604
|
+
// property change triggers updated(), which handles the first connect.
|
|
605
|
+
this.#startWorking();
|
|
606
|
+
} else {
|
|
607
|
+
this.#announcePending = true;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* The field value the mark annotates. A value the AI has already set but
|
|
613
|
+
* that the working state still holds back counts as the annotated one, since
|
|
614
|
+
* it is the value the field ends up showing.
|
|
615
|
+
*
|
|
616
|
+
* @return {unknown} the annotated value, or `undefined` for a field that has
|
|
617
|
+
* no value at all
|
|
618
|
+
*/
|
|
619
|
+
#annotatedValue() {
|
|
620
|
+
const field = this.#field;
|
|
621
|
+
return this.#valueDelay ? this.#valueDelay.latestValue : field.value;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Enters the "AI is working" state: shows the shimmer and makes the field
|
|
626
|
+
* read-only on the client so the user cannot edit a value the AI is about
|
|
627
|
+
* to overwrite. Idempotent — keeps the state captured on entry.
|
|
628
|
+
*/
|
|
629
|
+
#startWorking() {
|
|
630
|
+
const field = this.#field;
|
|
631
|
+
if (!field || (this.#lockedElements && this.#restoreTimer == null)) {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
this.#valueDelay ??= new DelayedFieldValue(field, HALF_WORKING_SLIDE_MS);
|
|
636
|
+
this.#valueDelay.install();
|
|
637
|
+
|
|
638
|
+
if (this.#restoreTimer != null) {
|
|
639
|
+
// The previous working state is still winding down. Cancel its restore
|
|
640
|
+
// and keep the read-only state it captured: the elements are locked right
|
|
641
|
+
// now, so capturing again would take the lock itself as the original.
|
|
642
|
+
clearTimeout(this.#restoreTimer);
|
|
643
|
+
this.#restoreTimer = null;
|
|
644
|
+
} else {
|
|
645
|
+
// A composite field does not propagate `readonly` to its inputs, so they
|
|
646
|
+
// are locked (and restored) individually alongside the field. Recognized
|
|
647
|
+
// by the `inputs` array rather than by tag name, which also covers a
|
|
648
|
+
// composite field shipped under its own tag name.
|
|
649
|
+
const locked = [field, ...(Array.isArray(field.inputs) ? field.inputs : [])];
|
|
650
|
+
this.#lockedElements = locked.map((element) => {
|
|
651
|
+
// A composite field also accepts native inputs, which spell the
|
|
652
|
+
// property `readOnly`.
|
|
653
|
+
const property = 'readonly' in element ? 'readonly' : 'readOnly';
|
|
654
|
+
return { element, property, value: element[property] };
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
field.setAttribute('ai-working', '');
|
|
659
|
+
// Expose the working state to assistive technology on the same element
|
|
660
|
+
// that carries the AI description: the shimmer alone is only visual.
|
|
661
|
+
this.#describedElement?.setAttribute('aria-busy', 'true');
|
|
662
|
+
this.#lockedElements.forEach(({ element, property }) => {
|
|
663
|
+
element[property] = true;
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Leaves the "AI is working" state: removes the shimmer and restores the
|
|
669
|
+
* field's previous client-side read-only state. A no-op when not working.
|
|
670
|
+
*
|
|
671
|
+
* @param {boolean} immediate restore the read-only state right away instead
|
|
672
|
+
* of after the shimmer wind-down (used on disconnect)
|
|
673
|
+
*/
|
|
674
|
+
#stopWorking(immediate = false) {
|
|
675
|
+
const field = this.#field;
|
|
676
|
+
if (!field) {
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (this.#restoreTimer != null) {
|
|
681
|
+
// Already winding down. Finish it now when the marker is going away, so
|
|
682
|
+
// the restore cannot overwrite a read-only state set after this point
|
|
683
|
+
// and a value still queued from the working state cannot land after it.
|
|
684
|
+
if (immediate) {
|
|
685
|
+
this.#restoreLockedElements();
|
|
686
|
+
}
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (!this.#lockedElements) {
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
field.removeAttribute('ai-working');
|
|
695
|
+
this.#describedElement?.removeAttribute('aria-busy');
|
|
696
|
+
|
|
697
|
+
// The value hold-back stays installed for the wind-down: a queued value
|
|
698
|
+
// still lands on its own deadline, and a value the host sets before the
|
|
699
|
+
// wind-down finishes supersedes a queued one instead of being overwritten
|
|
700
|
+
// by it when its deadline passes. The restore then lifts the hold-back.
|
|
701
|
+
if (immediate) {
|
|
702
|
+
this.#restoreLockedElements();
|
|
703
|
+
} else {
|
|
704
|
+
this.#restoreTimer = setTimeout(() => this.#restoreLockedElements(), HALF_WORKING_SLIDE_MS);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Restores the read-only state captured when the working state was entered
|
|
710
|
+
* and stops holding back the field's value assignments, applying a value
|
|
711
|
+
* still queued at this point right away — rather than on its deadline, after
|
|
712
|
+
* the marker stopped controlling the field.
|
|
713
|
+
*/
|
|
714
|
+
#restoreLockedElements() {
|
|
715
|
+
clearTimeout(this.#restoreTimer);
|
|
716
|
+
this.#restoreTimer = null;
|
|
717
|
+
|
|
718
|
+
this.#valueDelay.uninstall();
|
|
719
|
+
|
|
720
|
+
const locked = this.#lockedElements;
|
|
721
|
+
this.#lockedElements = null;
|
|
722
|
+
locked.forEach(({ element, property, value }) => {
|
|
723
|
+
element[property] = value;
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** Closes the marker's popover, if rendered. */
|
|
728
|
+
#closePopover() {
|
|
729
|
+
const popover = this.querySelector(':scope > vaadin-popover');
|
|
730
|
+
if (popover) {
|
|
731
|
+
popover.opened = false;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
#onRevert() {
|
|
736
|
+
// Return focus to the field before closing the popover. The popover
|
|
737
|
+
// targets the badge for focus restoration, but the host may remove the
|
|
738
|
+
// marker on revert, which would drop focus to the body. Moving focus to
|
|
739
|
+
// the field first makes the overlay skip its own restore — it only
|
|
740
|
+
// restores while focus is still inside the overlay (see
|
|
741
|
+
// OverlayFocusMixin._shouldRestoreFocus).
|
|
742
|
+
//
|
|
743
|
+
// Focus the field's own focusable element rather than calling focus() on
|
|
744
|
+
// the host: a host focus() can carry component-specific semantics that a
|
|
745
|
+
// revert must not trigger — date-picker opens its overlay on focus while
|
|
746
|
+
// it has no usable text input (fullscreen, iOS, or no i18n.parseDate).
|
|
747
|
+
//
|
|
748
|
+
// The revert control can be activated by pointer as well as by keyboard,
|
|
749
|
+
// so the focus ring is left to the current interaction modality instead of
|
|
750
|
+
// being forced on, which is what a bare focus() does on a Vaadin field.
|
|
751
|
+
const field = this.#field;
|
|
752
|
+
if (field) {
|
|
753
|
+
const focusTarget = field.focusElement || field.inputElement || getTabbableElements(field)[0] || field;
|
|
754
|
+
focusTarget.focus({ focusVisible: isKeyboardActive() });
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
this.#closePopover();
|
|
758
|
+
|
|
759
|
+
if (field) {
|
|
760
|
+
field.dispatchEvent(
|
|
761
|
+
new CustomEvent('ai-field-revert', {
|
|
762
|
+
bubbles: true,
|
|
763
|
+
composed: true,
|
|
764
|
+
detail: { value: this.#capturedValue },
|
|
765
|
+
}),
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
defineCustomElement(AiFieldMarker);
|
|
772
|
+
|
|
773
|
+
export { AiFieldMarker };
|
|
@@ -155,7 +155,7 @@ export class FieldHighlighterController {
|
|
|
155
155
|
*
|
|
156
156
|
* @customElement vaadin-field-highlighter
|
|
157
157
|
*/
|
|
158
|
-
|
|
158
|
+
class FieldHighlighter extends HTMLElement {
|
|
159
159
|
static get is() {
|
|
160
160
|
return 'vaadin-field-highlighter';
|
|
161
161
|
}
|
|
@@ -194,3 +194,5 @@ export class FieldHighlighter extends HTMLElement {
|
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
defineCustomElement(FieldHighlighter);
|
|
197
|
+
|
|
198
|
+
export { FieldHighlighter };
|
|
@@ -17,7 +17,7 @@ import { fieldOutlineStyles } from './styles/vaadin-field-outline-base-styles.js
|
|
|
17
17
|
* @extends HTMLElement
|
|
18
18
|
* @private
|
|
19
19
|
*/
|
|
20
|
-
|
|
20
|
+
class FieldOutline extends ThemableMixin(DirMixin(PolylitMixin(LumoInjectionMixin(LitElement)))) {
|
|
21
21
|
static get is() {
|
|
22
22
|
return 'vaadin-field-outline';
|
|
23
23
|
}
|
|
@@ -72,3 +72,5 @@ export class FieldOutline extends ThemableMixin(DirMixin(PolylitMixin(LumoInject
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
defineCustomElement(FieldOutline);
|
|
75
|
+
|
|
76
|
+
export { FieldOutline };
|
package/src/vaadin-user-tag.js
CHANGED
|
@@ -18,7 +18,7 @@ import { userTagStyles } from './styles/vaadin-user-tag-base-styles.js';
|
|
|
18
18
|
* @extends HTMLElement
|
|
19
19
|
* @private
|
|
20
20
|
*/
|
|
21
|
-
|
|
21
|
+
class UserTag extends ThemableMixin(DirMixin(PolylitMixin(LumoInjectionMixin(LitElement)))) {
|
|
22
22
|
static get is() {
|
|
23
23
|
return 'vaadin-user-tag';
|
|
24
24
|
}
|
|
@@ -94,3 +94,5 @@ export class UserTag extends ThemableMixin(DirMixin(PolylitMixin(LumoInjectionMi
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
defineCustomElement(UserTag);
|
|
97
|
+
|
|
98
|
+
export { UserTag };
|
package/src/vaadin-user-tags.js
CHANGED
|
@@ -28,7 +28,7 @@ const listenOnce = (elem, type) => {
|
|
|
28
28
|
* @extends HTMLElement
|
|
29
29
|
* @private
|
|
30
30
|
*/
|
|
31
|
-
|
|
31
|
+
class UserTags extends PolylitMixin(LitElement) {
|
|
32
32
|
static get is() {
|
|
33
33
|
return 'vaadin-user-tags';
|
|
34
34
|
}
|
|
@@ -442,3 +442,5 @@ export class UserTags extends PolylitMixin(LitElement) {
|
|
|
442
442
|
}
|
|
443
443
|
|
|
444
444
|
defineCustomElement(UserTags);
|
|
445
|
+
|
|
446
|
+
export { UserTags };
|