diff --git a/packages/app-layout/src/vaadin-app-layout-mixin.js b/packages/app-layout/src/vaadin-app-layout-mixin.js
index f0b4b8767f..44aed0bf15 100644
--- a/packages/app-layout/src/vaadin-app-layout-mixin.js
+++ b/packages/app-layout/src/vaadin-app-layout-mixin.js
@@ -7,9 +7,24 @@ import { AriaModalController } from '@vaadin/a11y-base/src/aria-modal-controller
import { FocusTrapController } from '@vaadin/a11y-base/src/focus-trap-controller.js';
import { isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js';
import { animationFrame } from '@vaadin/component-base/src/async.js';
+import { CSSPropertyObserver } from '@vaadin/component-base/src/css-property-observer.js';
import { Debouncer } from '@vaadin/component-base/src/debounce.js';
import { I18nMixin } from '@vaadin/component-base/src/i18n-mixin.js';
+CSS.registerProperty({
+ name: '--vaadin-app-layout-touch-optimized',
+ syntax: 'true | false',
+ inherits: true,
+ initialValue: 'false',
+});
+
+CSS.registerProperty({
+ name: '--vaadin-app-layout-drawer-overlay',
+ syntax: 'true | false',
+ inherits: true,
+ initialValue: 'false',
+});
+
/**
* @typedef {import('./vaadin-app-layout.js').AppLayoutI18n} AppLayoutI18n
*/
@@ -180,6 +195,16 @@ export const AppLayoutMixin = (superclass) =>
this.addController(this.__focusTrapController);
this.__setAriaExpanded();
+ this.__cssPropertyObserver = new CSSPropertyObserver(this.$.cssPropertyObserver, (propertyName) => {
+ if (propertyName === '--vaadin-app-layout-touch-optimized') {
+ this._updateTouchOptimizedMode();
+ }
+ if (propertyName === '--vaadin-app-layout-drawer-overlay') {
+ this._updateOverlayMode();
+ }
+ });
+ this.__cssPropertyObserver.observe('--vaadin-app-layout-touch-optimized', '--vaadin-app-layout-drawer-overlay');
+
this.$.drawer.addEventListener('transitionstart', () => {
this.__isDrawerAnimating = true;
});
@@ -328,8 +353,6 @@ export const AppLayoutMixin = (superclass) =>
/** @private */
_resize() {
this._blockAnimationUntilAfterNextRender();
- this._updateTouchOptimizedMode();
- this._updateOverlayMode();
}
/** @protected */
diff --git a/packages/app-layout/src/vaadin-app-layout.js b/packages/app-layout/src/vaadin-app-layout.js
index 532860c2d5..afc89f77da 100644
--- a/packages/app-layout/src/vaadin-app-layout.js
+++ b/packages/app-layout/src/vaadin-app-layout.js
@@ -144,6 +144,7 @@ class AppLayout extends AppLayoutMixin(ElementMixin(ThemableMixin(PolylitMixin(L
+
`;
}
}
diff --git a/packages/app-layout/test/app-layout.test.js b/packages/app-layout/test/app-layout.test.js
index 838ffda98e..0de3f05580 100644
--- a/packages/app-layout/test/app-layout.test.js
+++ b/packages/app-layout/test/app-layout.test.js
@@ -131,16 +131,6 @@ describe('vaadin-app-layout', () => {
expect(layout.$.navbarBottom.hasAttribute('hidden')).to.be.true;
});
- it('should remove hidden attribute on non-empty navbar-bottom on resize', () => {
- const header = document.createElement('h1');
- header.textContent = 'Header';
- header.setAttribute('slot', 'navbar touch-optimized');
- layout.appendChild(header);
- expect(layout.$.navbarBottom.hasAttribute('hidden')).to.be.true;
- window.dispatchEvent(new Event('resize'));
- expect(layout.$.navbarBottom.hasAttribute('hidden')).to.be.false;
- });
-
it('should update content offset when navbar height changes', async () => {
// Add content to navbar and measure original offset
const navbarContent = document.createElement('div');
diff --git a/packages/component-base/src/css-property-observer.js b/packages/component-base/src/css-property-observer.js
new file mode 100644
index 0000000000..8d3660cd95
--- /dev/null
+++ b/packages/component-base/src/css-property-observer.js
@@ -0,0 +1,60 @@
+/**
+ * @license
+ * Copyright (c) 2000 - 2025 Vaadin Ltd.
+ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
+ */
+
+/**
+ * WARNING: For internal use only. Do not use this class in custom components.
+ *
+ * @private
+ */
+export class CSSPropertyObserver extends EventTarget {
+ element;
+ callback;
+ properties = new Set();
+
+ constructor(element, callback) {
+ super();
+ this.element = element;
+ this._handleTransitionEvent = this._handleTransitionEvent.bind(this);
+
+ if (callback) {
+ this.addEventListener('property-changed', (event) => callback(event.detail.propertyName));
+ }
+ }
+
+ observe(...properties) {
+ this.connect();
+
+ const newProperties = properties.filter((property) => !this.properties.has(property));
+ if (newProperties.length > 0) {
+ newProperties.forEach((property) => this.properties.add(property));
+ this._updateStyles();
+ }
+ }
+
+ connect() {
+ this.element.addEventListener('transitionend', this._handleTransitionEvent);
+ }
+
+ disconnect() {
+ this.properties.clear();
+ this.element.removeEventListener('transitionend', this._handleTransitionEvent);
+ }
+
+ /** @protected */
+ _handleTransitionEvent(event) {
+ const { propertyName } = event;
+ this.dispatchEvent(new CustomEvent('property-changed', { detail: { propertyName } }));
+ }
+
+ /** @protected */
+ _updateStyles() {
+ this.element.style.display = 'contents';
+ this.element.style.transitionDuration = '1ms';
+ this.element.style.transitionBehavior = 'allow-discrete';
+ this.element.style.transitionProperty = `${[...this.properties].join(', ')}`;
+ this.element.style.transitionTimingFunction = 'step-end';
+ }
+}
diff --git a/packages/component-base/test/css-property-observer.test.js b/packages/component-base/test/css-property-observer.test.js
new file mode 100644
index 0000000000..107acd6883
--- /dev/null
+++ b/packages/component-base/test/css-property-observer.test.js
@@ -0,0 +1,56 @@
+import { expect } from '@vaadin/chai-plugins';
+import { fixtureSync, nextFrame } from '@vaadin/testing-helpers';
+import sinon from 'sinon';
+import { CSSPropertyObserver } from '../src/css-property-observer.js';
+
+CSS.registerProperty({
+ name: '--test-prop-0',
+ syntax: '',
+ inherits: true,
+ initialValue: '0',
+});
+
+CSS.registerProperty({
+ name: '--test-prop-1',
+ syntax: '',
+ inherits: true,
+ initialValue: '0',
+});
+
+describe('CSSPropertyObserver', () => {
+ let observer, element, callback;
+
+ beforeEach(async () => {
+ element = fixtureSync('');
+ callback = sinon.spy();
+ observer = new CSSPropertyObserver(element, callback);
+ await nextFrame();
+ });
+
+ it('should observe CSS property changes', async () => {
+ observer.observe('--test-prop-0', '--test-prop-1');
+ await nextFrame();
+ expect(callback).to.be.not.called;
+
+ element.style.setProperty('--test-prop-0', '1');
+ await nextFrame();
+ expect(callback).to.be.calledOnceWith('--test-prop-0');
+
+ callback.resetHistory();
+
+ element.style.setProperty('--test-prop-1', '1');
+ await nextFrame();
+ expect(callback).to.be.calledOnceWith('--test-prop-1');
+ });
+
+ it('should stop observing when disconnect is called', async () => {
+ observer.observe('--test-prop-0');
+ await nextFrame();
+ observer.disconnect();
+ await nextFrame();
+
+ element.style.setProperty('--test-prop-0', '1');
+ await nextFrame();
+ expect(callback).to.be.not.called;
+ });
+});
diff --git a/packages/vaadin-themable-mixin/src/css-property-observer.js b/packages/vaadin-themable-mixin/src/css-property-observer.js
deleted file mode 100644
index 80cfb656cc..0000000000
--- a/packages/vaadin-themable-mixin/src/css-property-observer.js
+++ /dev/null
@@ -1,90 +0,0 @@
-/**
- * @license
- * Copyright (c) 2021 - 2025 Vaadin Ltd.
- * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
- */
-
-/**
- * WARNING: For internal use only. Do not use this class in custom components.
- *
- * @private
- */
-export class CSSPropertyObserver extends EventTarget {
- #root;
- #properties = new Set();
- #styleSheet;
- #isConnected = false;
-
- constructor(root) {
- super();
- this.#root = root;
- this.#styleSheet = new CSSStyleSheet();
- }
-
- #handleTransitionEvent(event) {
- const { propertyName } = event;
- if (this.#properties.has(propertyName)) {
- this.dispatchEvent(new CustomEvent('property-changed', { detail: { propertyName } }));
- }
- }
-
- observe(property) {
- this.connect();
-
- if (this.#properties.has(property)) {
- return;
- }
-
- this.#properties.add(property);
-
- this.#styleSheet.replaceSync(`
- :root::before, :host::before {
- content: '' !important;
- position: absolute !important;
- top: -9999px !important;
- left: -9999px !important;
- visibility: hidden !important;
- transition: 1ms allow-discrete step-end !important;
- transition-property: ${[...this.#properties].join(', ')} !important;
- }
- `);
- }
-
- connect() {
- if (this.#isConnected) {
- return;
- }
-
- this.#root.adoptedStyleSheets.unshift(this.#styleSheet);
-
- this.#rootHost.addEventListener('transitionstart', (event) => this.#handleTransitionEvent(event));
- this.#rootHost.addEventListener('transitionend', (event) => this.#handleTransitionEvent(event));
-
- this.#isConnected = true;
- }
-
- disconnect() {
- this.#properties.clear();
-
- this.#root.adoptedStyleSheets = this.#root.adoptedStyleSheets.filter((s) => s !== this.#styleSheet);
-
- this.#rootHost.removeEventListener('transitionstart', this.#handleTransitionEvent);
- this.#rootHost.removeEventListener('transitionend', this.#handleTransitionEvent);
-
- this.#isConnected = false;
- }
-
- get #rootHost() {
- return this.#root.documentElement ?? this.#root.host;
- }
-
- /**
- * Gets or creates the CSSPropertyObserver for the given root.
- * @param {DocumentOrShadowRoot} root
- * @returns {CSSPropertyObserver}
- */
- static for(root) {
- root.__cssPropertyObserver ||= new CSSPropertyObserver(root);
- return root.__cssPropertyObserver;
- }
-}
diff --git a/packages/vaadin-themable-mixin/src/lumo-injector.js b/packages/vaadin-themable-mixin/src/lumo-injector.js
index d53294de03..4f34e5f54e 100644
--- a/packages/vaadin-themable-mixin/src/lumo-injector.js
+++ b/packages/vaadin-themable-mixin/src/lumo-injector.js
@@ -3,9 +3,9 @@
* Copyright (c) 2021 - 2025 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
-import { CSSPropertyObserver } from './css-property-observer.js';
import { injectLumoStyleSheet, removeLumoStyleSheet } from './css-utils.js';
import { parseStyleSheets } from './lumo-modules.js';
+import { RootCSSPropertyObserver } from './root-css-property-observer.js';
export function getLumoInjectorPropName(lumoInjector) {
return `--_lumo-${lumoInjector.is}-inject`;
@@ -87,7 +87,7 @@ export class LumoInjector {
constructor(root = document) {
this.#root = root;
this.handlePropertyChange = this.handlePropertyChange.bind(this);
- this.#cssPropertyObserver = CSSPropertyObserver.for(root);
+ this.#cssPropertyObserver = RootCSSPropertyObserver.for(root);
this.#cssPropertyObserver.addEventListener('property-changed', this.handlePropertyChange);
}
diff --git a/packages/vaadin-themable-mixin/src/root-css-property-observer.js b/packages/vaadin-themable-mixin/src/root-css-property-observer.js
new file mode 100644
index 0000000000..15d55f9a2d
--- /dev/null
+++ b/packages/vaadin-themable-mixin/src/root-css-property-observer.js
@@ -0,0 +1,59 @@
+/**
+ * @license
+ * Copyright (c) 2021 - 2025 Vaadin Ltd.
+ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
+ */
+import { CSSPropertyObserver } from '@vaadin/component-base/src/css-property-observer.js';
+
+/**
+ * An extension of CSSPropertyObserver that takes a Document or ShadowRoot and
+ * listens for changes to CSS custom properties on its `:root` or `:host` element
+ * via the `::before` pseudo-element.
+ *
+ * WARNING: For internal use only. Do not use this class in custom components.
+ */
+export class RootCSSPropertyObserver extends CSSPropertyObserver {
+ #root;
+ #styleSheet = new CSSStyleSheet();
+
+ /**
+ * Gets or creates the CSSPropertyObserver for the given root.
+ * @param {DocumentOrShadowRoot} root
+ * @returns {RootCSSPropertyObserver}
+ */
+ static for(root) {
+ root.__cssPropertyObserver ||= new RootCSSPropertyObserver(root);
+ return root.__cssPropertyObserver;
+ }
+
+ constructor(root) {
+ super(root.host ?? root.documentElement);
+ this.#root = root;
+ }
+
+ connect() {
+ super.connect();
+ this.#root.adoptedStyleSheets = this.#root.adoptedStyleSheets.filter((s) => s !== this.#styleSheet);
+ this.#root.adoptedStyleSheets.unshift(this.#styleSheet);
+ }
+
+ disconnect() {
+ super.disconnect();
+ this.#root.adoptedStyleSheets = this.#root.adoptedStyleSheets.filter((s) => s !== this.#styleSheet);
+ }
+
+ /** @override */
+ _updateStyles() {
+ this.#styleSheet.replaceSync(`
+ :root::before, :host::before {
+ content: '' !important;
+ position: absolute !important;
+ top: -9999px !important;
+ left: -9999px !important;
+ visibility: hidden !important;
+ transition: 1ms allow-discrete step-end !important;
+ transition-property: ${[...this.properties].join(', ')} !important;
+ }
+ `);
+ }
+}
diff --git a/packages/vaadin-themable-mixin/src/theme-detector.js b/packages/vaadin-themable-mixin/src/theme-detector.js
index 9e09792df1..fc60e07f4b 100644
--- a/packages/vaadin-themable-mixin/src/theme-detector.js
+++ b/packages/vaadin-themable-mixin/src/theme-detector.js
@@ -3,7 +3,7 @@
* Copyright (c) 2000 - 2025 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
-import { CSSPropertyObserver } from './css-property-observer.js';
+import { RootCSSPropertyObserver } from './root-css-property-observer.js';
// Register CSS custom properties for observing theme changes
CSS.registerProperty({
@@ -43,7 +43,7 @@ export class ThemeDetector extends EventTarget {
this.#root = root;
this.#detectTheme();
- this.#observer = CSSPropertyObserver.for(this.#root);
+ this.#observer = RootCSSPropertyObserver.for(this.#root);
this.#observer.observe('--vaadin-aura-theme');
this.#observer.observe('--vaadin-lumo-theme');
this.#observer.addEventListener('property-changed', this.#boundHandleThemeChange);