@vaadin/component-base 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaadin/component-base",
3
- "version": "25.2.7",
3
+ "version": "25.3.0-alpha10",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -38,11 +38,11 @@
38
38
  "lit": "^3.0.0"
39
39
  },
40
40
  "devDependencies": {
41
- "@vaadin/chai-plugins": "~25.2.7",
42
- "@vaadin/test-runner-commands": "~25.2.7",
41
+ "@vaadin/chai-plugins": "25.3.0-alpha10",
42
+ "@vaadin/test-runner-commands": "25.3.0-alpha10",
43
43
  "@vaadin/testing-helpers": "^2.0.0",
44
44
  "sinon": "^22.0.0"
45
45
  },
46
46
  "customElements": "custom-elements.json",
47
- "gitHead": "14be1af674f3968cce68c6eb4742340694c4a949"
47
+ "gitHead": "f2833abdf9b613fa0d0ed216830e3f4de87b7dac"
48
48
  }
package/src/define.js CHANGED
@@ -13,7 +13,7 @@ function dashToCamelCase(dash) {
13
13
 
14
14
  const experimentalMap = {};
15
15
 
16
- export function defineCustomElement(CustomElement, version = '25.2.7') {
16
+ export function defineCustomElement(CustomElement, version = '25.3.0-alpha10') {
17
17
  Object.defineProperty(CustomElement, 'version', {
18
18
  get() {
19
19
  return version;
@@ -4,6 +4,7 @@
4
4
  * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
5
  */
6
6
  import { dedupeMixin } from '@open-wc/dedupe-mixin';
7
+ import { setOrRemoveAttribute } from './dom-utils.js';
7
8
 
8
9
  /**
9
10
  * A mixin to delegate properties and attributes to a target element.
@@ -103,10 +104,8 @@ const DelegateStateMixinImplementation = (superclass) => {
103
104
 
104
105
  if (typeof value === 'boolean') {
105
106
  this.stateTarget.toggleAttribute(name, value);
106
- } else if (value) {
107
- this.stateTarget.setAttribute(name, value);
108
107
  } else {
109
- this.stateTarget.removeAttribute(name);
108
+ setOrRemoveAttribute(this.stateTarget, name, value);
110
109
  }
111
110
  }
112
111
 
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @license
3
+ * Copyright (c) 2026 - 2026 Vaadin Ltd.
4
+ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
+ */
6
+ import type { DirectiveResult } from 'lit/directive.js';
7
+
8
+ /**
9
+ * A key-value set of part names to truthy values.
10
+ */
11
+ export interface PartNameInfo {
12
+ readonly [name: string]: string | boolean | number | null | undefined;
13
+ }
14
+
15
+ /**
16
+ * A directive that applies dynamic shadow DOM part names.
17
+ *
18
+ * This must be used in the `part` attribute and must be the only binding in it.
19
+ * Each property name in `partNameInfo` is added to the element's `part` list
20
+ * if the property value is truthy, and removed if the value is falsy.
21
+ */
22
+ export declare function partMap(partNameInfo: PartNameInfo): DirectiveResult;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @license
3
+ * Copyright (c) 2026 - 2026 Vaadin Ltd.
4
+ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
+ */
6
+ import { noChange } from 'lit';
7
+ import { Directive, directive, PartType } from 'lit/directive.js';
8
+
9
+ class PartMapDirective extends Directive {
10
+ // Part names applied by the directive on the previous render,
11
+ // used to remove names that no longer apply.
12
+ #previousParts;
13
+
14
+ // Part names declared statically in the attribute, never removed.
15
+ #staticParts;
16
+
17
+ constructor(partInfo) {
18
+ super(partInfo);
19
+ if (partInfo.type !== PartType.ATTRIBUTE || partInfo.name !== 'part' || partInfo.strings?.length > 2) {
20
+ throw new Error('`partMap()` can only be used in the `part` attribute and must be the only binding in it.');
21
+ }
22
+ }
23
+
24
+ render(partNameInfo) {
25
+ // Add spaces to ensure separation from static parts
26
+ return ` ${Object.keys(partNameInfo)
27
+ .filter((key) => partNameInfo[key])
28
+ .join(' ')} `;
29
+ }
30
+
31
+ update(part, [partNameInfo]) {
32
+ // Remember dynamic parts on the first render
33
+ if (this.#previousParts === undefined) {
34
+ this.#previousParts = new Set();
35
+ if (part.strings !== undefined) {
36
+ this.#staticParts = new Set(
37
+ part.strings
38
+ .join(' ')
39
+ .split(/\s/u)
40
+ .filter((s) => s !== ''),
41
+ );
42
+ }
43
+ Object.keys(partNameInfo).forEach((name) => {
44
+ if (partNameInfo[name] && !this.#staticParts?.has(name)) {
45
+ this.#previousParts.add(name);
46
+ }
47
+ });
48
+ return this.render(partNameInfo);
49
+ }
50
+
51
+ const partList = part.element.part;
52
+
53
+ // Remove old parts that no longer apply
54
+ this.#previousParts.forEach((name) => {
55
+ if (!(name in partNameInfo)) {
56
+ partList.remove(name);
57
+ this.#previousParts.delete(name);
58
+ }
59
+ });
60
+
61
+ // Add or remove parts based on their partMap value
62
+ Object.keys(partNameInfo).forEach((name) => {
63
+ const value = !!partNameInfo[name];
64
+ if (value !== this.#previousParts.has(name) && !this.#staticParts?.has(name)) {
65
+ if (value) {
66
+ partList.add(name);
67
+ this.#previousParts.add(name);
68
+ } else {
69
+ partList.remove(name);
70
+ this.#previousParts.delete(name);
71
+ }
72
+ }
73
+ });
74
+
75
+ return noChange;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * A directive that applies dynamic shadow DOM part names.
81
+ *
82
+ * This must be used in the `part` attribute and must be the only binding in it.
83
+ * Each property name in `partNameInfo` is added to the element's `part` list
84
+ * if the property value is truthy, and removed if the value is falsy.
85
+ */
86
+ export const partMap = directive(PartMapDirective);
@@ -37,15 +37,26 @@ export function deserializeAttributeValue(value: string): Set<string>;
37
37
  export function serializeAttributeValue(values: Set<string>): string;
38
38
 
39
39
  /**
40
- * Adds a value to an attribute containing space-delimited values.
40
+ * Sets the attribute to the given value, or removes the attribute when the
41
+ * value is falsy (e.g. `null`, `undefined`, `false` or an empty string).
41
42
  */
42
- export function addValueToAttribute(element: HTMLElement, attr: string, value: string): void;
43
+ export function setOrRemoveAttribute(
44
+ element: HTMLElement,
45
+ attr: string,
46
+ value: string | boolean | null | undefined,
47
+ ): void;
43
48
 
44
49
  /**
45
- * Removes a value from an attribute containing space-delimited values.
46
- * If the value is the last one, the whole attribute is removed.
50
+ * Adds one or more values to an attribute containing space-delimited values.
51
+ * If no values remain, the whole attribute is removed.
47
52
  */
48
- export function removeValueFromAttribute(element: HTMLElement, attr: string, value: string): void;
53
+ export function addValuesToAttribute(element: HTMLElement, attr: string, valuesToAdd: string | string[]): void;
54
+
55
+ /**
56
+ * Removes one or more values from an attribute containing space-delimited values.
57
+ * If no values remain, the whole attribute is removed.
58
+ */
59
+ export function removeValuesFromAttribute(element: HTMLElement, attr: string, valuesToRemove: string | string[]): void;
49
60
 
50
61
  /**
51
62
  * Returns true if the given node is an empty text node, false otherwise.
package/src/dom-utils.js CHANGED
@@ -84,11 +84,7 @@ export function getClosestElement(selector, node) {
84
84
  * @return {Set<string>}
85
85
  */
86
86
  export function deserializeAttributeValue(value) {
87
- if (!value) {
88
- return new Set();
89
- }
90
-
91
- return new Set(value.split(' '));
87
+ return new Set(value ? value.split(' ').filter(Boolean) : []);
92
88
  }
93
89
 
94
90
  /**
@@ -102,34 +98,65 @@ export function serializeAttributeValue(values) {
102
98
  }
103
99
 
104
100
  /**
105
- * Adds a value to an attribute containing space-delimited values.
101
+ * Sets the attribute to the given value, or removes the attribute when the
102
+ * value is falsy (e.g. `null`, `undefined`, `false` or an empty string).
106
103
  *
107
104
  * @param {HTMLElement} element
108
105
  * @param {string} attr
109
- * @param {string} value
106
+ * @param {string | boolean | null | undefined} value
107
+ */
108
+ export function setOrRemoveAttribute(element, attr, value) {
109
+ if (value) {
110
+ element.setAttribute(attr, value);
111
+ } else {
112
+ element.removeAttribute(attr);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Normalizes values passed to `addValuesToAttribute` and `removeValuesFromAttribute`
118
+ * into a set of values. Both a single string and every array entry may contain
119
+ * multiple values separated by space.
120
+ *
121
+ * @param {string | string[] | null | undefined} values
122
+ * @return {Set<string>}
123
+ */
124
+ function normalizeAttributeValues(values) {
125
+ return deserializeAttributeValue(Array.isArray(values) ? values.join(' ') : values);
126
+ }
127
+
128
+ /**
129
+ * Adds one or more values to an attribute containing space-delimited values.
130
+ * If no values remain, the whole attribute is removed.
131
+ *
132
+ * @param {HTMLElement} element
133
+ * @param {string} attr
134
+ * @param {string | string[]} valuesToAdd a string or an array of strings with values separated by space
110
135
  */
111
- export function addValueToAttribute(element, attr, value) {
136
+ export function addValuesToAttribute(element, attr, valuesToAdd) {
137
+ valuesToAdd = normalizeAttributeValues(valuesToAdd);
138
+
112
139
  const values = deserializeAttributeValue(element.getAttribute(attr));
113
- values.add(value);
114
- element.setAttribute(attr, serializeAttributeValue(values));
140
+ valuesToAdd.forEach((value) => values.add(value));
141
+
142
+ setOrRemoveAttribute(element, attr, serializeAttributeValue(values));
115
143
  }
116
144
 
117
145
  /**
118
- * Removes a value from an attribute containing space-delimited values.
119
- * If the value is the last one, the whole attribute is removed.
146
+ * Removes one or more values from an attribute containing space-delimited values.
147
+ * If no values remain, the whole attribute is removed.
120
148
  *
121
149
  * @param {HTMLElement} element
122
150
  * @param {string} attr
123
- * @param {string} value
151
+ * @param {string | string[]} valuesToRemove a string or an array of strings with values separated by space
124
152
  */
125
- export function removeValueFromAttribute(element, attr, value) {
153
+ export function removeValuesFromAttribute(element, attr, valuesToRemove) {
154
+ valuesToRemove = normalizeAttributeValues(valuesToRemove);
155
+
126
156
  const values = deserializeAttributeValue(element.getAttribute(attr));
127
- values.delete(value);
128
- if (values.size === 0) {
129
- element.removeAttribute(attr);
130
- return;
131
- }
132
- element.setAttribute(attr, serializeAttributeValue(values));
157
+ valuesToRemove.forEach((value) => values.delete(value));
158
+
159
+ setOrRemoveAttribute(element, attr, serializeAttributeValue(values));
133
160
  }
134
161
 
135
162
  /**
@@ -8,6 +8,9 @@
8
8
  * A controller for listening on media query changes.
9
9
  */
10
10
  export class MediaQueryController {
11
+ /** @type {MediaQueryList | null} */
12
+ #mediaQuery = null;
13
+
11
14
  constructor(query, callback) {
12
15
  /**
13
16
  * The CSS media query to evaluate.
@@ -24,44 +27,39 @@ export class MediaQueryController {
24
27
  * @protected
25
28
  */
26
29
  this.callback = callback;
27
-
28
- this._boundQueryHandler = this._queryHandler.bind(this);
29
30
  }
30
31
 
31
32
  hostConnected() {
32
- this._removeListener();
33
+ this.#removeListener();
33
34
 
34
- this._mediaQuery = window.matchMedia(this.query);
35
+ this.#mediaQuery = window.matchMedia(this.query);
35
36
 
36
- this._addListener();
37
+ this.#addListener();
37
38
 
38
- this._queryHandler(this._mediaQuery);
39
+ this.#queryHandler(this.#mediaQuery);
39
40
  }
40
41
 
41
42
  hostDisconnected() {
42
- this._removeListener();
43
+ this.#removeListener();
43
44
  }
44
45
 
45
- /** @private */
46
- _addListener() {
47
- if (this._mediaQuery) {
48
- this._mediaQuery.addListener(this._boundQueryHandler);
46
+ #addListener() {
47
+ if (this.#mediaQuery) {
48
+ this.#mediaQuery.addListener(this.#queryHandler);
49
49
  }
50
50
  }
51
51
 
52
- /** @private */
53
- _removeListener() {
54
- if (this._mediaQuery) {
55
- this._mediaQuery.removeListener(this._boundQueryHandler);
52
+ #removeListener() {
53
+ if (this.#mediaQuery) {
54
+ this.#mediaQuery.removeListener(this.#queryHandler);
56
55
  }
57
56
 
58
- this._mediaQuery = null;
57
+ this.#mediaQuery = null;
59
58
  }
60
59
 
61
- /** @private */
62
- _queryHandler(mediaQuery) {
60
+ #queryHandler = (mediaQuery) => {
63
61
  if (typeof this.callback === 'function') {
64
62
  this.callback(mediaQuery.matches);
65
63
  }
66
- }
64
+ };
67
65
  }
@@ -3,6 +3,7 @@
3
3
  * Copyright (c) 2021 - 2026 Vaadin Ltd.
4
4
  * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
5
  */
6
+ import { setOrRemoveAttribute } from './dom-utils.js';
6
7
 
7
8
  /**
8
9
  * A controller that detects if content inside the element overflows its scrolling viewport,
@@ -10,6 +11,15 @@
10
11
  * where content is overflowing. Supported values are: `top`, `bottom`, `start`, `end`.
11
12
  */
12
13
  export class OverflowController {
14
+ /** @type {ResizeObserver} */
15
+ #resizeObserver;
16
+
17
+ /** @type {MutationObserver} */
18
+ #childObserver;
19
+
20
+ /** @type {number} */
21
+ #resizeRaf;
22
+
13
23
  constructor(host, scrollTarget) {
14
24
  /**
15
25
  * The controller host element.
@@ -25,9 +35,6 @@ export class OverflowController {
25
35
  * @type {HTMLElement}
26
36
  */
27
37
  this.scrollTarget = scrollTarget || host;
28
-
29
- /** @private */
30
- this.__boundOnScroll = this.__onScroll.bind(this);
31
38
  }
32
39
 
33
40
  hostConnected() {
@@ -46,64 +53,60 @@ export class OverflowController {
46
53
  observe() {
47
54
  const { host } = this;
48
55
 
49
- this.__resizeObserver = new ResizeObserver(() => this.__onResize());
50
- this.__resizeObserver.observe(host);
56
+ this.#resizeObserver = new ResizeObserver(() => this.#onResize());
57
+ this.#resizeObserver.observe(host);
51
58
 
52
59
  // Observe initial children
53
60
  [...host.children].forEach((child) => {
54
- this.__resizeObserver.observe(child);
61
+ this.#resizeObserver.observe(child);
55
62
  });
56
63
 
57
- this.__childObserver = new MutationObserver((mutations) => {
64
+ this.#childObserver = new MutationObserver((mutations) => {
58
65
  mutations.forEach(({ addedNodes, removedNodes }) => {
59
66
  addedNodes.forEach((node) => {
60
67
  if (node.nodeType === Node.ELEMENT_NODE) {
61
- this.__resizeObserver.observe(node);
68
+ this.#resizeObserver.observe(node);
62
69
  }
63
70
  });
64
71
 
65
72
  removedNodes.forEach((node) => {
66
73
  if (node.nodeType === Node.ELEMENT_NODE) {
67
- this.__resizeObserver.unobserve(node);
74
+ this.#resizeObserver.unobserve(node);
68
75
  }
69
76
  });
70
77
 
71
78
  if (addedNodes.length === 0 && removedNodes.length > 0) {
72
- this.__updateState({ sync: true });
79
+ this.#updateState({ sync: true });
73
80
  }
74
81
  });
75
82
  });
76
83
 
77
- this.__childObserver.observe(host, { childList: true });
84
+ this.#childObserver.observe(host, { childList: true });
78
85
 
79
86
  // Update overflow attribute on scroll
80
- this.scrollTarget.addEventListener('scroll', this.__boundOnScroll);
87
+ this.scrollTarget.addEventListener('scroll', this.#onScroll);
81
88
  }
82
89
 
83
- /** @private */
84
- __onResize() {
85
- this.__updateState({ sync: false });
90
+ #onResize() {
91
+ this.#updateState({ sync: false });
86
92
  }
87
93
 
88
- /** @private */
89
- __onScroll() {
90
- this.__updateState({ sync: true });
91
- }
94
+ #onScroll = () => {
95
+ this.#updateState({ sync: true });
96
+ };
92
97
 
93
- /** @private */
94
- __updateState({ sync }) {
95
- cancelAnimationFrame(this.__resizeRaf);
98
+ #updateState({ sync }) {
99
+ cancelAnimationFrame(this.#resizeRaf);
96
100
 
97
- const state = this.__readState();
101
+ const state = this.#readState();
98
102
  if (sync) {
99
- this.__writeState(state);
103
+ this.#writeState(state);
100
104
  } else {
101
- this.__resizeRaf = requestAnimationFrame(() => this.__writeState(state));
105
+ this.#resizeRaf = requestAnimationFrame(() => this.#writeState(state));
102
106
  }
103
107
  }
104
108
 
105
- /** @private */
106
- __readState() {
109
+ #readState() {
107
110
  const target = this.scrollTarget;
108
111
 
109
112
  let overflow = '';
@@ -128,12 +131,7 @@ export class OverflowController {
128
131
  return { overflow: overflow.trim() };
129
132
  }
130
133
 
131
- /** @private */
132
- __writeState({ overflow }) {
133
- if (overflow) {
134
- this.host.setAttribute('overflow', overflow);
135
- } else {
136
- this.host.removeAttribute('overflow');
137
- }
134
+ #writeState({ overflow }) {
135
+ setOrRemoveAttribute(this.host, 'overflow', overflow);
138
136
  }
139
137
  }
@@ -25,4 +25,9 @@ export class SlotChildObserveController extends SlotController {
25
25
  * Override to update default node text on property change.
26
26
  */
27
27
  protected updateDefaultNode(node: Node): void;
28
+
29
+ /**
30
+ * Fire an event to notify the controller host about node changes.
31
+ */
32
+ protected _notifyChange(node: Node): void;
28
33
  }
@@ -10,6 +10,9 @@ import { SlotController } from './slot-controller.js';
10
10
  * and the text content, and fires an event to notify host element about those.
11
11
  */
12
12
  export class SlotChildObserveController extends SlotController {
13
+ /** @type {MutationObserver} */
14
+ #nodeObserver;
15
+
13
16
  constructor(host, slot, tagName, config = {}) {
14
17
  super(host, slot, tagName, { ...config, useUniqueId: true });
15
18
  }
@@ -22,8 +25,8 @@ export class SlotChildObserveController extends SlotController {
22
25
  * @override
23
26
  */
24
27
  initCustomNode(node) {
25
- this.__updateNodeId(node);
26
- this.__notifyChange(node);
28
+ this.#updateNodeId(node);
29
+ this._notifyChange(node);
27
30
  }
28
31
 
29
32
  /**
@@ -39,7 +42,7 @@ export class SlotChildObserveController extends SlotController {
39
42
 
40
43
  // Custom node is added to the slot
41
44
  if (node && node !== this.defaultNode) {
42
- this.__notifyChange(node);
45
+ this._notifyChange(node);
43
46
  } else {
44
47
  this.restoreDefaultNode();
45
48
  this.updateDefaultNode(this.node);
@@ -58,7 +61,7 @@ export class SlotChildObserveController extends SlotController {
58
61
  const node = super.attachDefaultNode();
59
62
 
60
63
  if (node) {
61
- this.__updateNodeId(node);
64
+ this.#updateNodeId(node);
62
65
  }
63
66
 
64
67
  return node;
@@ -80,7 +83,7 @@ export class SlotChildObserveController extends SlotController {
80
83
  * @protected
81
84
  */
82
85
  updateDefaultNode(node) {
83
- this.__notifyChange(node);
86
+ this._notifyChange(node);
84
87
  }
85
88
 
86
89
  /**
@@ -92,11 +95,11 @@ export class SlotChildObserveController extends SlotController {
92
95
  */
93
96
  observeNode(node) {
94
97
  // Stop observing the previous node, if any.
95
- if (this.__nodeObserver) {
96
- this.__nodeObserver.disconnect();
98
+ if (this.#nodeObserver) {
99
+ this.#nodeObserver.disconnect();
97
100
  }
98
101
 
99
- this.__nodeObserver = new MutationObserver((mutations) => {
102
+ this.#nodeObserver = new MutationObserver((mutations) => {
100
103
  mutations.forEach((mutation) => {
101
104
  const target = mutation.target;
102
105
 
@@ -108,17 +111,17 @@ export class SlotChildObserveController extends SlotController {
108
111
  // We use attributeFilter to only observe ID mutation,
109
112
  // no need to check for attribute name separately.
110
113
  if (isCurrentNodeMutation) {
111
- this.__updateNodeId(target);
114
+ this.#updateNodeId(target);
112
115
  }
113
116
  } else if (isCurrentNodeMutation || target.parentElement === this.node) {
114
117
  // Node text content has changed.
115
- this.__notifyChange(this.node);
118
+ this._notifyChange(this.node);
116
119
  }
117
120
  });
118
121
  });
119
122
 
120
123
  // Observe changes to node ID attribute, text content and children.
121
- this.__nodeObserver.observe(node, {
124
+ this.#nodeObserver.observe(node, {
122
125
  attributes: true,
123
126
  attributeFilter: ['id'],
124
127
  childList: true,
@@ -133,9 +136,8 @@ export class SlotChildObserveController extends SlotController {
133
136
  *
134
137
  * @param {Node} node
135
138
  * @return {boolean}
136
- * @private
137
139
  */
138
- __hasContent(node) {
140
+ #hasContent(node) {
139
141
  if (!node) {
140
142
  return false;
141
143
  }
@@ -150,12 +152,12 @@ export class SlotChildObserveController extends SlotController {
150
152
  * Fire an event to notify the controller host about node changes.
151
153
  *
152
154
  * @param {Node} node
153
- * @private
155
+ * @protected
154
156
  */
155
- __notifyChange(node) {
157
+ _notifyChange(node) {
156
158
  this.dispatchEvent(
157
159
  new CustomEvent('slot-content-changed', {
158
- detail: { hasContent: this.__hasContent(node), node },
160
+ detail: { hasContent: this.#hasContent(node), node },
159
161
  }),
160
162
  );
161
163
  }
@@ -164,9 +166,8 @@ export class SlotChildObserveController extends SlotController {
164
166
  * Set default ID on the node in case it is an HTML element.
165
167
  *
166
168
  * @param {Node} node
167
- * @private
168
169
  */
169
- __updateNodeId(node) {
170
+ #updateNodeId(node) {
170
171
  // When in multiple mode, only set ID attribute on the element in default slot.
171
172
  const isFirstNode = !this.nodes || node === this.nodes[0];
172
173
  if (node.nodeType === Node.ELEMENT_NODE && (!this.multiple || isFirstNode) && !node.id) {
@@ -202,7 +202,8 @@ export class SlotController extends EventTarget {
202
202
  const selector = slotName === '' ? 'slot:not([name])' : `slot[name=${slotName}]`;
203
203
  const slot = this.host.shadowRoot.querySelector(selector);
204
204
 
205
- this.__slotObserver = new SlotObserver(slot, ({ addedNodes, removedNodes }) => {
205
+ // eslint-disable-next-line no-new
206
+ new SlotObserver(slot, ({ addedNodes, removedNodes }) => {
206
207
  const current = this.multiple ? this.nodes : [this.node];
207
208
 
208
209
  // Calling `slot.assignedNodes()` includes whitespace text nodes in case of default slot:
@@ -14,12 +14,18 @@
14
14
  * bubbling to it and diffs the **union** of `assignedNodes({ flatten: true })`
15
15
  * every descendant `<slot>`. Cross-slot reassignment of the same node does
16
16
  * not change the union and therefore fires no callback.
17
+ *
18
+ * The initial pass runs in a microtask by default. Use the `syncInitial` option
19
+ * when the callback sets state that affects the layout of the component, so that
20
+ * it has its final size once connected. Otherwise consumers that measure it
21
+ * synchronously, such as auto-width columns in `<vaadin-grid>`, would measure the
22
+ * component before that state is applied.
17
23
  */
18
24
  export class SlotObserver {
19
25
  constructor(
20
26
  target: HTMLSlotElement | DocumentFragment,
21
27
  callback: (info: { addedNodes: Node[]; currentNodes: Node[]; movedNodes: Node[]; removedNodes: Node[] }) => void,
22
- forceInitial?: boolean,
28
+ options?: { forceInitial?: boolean; syncInitial?: boolean },
23
29
  );
24
30
 
25
31
  readonly target: HTMLSlotElement | DocumentFragment;
@@ -14,9 +14,20 @@
14
14
  * bubbling to it and diffs the **union** of `assignedNodes({ flatten: true })`
15
15
  * across every descendant `<slot>`. Cross-slot reassignment of the same node
16
16
  * does not change the union and therefore fires no callback.
17
+ *
18
+ * The initial pass runs in a microtask by default. Use the `syncInitial` option
19
+ * when the callback sets state that affects the layout of the component, so that
20
+ * it has its final size once connected. Otherwise consumers that measure it
21
+ * synchronously, such as auto-width columns in `<vaadin-grid>`, would measure the
22
+ * component before that state is applied.
17
23
  */
18
24
  export class SlotObserver {
19
- constructor(target, callback, forceInitial) {
25
+ /**
26
+ * @param {HTMLSlotElement | DocumentFragment} target
27
+ * @param {Function} callback
28
+ * @param {{ forceInitial?: boolean, syncInitial?: boolean }} options
29
+ */
30
+ constructor(target, callback, options = {}) {
20
31
  /** @type {HTMLSlotElement | DocumentFragment} */
21
32
  this.target = target;
22
33
 
@@ -24,7 +35,7 @@ export class SlotObserver {
24
35
  this.callback = callback;
25
36
 
26
37
  /** @type {boolean} */
27
- this.forceInitial = forceInitial;
38
+ this.forceInitial = options.forceInitial;
28
39
 
29
40
  /** @type {Node[]} */
30
41
  this._storedNodes = [];
@@ -40,7 +51,12 @@ export class SlotObserver {
40
51
  };
41
52
 
42
53
  this.connect();
43
- this._schedule();
54
+
55
+ if (options.syncInitial) {
56
+ this.flush();
57
+ } else {
58
+ this._schedule();
59
+ }
44
60
  }
45
61
 
46
62
  /**
@@ -69,7 +85,11 @@ export class SlotObserver {
69
85
  this._scheduled = true;
70
86
 
71
87
  queueMicrotask(() => {
72
- this.flush();
88
+ // Skip if the nodes have already been processed by an explicit `flush()`
89
+ // in the meantime, to avoid running the diff a second time for nothing.
90
+ if (this._scheduled) {
91
+ this.flush();
92
+ }
73
93
  });
74
94
  }
75
95
  }
@@ -83,10 +83,12 @@ addGlobalStyles(
83
83
  --_vaadin-icon-arrow-up: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>');
84
84
  --_vaadin-icon-calendar: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/></svg>');
85
85
  --_vaadin-icon-checkmark: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /></svg>');
86
+ --_vaadin-icon-checkmark-small: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="3.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /></svg>');
86
87
  --_vaadin-icon-chevron-down: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>');
87
88
  --_vaadin-icon-chevron-right: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>');
88
89
  --_vaadin-icon-clock: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 6v6l4 2"/><circle cx="12" cy="12" r="10"/></svg>');
89
90
  --_vaadin-icon-cross: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>');
91
+ --_vaadin-icon-cross-small: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="3.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>');
90
92
  --_vaadin-icon-drag: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M11 7c0 .82843-.6716 1.5-1.5 1.5C8.67157 8.5 8 7.82843 8 7s.67157-1.5 1.5-1.5c.8284 0 1.5.67157 1.5 1.5Zm0 5c0 .8284-.6716 1.5-1.5 1.5-.82843 0-1.5-.6716-1.5-1.5s.67157-1.5 1.5-1.5c.8284 0 1.5.6716 1.5 1.5Zm0 5c0 .8284-.6716 1.5-1.5 1.5-.82843 0-1.5-.6716-1.5-1.5s.67157-1.5 1.5-1.5c.8284 0 1.5.6716 1.5 1.5Zm5-10c0 .82843-.6716 1.5-1.5 1.5S13 7.82843 13 7s.6716-1.5 1.5-1.5S16 6.17157 16 7Zm0 5c0 .8284-.6716 1.5-1.5 1.5S13 12.8284 13 12s.6716-1.5 1.5-1.5 1.5.6716 1.5 1.5Zm0 5c0 .8284-.6716 1.5-1.5 1.5S13 17.8284 13 17s.6716-1.5 1.5-1.5 1.5.6716 1.5 1.5Z" fill="currentColor"/></svg>');
91
93
  --_vaadin-icon-ellipsis: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>');
92
94
  --_vaadin-icon-eye: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>');
@@ -14,7 +14,6 @@ export class TooltipController extends SlotController {
14
14
  super(host, 'tooltip');
15
15
 
16
16
  this.setTarget(host);
17
- this.__onContentChange = this.__onContentChange.bind(this);
18
17
  }
19
18
 
20
19
  /**
@@ -50,8 +49,8 @@ export class TooltipController extends SlotController {
50
49
  if (!this.manual) {
51
50
  this.host.setAttribute('has-tooltip', '');
52
51
  }
53
- this.__notifyChange(tooltipNode);
54
- tooltipNode.addEventListener('content-changed', this.__onContentChange);
52
+ this.#notifyChange(tooltipNode);
53
+ tooltipNode.addEventListener('content-changed', this.#onContentChange);
55
54
  }
56
55
 
57
56
  /**
@@ -65,8 +64,8 @@ export class TooltipController extends SlotController {
65
64
  if (!this.manual) {
66
65
  this.host.removeAttribute('has-tooltip');
67
66
  }
68
- tooltipNode.removeEventListener('content-changed', this.__onContentChange);
69
- this.__notifyChange(null);
67
+ tooltipNode.removeEventListener('content-changed', this.#onContentChange);
68
+ this.#notifyChange(null);
70
69
  }
71
70
 
72
71
  /**
@@ -179,13 +178,11 @@ export class TooltipController extends SlotController {
179
178
  }
180
179
  }
181
180
 
182
- /** @private */
183
- __onContentChange(event) {
184
- this.__notifyChange(event.target);
185
- }
181
+ #onContentChange = (event) => {
182
+ this.#notifyChange(event.target);
183
+ };
186
184
 
187
- /** @private */
188
- __notifyChange(node) {
185
+ #notifyChange(node) {
189
186
  this.dispatchEvent(new CustomEvent('tooltip-changed', { detail: { node } }));
190
187
  }
191
188
  }
@@ -78,12 +78,7 @@ export class IronListAdapter {
78
78
  });
79
79
  attachObserver.observe(this.scrollTarget);
80
80
 
81
- this.scrollTarget.addEventListener('virtualizer-element-focused', (e) => this.__onElementFocused(e));
82
- this.elementsContainer.addEventListener('focusin', () => {
83
- this.scrollTarget.dispatchEvent(
84
- new CustomEvent('virtualizer-element-focused', { detail: { element: this.__getFocusedElement() } }),
85
- );
86
- });
81
+ this.elementsContainer.addEventListener('focusin', () => this.__onElementFocused());
87
82
 
88
83
  if (this.reorderElements) {
89
84
  // Reordering the physical elements cancels the user's grab of the scroll bar handle on Safari.
@@ -161,7 +156,7 @@ export class IronListAdapter {
161
156
  this.__skipNextVirtualIndexAdjust = true;
162
157
  super.scrollToIndex(targetVirtualIndex);
163
158
 
164
- if (this.adjustedFirstVisibleIndex !== index && this._scrollTop < this._maxScrollTop && !this.grid) {
159
+ if (this.adjustedFirstVisibleIndex !== index && this._scrollTop < this._maxScrollTop) {
165
160
  // Workaround an iron-list issue by manually adjusting the scroll position
166
161
  this._scrollTop -= this.__getIndexScrollOffset(index) || 0;
167
162
  }
@@ -189,9 +184,6 @@ export class IronListAdapter {
189
184
  if (this.__scrollReorderDebouncer) {
190
185
  this.__scrollReorderDebouncer.flush();
191
186
  }
192
- if (this.__debouncerWheelAnimationFrame) {
193
- this.__debouncerWheelAnimationFrame.flush();
194
- }
195
187
  }
196
188
 
197
189
  hostConnected() {
@@ -462,16 +454,10 @@ export class IronListAdapter {
462
454
 
463
455
  /** @private */
464
456
  updateViewportBoundaries() {
465
- const styles = window.getComputedStyle(this.scrollTarget);
466
- this._scrollerPaddingTop = this.scrollTarget === this ? 0 : parseInt(styles['padding-top'], 10);
467
- this._isRTL = Boolean(styles.direction === 'rtl');
468
- this._viewportWidth = this.elementsContainer.offsetWidth;
457
+ this._scrollerPaddingTop = parseInt(window.getComputedStyle(this.scrollTarget)['padding-top'], 10);
469
458
  this._viewportHeight = this.scrollTarget.offsetHeight;
470
459
  }
471
460
 
472
- /** @private */
473
- setAttribute() {}
474
-
475
461
  /** @private */
476
462
  _createPool(size) {
477
463
  const physicalItems = this.createElements(size);
@@ -521,20 +507,8 @@ export class IronListAdapter {
521
507
  toggleScrollListener() {}
522
508
 
523
509
  /** @private */
524
- __getFocusedElement(visibleElements = this.__getVisibleElements()) {
525
- // `document.activeElement` retargets to the outermost shadow host when
526
- // focus lives in a nested shadow tree. Descend through nested shadow
527
- // roots' `activeElement`s to reach the real focused node, then walk up
528
- // the flattened tree (via `assignedSlot`/`parentNode`/`host`) until a
529
- // visible row is reached.
530
- let node = document.activeElement;
531
- while (node?.shadowRoot?.activeElement) {
532
- node = node.shadowRoot.activeElement;
533
- }
534
- while (node && !visibleElements.includes(node)) {
535
- node = node.assignedSlot || node.parentNode || node.host;
536
- }
537
- return node;
510
+ __getFocusedElement() {
511
+ return this.__getVisibleElements().find((element) => element.matches(':focus-within'));
538
512
  }
539
513
 
540
514
  /** @private */
@@ -558,12 +532,12 @@ export class IronListAdapter {
558
532
  }
559
533
 
560
534
  /** @private */
561
- __onElementFocused(e) {
535
+ __onElementFocused() {
562
536
  if (!this.reorderElements) {
563
537
  return;
564
538
  }
565
539
 
566
- const focusedElement = e.detail.element;
540
+ const focusedElement = this.__getFocusedElement();
567
541
  if (!focusedElement) {
568
542
  return;
569
543
  }
@@ -805,7 +779,7 @@ export class IronListAdapter {
805
779
 
806
780
  // Which row to use as a target?
807
781
  const visibleElements = this.__getVisibleElements();
808
- const targetElement = this.__getFocusedElement(visibleElements) || visibleElements[0];
782
+ const targetElement = this.__getFocusedElement() || visibleElements[0];
809
783
  if (!targetElement) {
810
784
  // All elements are hidden, don't reorder
811
785
  return;