Skip to content

Commit 85771d8

Browse files
authored
feat: add mixin for detecting Vaadin theme (#10390)
* feat: add mixin for detecting Vaadin theme * use data-application-theme as attribute * use private members * improve annotations * update attribute name in JSDoc
1 parent a93f9ef commit 85771d8

File tree

7 files changed

+279
-1
lines changed

7 files changed

+279
-1
lines changed

packages/aura/aura.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,5 @@
3939
cursor: default;
4040
/* TODO: slightly smaller chevron than the one in base styles - consider if we should do this in base styles */
4141
--_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="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>');
42+
--vaadin-aura-theme: 1;
4243
}

packages/vaadin-lumo-styles/lumo.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,8 @@
66
@import './src/props/index.css';
77
@import './src/global/index.css';
88
@import './components/index.css';
9+
10+
:where(:root),
11+
:where(:host) {
12+
--vaadin-lumo-theme: 1;
13+
}

packages/vaadin-themable-mixin/lumo-injection-mixin.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const registeredProperties = new Set();
1616
* @param {HTMLElement} element
1717
* @return {DocumentOrShadowRoot}
1818
*/
19-
function findRoot(element) {
19+
export function findRoot(element) {
2020
const root = element.getRootNode();
2121

2222
if (root.host && root.host.constructor.version) {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* @license
3+
* Copyright (c) 2000 - 2025 Vaadin Ltd.
4+
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5+
*/
6+
import { CSSPropertyObserver } from './css-property-observer.js';
7+
8+
// Register CSS custom properties for observing theme changes
9+
CSS.registerProperty({
10+
name: '--vaadin-aura-theme',
11+
syntax: '<number>',
12+
inherits: true,
13+
initialValue: '0',
14+
});
15+
16+
CSS.registerProperty({
17+
name: '--vaadin-lumo-theme',
18+
syntax: '<number>',
19+
inherits: true,
20+
initialValue: '0',
21+
});
22+
23+
/**
24+
* Observes a root (Document or ShadowRoot) for which Vaadin theme is currently applied.
25+
* Notifies about theme changes by firing a `theme-changed` event.
26+
*
27+
* WARNING: For internal use only. Do not use this class in custom components.
28+
*
29+
* @private
30+
*/
31+
export class ThemeDetector extends EventTarget {
32+
/** @type {DocumentOrShadowRoot} */
33+
#root;
34+
/** @type {CSSPropertyObserver} */
35+
#observer;
36+
/** @type {{ aura: boolean; lumo: boolean }} */
37+
#themes = { aura: false, lumo: false };
38+
/** @type {(event: CustomEvent) => void} */
39+
#boundHandleThemeChange = this.#handleThemeChange.bind(this);
40+
41+
constructor(root) {
42+
super();
43+
this.#root = root;
44+
this.#detectTheme();
45+
46+
this.#observer = CSSPropertyObserver.for(this.#root);
47+
this.#observer.observe('--vaadin-aura-theme');
48+
this.#observer.observe('--vaadin-lumo-theme');
49+
this.#observer.addEventListener('property-changed', this.#boundHandleThemeChange);
50+
}
51+
52+
get themes() {
53+
return { ...this.#themes };
54+
}
55+
56+
#handleThemeChange(event) {
57+
const { propertyName } = event.detail;
58+
if (!['--vaadin-aura-theme', '--vaadin-lumo-theme'].includes(propertyName)) {
59+
return;
60+
}
61+
62+
this.#detectTheme();
63+
this.dispatchEvent(new CustomEvent('theme-changed'));
64+
}
65+
66+
#detectTheme() {
67+
const rootElement = this.#root.documentElement ?? this.#root.host;
68+
const style = getComputedStyle(rootElement);
69+
this.#themes = {
70+
aura: style.getPropertyValue('--vaadin-aura-theme').trim() === '1',
71+
lumo: style.getPropertyValue('--vaadin-lumo-theme').trim() === '1',
72+
};
73+
}
74+
75+
disconnect() {
76+
this.#observer.removeEventListener('property-changed', this.#boundHandleThemeChange);
77+
}
78+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { expect } from '@vaadin/chai-plugins';
2+
import { fixtureSync, nextRender, oneEvent } from '@vaadin/testing-helpers';
3+
import { LitElement } from 'lit';
4+
import { ThemeDetectionMixin } from '../vaadin-theme-detection-mixin.js';
5+
6+
class TestElement extends ThemeDetectionMixin(LitElement) {
7+
static get is() {
8+
return 'test-element';
9+
}
10+
11+
static get version() {
12+
return '1.0.0';
13+
}
14+
}
15+
16+
customElements.define(TestElement.is, TestElement);
17+
18+
class CustomStyleRoot extends LitElement {}
19+
20+
customElements.define('custom-style-root', CustomStyleRoot);
21+
22+
describe('theme-detection-mixin', () => {
23+
let styleRoot, hostElement, contentElement, stylesheet, testElement;
24+
25+
function applyTheme(themeProperty = null) {
26+
if (styleRoot.adoptedStyleSheets.includes(stylesheet)) {
27+
styleRoot.adoptedStyleSheets = styleRoot.adoptedStyleSheets.filter((s) => s !== stylesheet);
28+
}
29+
30+
if (themeProperty) {
31+
stylesheet = new CSSStyleSheet();
32+
stylesheet.replaceSync(`:host, :root { ${themeProperty}: 1; }`);
33+
styleRoot.adoptedStyleSheets = [...styleRoot.adoptedStyleSheets, stylesheet];
34+
}
35+
}
36+
37+
beforeEach(() => {
38+
testElement = document.createElement('test-element');
39+
});
40+
41+
afterEach(() => {
42+
testElement.remove();
43+
styleRoot.__themeDetector?.disconnect();
44+
styleRoot.__themeDetector = undefined;
45+
styleRoot.__cssPropertyObserver?.disconnect();
46+
styleRoot.__cssPropertyObserver = undefined;
47+
applyTheme();
48+
});
49+
50+
function runThemeDetectionTests() {
51+
it('should define ThemeDetector on the style root', () => {
52+
contentElement.append(testElement);
53+
54+
expect(styleRoot.__themeDetector).to.be.ok;
55+
});
56+
57+
describe('initial theme', () => {
58+
it('should detect no theme', () => {
59+
contentElement.append(testElement);
60+
61+
expect(testElement.hasAttribute('data-application-theme')).to.be.false;
62+
});
63+
64+
it('should detect Aura theme', () => {
65+
applyTheme('--vaadin-aura-theme');
66+
67+
contentElement.append(testElement);
68+
69+
expect(testElement.getAttribute('data-application-theme')).to.equal('aura');
70+
});
71+
72+
it('should detect Lumo theme', () => {
73+
applyTheme('--vaadin-lumo-theme');
74+
75+
contentElement.append(testElement);
76+
77+
expect(testElement.getAttribute('data-application-theme')).to.equal('lumo');
78+
});
79+
});
80+
81+
describe('theme changes', () => {
82+
beforeEach(async () => {
83+
contentElement.append(testElement);
84+
await nextRender();
85+
});
86+
87+
it('should detect theme changes', async () => {
88+
expect(testElement.hasAttribute('data-application-theme')).to.be.false;
89+
90+
applyTheme('--vaadin-aura-theme');
91+
await oneEvent(hostElement, 'transitionend');
92+
expect(testElement.getAttribute('data-application-theme')).to.equal('aura');
93+
94+
applyTheme('--vaadin-lumo-theme');
95+
await oneEvent(hostElement, 'transitionend');
96+
expect(testElement.getAttribute('data-application-theme')).to.equal('lumo');
97+
98+
applyTheme();
99+
await oneEvent(hostElement, 'transitionend');
100+
expect(testElement.hasAttribute('data-application-theme')).to.be.false;
101+
});
102+
});
103+
}
104+
105+
describe('in global scope', () => {
106+
beforeEach(() => {
107+
hostElement = document;
108+
styleRoot = document;
109+
contentElement = document.body;
110+
});
111+
112+
runThemeDetectionTests();
113+
});
114+
115+
describe('in shadow root', () => {
116+
beforeEach(() => {
117+
hostElement = fixtureSync(`<custom-style-root></custom-style-root>`);
118+
styleRoot = hostElement.shadowRoot;
119+
contentElement = hostElement.shadowRoot;
120+
});
121+
122+
runThemeDetectionTests();
123+
});
124+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* @license
3+
* Copyright (c) 2017 - 2025 Vaadin Ltd.
4+
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5+
*/
6+
import type { Constructor } from '@open-wc/dedupe-mixin';
7+
8+
export declare function ThemeDetectionMixin<T extends Constructor<HTMLElement>>(
9+
base: T,
10+
): Constructor<ThemeDetectionMixinClass> & T;
11+
12+
export declare class ThemeDetectionMixinClass {}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { findRoot } from './lumo-injection-mixin.js';
2+
import { ThemeDetector } from './src/theme-detector.js';
3+
4+
/**
5+
* Mixin for detecting which Vaadin theme is applied to the application.
6+
* Automatically adds a `data-application-theme` attribute to the host
7+
* element with the name of the detected theme (`lumo` or `aura`), which
8+
* can be used in component styles to apply theme-specific styling.
9+
*
10+
* @polymerMixin
11+
*/
12+
export const ThemeDetectionMixin = (superClass) =>
13+
class ThemeDetectionMixinClass extends superClass {
14+
constructor() {
15+
super();
16+
17+
this.__applyDetectedTheme = this.__applyDetectedTheme.bind(this);
18+
}
19+
20+
/** @protected */
21+
connectedCallback() {
22+
super.connectedCallback();
23+
24+
if (this.isConnected) {
25+
const root = findRoot(this);
26+
root.__themeDetector = root.__themeDetector || new ThemeDetector(root);
27+
this.__themeDetector = root.__themeDetector;
28+
this.__themeDetector.addEventListener('theme-changed', this.__applyDetectedTheme);
29+
this.__applyDetectedTheme();
30+
}
31+
}
32+
33+
/** @protected */
34+
disconnectedCallback() {
35+
super.disconnectedCallback();
36+
37+
if (this.__themeDetector) {
38+
this.__themeDetector.removeEventListener('theme-changed', this.__applyDetectedTheme);
39+
this.__themeDetector = null;
40+
}
41+
}
42+
43+
/** @private */
44+
__applyDetectedTheme() {
45+
if (!this.__themeDetector) {
46+
return;
47+
}
48+
49+
const themes = this.__themeDetector.themes;
50+
if (themes.aura) {
51+
this.dataset.applicationTheme = 'aura';
52+
} else if (themes.lumo) {
53+
this.dataset.applicationTheme = 'lumo';
54+
} else {
55+
delete this.dataset.applicationTheme;
56+
}
57+
}
58+
};

0 commit comments

Comments
 (0)