Skip to content

Commit d97b63b

Browse files
committed
feat: add API for switching between light and dark theme variants
Adds Page.setThemeVariant() and Page.getThemeVariant() methods to enable runtime switching between theme variants without requiring manual JavaScript execution. The theme variant is automatically synced from the browser on page load if set in index.html. The implementation supports both Lumo and Aura themes: - Lumo: Sets/removes the 'theme' attribute on the HTML element - Aura: Sets/removes the '--aura-color-scheme' CSS custom property Key changes: - Page API: setThemeVariant() and getThemeVariant() methods - UIInternals: cache theme variant for quick access (returns "" not null) - ExtendedClientDetails: include theme variant from browser - FlowBootstrap.js: sync theme attribute and Aura color scheme on page load - Comprehensive unit and integration tests Fixes #15354
1 parent fd7215d commit d97b63b

File tree

7 files changed

+264
-11
lines changed

7 files changed

+264
-11
lines changed

flow-client/src/main/frontend/Flow.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,24 @@ export class Flow {
539539
params['v-np'] = ($wnd as any).navigator.platform;
540540
}
541541

542+
/* Theme variant from HTML element - supports both Lumo and Aura */
543+
let themeAttr = document.documentElement.getAttribute('theme');
544+
if (!themeAttr) {
545+
// If no theme attribute, check for Aura color scheme CSS property
546+
const auraScheme = getComputedStyle(document.documentElement).getPropertyValue('--aura-color-scheme').trim();
547+
themeAttr = auraScheme || '';
548+
}
549+
params['v-theme'] = themeAttr;
550+
/* Theme name - detect which theme is in use */
551+
const computedStyle = getComputedStyle(document.documentElement);
552+
let themeName = '';
553+
if (computedStyle.getPropertyValue('--vaadin-lumo-theme').trim()) {
554+
themeName = 'lumo';
555+
} else if (computedStyle.getPropertyValue('--vaadin-aura-theme').trim()) {
556+
themeName = 'aura';
557+
}
558+
params['v-theme-name'] = themeName;
559+
542560
/* Stringify each value (they are parsed on the server side) */
543561
const stringParams: Record<string, string> = {};
544562
Object.keys(params).forEach((key) => {

flow-server/src/main/java/com/vaadin/flow/component/page/ExtendedClientDetails.java

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ public class ExtendedClientDetails implements Serializable {
6060
private double devicePixelRatio = -1.0D;
6161
private String windowName;
6262
private String navigatorPlatform;
63+
private String themeVariant;
64+
private String themeName;
6365

6466
/**
6567
* For internal use only. Updates all properties in the class according to
@@ -100,14 +102,18 @@ public class ExtendedClientDetails implements Serializable {
100102
* a unique browser window name which persists on reload
101103
* @param navigatorPlatform
102104
* navigation platform received from the browser
105+
* @param themeVariant
106+
* the current theme variant
107+
* @param themeName
108+
* the theme name (e.g., "lumo", "aura")
103109
*/
104110
public ExtendedClientDetails(UI ui, String screenWidth, String screenHeight,
105111
String windowInnerWidth, String windowInnerHeight,
106112
String bodyClientWidth, String bodyClientHeight, String tzOffset,
107113
String rawTzOffset, String dstShift, String dstInEffect,
108114
String tzId, String curDate, String touchDevice,
109115
String devicePixelRatio, String windowName,
110-
String navigatorPlatform) {
116+
String navigatorPlatform, String themeVariant, String themeName) {
111117
this.ui = ui;
112118
if (screenWidth != null) {
113119
try {
@@ -184,6 +190,8 @@ public ExtendedClientDetails(UI ui, String screenWidth, String screenHeight,
184190

185191
this.windowName = windowName;
186192
this.navigatorPlatform = navigatorPlatform;
193+
this.themeVariant = themeVariant;
194+
this.themeName = themeName;
187195
}
188196

189197
/**
@@ -397,6 +405,35 @@ public boolean isIOS() {
397405
&& navigatorPlatform.startsWith("iPod"));
398406
}
399407

408+
/**
409+
* Gets the theme variant.
410+
*
411+
* @return the theme variant, or empty string if not set
412+
*/
413+
public String getThemeVariant() {
414+
return themeVariant;
415+
}
416+
417+
/**
418+
* Gets the theme name.
419+
*
420+
* @return the theme name (e.g., "lumo", "aura"), or empty string if not
421+
* detected
422+
*/
423+
public String getThemeName() {
424+
return themeName;
425+
}
426+
427+
/**
428+
* Updates the theme variant. For internal use only.
429+
*
430+
* @param themeVariant
431+
* the new theme variant
432+
*/
433+
void setThemeVariant(String themeVariant) {
434+
this.themeVariant = themeVariant == null ? "" : themeVariant;
435+
}
436+
400437
/**
401438
* Creates an ExtendedClientDetails instance from browser details JSON
402439
* object. This is intended for internal use when browser details are
@@ -446,7 +483,9 @@ public static ExtendedClientDetails fromJson(UI ui, JsonNode json) {
446483
getStringElseNull.apply("v-td"),
447484
getStringElseNull.apply("v-pr"),
448485
getStringElseNull.apply("v-wn"),
449-
getStringElseNull.apply("v-np"));
486+
getStringElseNull.apply("v-np"),
487+
getStringElseNull.apply("v-theme"),
488+
getStringElseNull.apply("v-theme-name"));
450489
}
451490

452491
/**
@@ -474,4 +513,5 @@ public void refresh(Consumer<ExtendedClientDetails> callback) {
474513
};
475514
ui.getPage().executeJs(js).then(resultHandler, errorHandler);
476515
}
516+
477517
}

flow-server/src/main/java/com/vaadin/flow/component/page/Page.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,54 @@ public void setTitle(String title) {
8484
ui.getInternals().setTitle(title);
8585
}
8686

87+
/**
88+
* Sets the theme variant for the page.
89+
*
90+
* @param variant
91+
* the theme variant to set (e.g., "dark", "light"), or
92+
* {@code null} or empty string to remove the theme variant
93+
*/
94+
public void setThemeVariant(String variant) {
95+
if (variant == null || variant.isEmpty()) {
96+
executeJs(
97+
"""
98+
document.documentElement.removeAttribute('theme');
99+
document.documentElement.style.removeProperty('--aura-color-scheme');
100+
""");
101+
ui.getInternals().setThemeVariant(null);
102+
} else {
103+
executeJs(
104+
"""
105+
document.documentElement.setAttribute('theme', $0);
106+
document.documentElement.style.setProperty('--aura-color-scheme', $0);
107+
""",
108+
variant);
109+
ui.getInternals().setThemeVariant(variant);
110+
}
111+
112+
// Update the cached value in ExtendedClientDetails
113+
ExtendedClientDetails details = ui.getInternals()
114+
.getExtendedClientDetails();
115+
if (details != null) {
116+
details.setThemeVariant(variant);
117+
}
118+
}
119+
120+
/**
121+
* Gets the theme variant for the page.
122+
* <p>
123+
* Note that this method returns the server-side cached value and will not
124+
* detect theme changes made directly via JavaScript or browser developer
125+
* tools.
126+
*
127+
* @return the theme variant, or empty string if not set
128+
*/
129+
public String getThemeVariant() {
130+
ExtendedClientDetails details = ui.getInternals()
131+
.getExtendedClientDetails();
132+
return details != null ? details.getThemeVariant() : "";
133+
}
134+
87135
/**
88136
* Adds the given style sheet to the page and ensures that it is loaded
89137
* successfully.

flow-server/src/main/java/com/vaadin/flow/server/communication/IndexHtmlRequestHandler.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,8 @@ public boolean synchronizedHandleRequest(VaadinSession session,
177177
}
178178

179179
addDevBundleTheme(indexDocument, context);
180-
applyThemeVariant(indexDocument, context);
180+
applyThemeVariant(indexDocument, context,
181+
indexHtmlResponse.getUI().orElse(null));
181182

182183
if (config.isDevToolsEnabled()) {
183184
addDevTools(indexDocument, config, session, request);
@@ -254,11 +255,20 @@ private static void addDevBundleTheme(Document document,
254255
}
255256

256257
private void applyThemeVariant(Document indexDocument,
257-
VaadinContext context) {
258+
VaadinContext context, UI ui) {
259+
Element htmlElement = indexDocument.head().parent();
260+
258261
ThemeUtils.getThemeAnnotation(context).ifPresent(theme -> {
259262
String variant = theme.variant();
260263
if (!variant.isEmpty()) {
261-
indexDocument.head().parent().attr("theme", variant);
264+
htmlElement.attr("theme", variant);
265+
String style = htmlElement.attr("style");
266+
String auraProperty = "--aura-color-scheme: " + variant + ";";
267+
if (style != null && !style.isEmpty()) {
268+
htmlElement.attr("style", style + " " + auraProperty);
269+
} else {
270+
htmlElement.attr("style", auraProperty);
271+
}
262272
}
263273
});
264274
}

flow-server/src/test/java/com/vaadin/flow/component/page/ExtendedClientDetailsTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,14 +161,15 @@ private class ExtendBuilder {
161161
private String devicePixelRatio = "2.0";
162162
private String windowName = "ROOT-1234567-0.1234567";
163163
private String navigatorPlatform = "Linux i686";
164+
private String themeVariant = null;
164165

165166
public ExtendedClientDetails buildDetails() {
166167
return new ExtendedClientDetails(null, screenWidth, screenHeight,
167168
windowInnerWidth, windowInnerHeight, bodyClientWidth,
168169
bodyClientHeight, timezoneOffset, rawTimezoneOffset,
169170
dstSavings, dstInEffect, timeZoneId, clientServerTimeDelta,
170171
touchDevice, devicePixelRatio, windowName,
171-
navigatorPlatform);
172+
navigatorPlatform, themeVariant);
172173
}
173174

174175
public ExtendBuilder setScreenWidth(String screenWidth) {

flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,4 +300,125 @@ public PendingJavaScriptResult executeJs(String expression,
300300
MatcherAssert.assertThat(capture.get(), CoreMatchers
301301
.startsWith("if ($1 == '_self') this.stopApplication();"));
302302
}
303+
304+
@Test
305+
public void setThemeVariant_setsAttribute() {
306+
AtomicReference<String> capturedExpression = new AtomicReference<>();
307+
AtomicReference<Object> capturedParam = new AtomicReference<>();
308+
MockUI mockUI = new MockUI();
309+
Page page = new Page(mockUI) {
310+
@Override
311+
public PendingJavaScriptResult executeJs(String expression,
312+
Object... parameters) {
313+
capturedExpression.set(expression);
314+
if (parameters.length > 0) {
315+
capturedParam.set(parameters[0]);
316+
}
317+
return Mockito.mock(PendingJavaScriptResult.class);
318+
}
319+
};
320+
321+
page.setThemeVariant("dark");
322+
323+
String js = capturedExpression.get();
324+
Assert.assertTrue(js.contains("setAttribute('theme', $0)"));
325+
Assert.assertTrue(
326+
js.contains("setProperty('--aura-color-scheme', $0)"));
327+
Assert.assertEquals("dark", capturedParam.get());
328+
}
329+
330+
@Test
331+
public void setThemeVariant_null_removesAttribute() {
332+
MockUI mockUI = new MockUI();
333+
334+
AtomicReference<String> capturedExpression = new AtomicReference<>();
335+
Page page = new Page(mockUI) {
336+
@Override
337+
public PendingJavaScriptResult executeJs(String expression,
338+
Object... parameters) {
339+
capturedExpression.set(expression);
340+
return Mockito.mock(PendingJavaScriptResult.class);
341+
}
342+
};
343+
344+
page.setThemeVariant(null);
345+
346+
String js = capturedExpression.get();
347+
Assert.assertTrue(js.contains("removeAttribute('theme')"));
348+
Assert.assertTrue(js.contains("removeProperty('--aura-color-scheme')"));
349+
Assert.assertEquals("", mockUI.getInternals().getThemeVariant());
350+
}
351+
352+
@Test
353+
public void setThemeVariant_emptyString_removesAttribute() {
354+
MockUI mockUI = new MockUI();
355+
356+
AtomicReference<String> capturedExpression = new AtomicReference<>();
357+
Page page = new Page(mockUI) {
358+
@Override
359+
public PendingJavaScriptResult executeJs(String expression,
360+
Object... parameters) {
361+
capturedExpression.set(expression);
362+
return Mockito.mock(PendingJavaScriptResult.class);
363+
}
364+
};
365+
366+
page.setThemeVariant("");
367+
368+
String js = capturedExpression.get();
369+
Assert.assertTrue(js.contains("removeAttribute('theme')"));
370+
Assert.assertTrue(js.contains("removeProperty('--aura-color-scheme')"));
371+
Assert.assertEquals("", mockUI.getInternals().getThemeVariant());
372+
}
373+
374+
@Test
375+
public void getThemeVariant_returnsEmptyString_whenNotSet() {
376+
Page page = new Page(new MockUI());
377+
Assert.assertEquals("", page.getThemeVariant());
378+
}
379+
380+
@Test
381+
public void getThemeVariant_returnsCachedValue() {
382+
MockUI mockUI = new MockUI();
383+
// Set up ExtendedClientDetails with theme variant
384+
ExtendedClientDetails details = new ExtendedClientDetails(mockUI, null,
385+
null, null, null, null, null, null, null, null, null, null,
386+
null, null, null, null, null, "dark", null);
387+
mockUI.getInternals().setExtendedClientDetails(details);
388+
389+
Page page = new Page(mockUI);
390+
Assert.assertEquals("dark", page.getThemeVariant());
391+
}
392+
393+
@Test
394+
public void setThemeVariant_updatesGetThemeVariant() {
395+
MockUI mockUI = new MockUI();
396+
// Set up ExtendedClientDetails
397+
ExtendedClientDetails details = new ExtendedClientDetails(mockUI, null,
398+
null, null, null, null, null, null, null, null, null, null,
399+
null, null, null, null, null, null, null);
400+
mockUI.getInternals().setExtendedClientDetails(details);
401+
402+
Page page = new Page(mockUI) {
403+
@Override
404+
public PendingJavaScriptResult executeJs(String expression,
405+
Object... parameters) {
406+
return Mockito.mock(PendingJavaScriptResult.class);
407+
}
408+
};
409+
410+
Assert.assertEquals("", page.getThemeVariant());
411+
412+
page.setThemeVariant("dark");
413+
Assert.assertEquals("dark", page.getThemeVariant());
414+
415+
page.setThemeVariant("light");
416+
Assert.assertEquals("light", page.getThemeVariant());
417+
418+
page.setThemeVariant(null);
419+
Assert.assertEquals("", page.getThemeVariant());
420+
421+
page.setThemeVariant("");
422+
Assert.assertEquals("", page.getThemeVariant());
423+
}
303424
}

0 commit comments

Comments
 (0)