Skip to content

Commit ec54cee

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 ec54cee

File tree

7 files changed

+234
-9
lines changed

7 files changed

+234
-9
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/internal/UIInternals.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1359,7 +1359,7 @@ public ExtendedClientDetails getExtendedClientDetails() {
13591359
// Create placeholder with default values
13601360
extendedClientDetails = new ExtendedClientDetails(ui, null, null,
13611361
null, null, null, null, null, null, null, null, null, null,
1362-
null, null, null, null);
1362+
null, null, null, null, null, null);
13631363
}
13641364
return extendedClientDetails;
13651365
}

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

Lines changed: 41 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
/**

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,38 @@ 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+
String newValue = (variant == null || variant.isEmpty()) ? null
96+
: variant;
97+
if (newValue == null) {
98+
executeJs("document.documentElement.removeAttribute('theme');");
99+
} else {
100+
executeJs("document.documentElement.setAttribute('theme', $0);",
101+
newValue);
102+
}
103+
getExtendedClientDetails().setThemeVariant(newValue);
104+
}
105+
106+
/**
107+
* Gets the theme variant for the page.
108+
* <p>
109+
* Note that this method returns the server-side cached value and will not
110+
* detect theme changes made directly via JavaScript or browser developer
111+
* tools.
112+
*
113+
* @return the theme variant, or empty string if not set
114+
*/
115+
public String getThemeVariant() {
116+
return getExtendedClientDetails().getThemeVariant();
117+
}
118+
87119
/**
88120
* Adds the given style sheet to the page and ensures that it is loaded
89121
* successfully.

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ public void initializeWithClientValues_gettersReturnExpectedValues() {
5151
Assert.assertEquals(2.0D, details.getDevicePixelRatio(), 0.0);
5252
Assert.assertEquals("ROOT-1234567-0.1234567", details.getWindowName());
5353
Assert.assertFalse(details.isIPad());
54+
Assert.assertEquals("light", details.getThemeVariant());
55+
Assert.assertEquals("aura", details.getThemeName());
5456

5557
// Don't test getCurrentDate() and time delta due to the dependency on
5658
// server-side time
@@ -161,14 +163,16 @@ private class ExtendBuilder {
161163
private String devicePixelRatio = "2.0";
162164
private String windowName = "ROOT-1234567-0.1234567";
163165
private String navigatorPlatform = "Linux i686";
166+
private String themeVariant = "light";
167+
private String themeName = "aura";
164168

165169
public ExtendedClientDetails buildDetails() {
166170
return new ExtendedClientDetails(null, screenWidth, screenHeight,
167171
windowInnerWidth, windowInnerHeight, bodyClientWidth,
168172
bodyClientHeight, timezoneOffset, rawTimezoneOffset,
169173
dstSavings, dstInEffect, timeZoneId, clientServerTimeDelta,
170174
touchDevice, devicePixelRatio, windowName,
171-
navigatorPlatform);
175+
navigatorPlatform, themeVariant, themeName);
172176
}
173177

174178
public ExtendBuilder setScreenWidth(String screenWidth) {

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

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,4 +300,121 @@ 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.assertEquals("dark", capturedParam.get());
326+
}
327+
328+
@Test
329+
public void setThemeVariant_null_removesAttribute() {
330+
MockUI mockUI = new MockUI();
331+
332+
AtomicReference<String> capturedExpression = new AtomicReference<>();
333+
Page page = new Page(mockUI) {
334+
@Override
335+
public PendingJavaScriptResult executeJs(String expression,
336+
Object... parameters) {
337+
capturedExpression.set(expression);
338+
return Mockito.mock(PendingJavaScriptResult.class);
339+
}
340+
};
341+
342+
page.setThemeVariant(null);
343+
344+
String js = capturedExpression.get();
345+
Assert.assertTrue(js.contains("removeAttribute('theme')"));
346+
Assert.assertEquals("", page.getThemeVariant());
347+
}
348+
349+
@Test
350+
public void setThemeVariant_emptyString_removesAttribute() {
351+
MockUI mockUI = new MockUI();
352+
353+
AtomicReference<String> capturedExpression = new AtomicReference<>();
354+
Page page = new Page(mockUI) {
355+
@Override
356+
public PendingJavaScriptResult executeJs(String expression,
357+
Object... parameters) {
358+
capturedExpression.set(expression);
359+
return Mockito.mock(PendingJavaScriptResult.class);
360+
}
361+
};
362+
363+
page.setThemeVariant("");
364+
365+
String js = capturedExpression.get();
366+
Assert.assertTrue(js.contains("removeAttribute('theme')"));
367+
Assert.assertEquals("", page.getThemeVariant());
368+
}
369+
370+
@Test
371+
public void getThemeVariant_returnsEmptyString_whenNotSet() {
372+
Page page = new Page(new MockUI());
373+
Assert.assertEquals("", page.getThemeVariant());
374+
}
375+
376+
@Test
377+
public void getThemeVariant_returnsCachedValue() {
378+
MockUI mockUI = new MockUI();
379+
// Set up ExtendedClientDetails with theme variant
380+
ExtendedClientDetails details = new ExtendedClientDetails(mockUI, null,
381+
null, null, null, null, null, null, null, null, null, null,
382+
null, null, null, null, null, "dark", null);
383+
mockUI.getInternals().setExtendedClientDetails(details);
384+
385+
Page page = new Page(mockUI);
386+
Assert.assertEquals("dark", page.getThemeVariant());
387+
}
388+
389+
@Test
390+
public void setThemeVariant_updatesGetThemeVariant() {
391+
MockUI mockUI = new MockUI();
392+
// Set up ExtendedClientDetails
393+
ExtendedClientDetails details = new ExtendedClientDetails(mockUI, null,
394+
null, null, null, null, null, null, null, null, null, null,
395+
null, null, null, null, null, null, null);
396+
mockUI.getInternals().setExtendedClientDetails(details);
397+
398+
Page page = new Page(mockUI) {
399+
@Override
400+
public PendingJavaScriptResult executeJs(String expression,
401+
Object... parameters) {
402+
return Mockito.mock(PendingJavaScriptResult.class);
403+
}
404+
};
405+
406+
Assert.assertEquals("", page.getThemeVariant());
407+
408+
page.setThemeVariant("dark");
409+
Assert.assertEquals("dark", page.getThemeVariant());
410+
411+
page.setThemeVariant("light");
412+
Assert.assertEquals("light", page.getThemeVariant());
413+
414+
page.setThemeVariant(null);
415+
Assert.assertEquals("", page.getThemeVariant());
416+
417+
page.setThemeVariant("");
418+
Assert.assertEquals("", page.getThemeVariant());
419+
}
303420
}

flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/ExtendedClientDetailsView.java

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,15 @@ protected void onShow() {
3535
Div bodyElementHeight = createDiv("bh");
3636
Div devicePixelRatio = createDiv("pr");
3737
Div touchDevice = createDiv("td");
38+
Div themeVariant = createDiv("theme-variant");
39+
Div themeName = createDiv("theme-name");
3840

3941
// Display initial values immediately
4042
ExtendedClientDetails details = UI.getCurrentOrThrow().getPage()
4143
.getExtendedClientDetails();
4244
displayDetails(details, screenWidth, screenHeight, windowInnerWidth,
4345
windowInnerHeight, bodyElementWidth, bodyElementHeight,
44-
devicePixelRatio, touchDevice);
46+
devicePixelRatio, touchDevice, themeVariant, themeName);
4547

4648
// the sizing values cannot be set with JS but pixel ratio and touch
4749
// support can be faked
@@ -61,19 +63,28 @@ protected void onShow() {
6163
screenHeight, windowInnerWidth,
6264
windowInnerHeight, bodyElementWidth,
6365
bodyElementHeight, devicePixelRatio,
64-
touchDevice);
66+
touchDevice, themeVariant, themeName);
6567

6668
});
69+
getUI().ifPresent(
70+
ui -> ui.getPage().setThemeVariant("light"));
6771
});
68-
fetchDetailsButton.setId("fetch-values");
72+
setLightButton.setId("set-light");
6973

70-
add(setValuesButton, fetchDetailsButton);
74+
NativeButton clearThemeButton = new NativeButton("Clear Theme",
75+
event -> {
76+
getUI().ifPresent(ui -> ui.getPage().setThemeVariant(null));
77+
});
78+
clearThemeButton.setId("clear-theme");
79+
80+
add(setValuesButton, fetchDetailsButton, setDarkButton, setLightButton,
81+
clearThemeButton);
7182
}
7283

7384
private void displayDetails(ExtendedClientDetails details, Div screenWidth,
7485
Div screenHeight, Div windowInnerWidth, Div windowInnerHeight,
7586
Div bodyElementWidth, Div bodyElementHeight, Div devicePixelRatio,
76-
Div touchDevice) {
87+
Div touchDevice, Div themeVariant, Div themeName) {
7788
screenWidth.setText("" + details.getScreenWidth());
7889
screenHeight.setText("" + details.getScreenHeight());
7990
windowInnerWidth.setText("" + details.getWindowInnerWidth());
@@ -82,6 +93,10 @@ private void displayDetails(ExtendedClientDetails details, Div screenWidth,
8293
bodyElementHeight.setText("" + details.getBodyClientHeight());
8394
devicePixelRatio.setText("" + details.getDevicePixelRatio());
8495
touchDevice.setText("" + details.isTouchDevice());
96+
String theme = details.getThemeVariant();
97+
themeVariant.setText(theme.isEmpty() ? "(empty)" : theme);
98+
String name = details.getThemeName();
99+
themeName.setText(name.isEmpty() ? "(empty)" : name);
85100
}
86101

87102
private Div createDiv(String id) {

0 commit comments

Comments
 (0)