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);