Skip to content

Commit fa58500

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 8728721 commit fa58500

File tree

8 files changed

+406
-20
lines changed

8 files changed

+406
-20
lines changed

flow-client/src/main/frontend/FlowBootstrap.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,15 @@ Please submit an issue to https://github.com/vaadin/flow-components/issues/new/c
264264
params['v-np'] = navigator.platform;
265265
}
266266

267+
/* Theme variant from HTML element - supports both Lumo and Aura */
268+
var themeAttr = document.documentElement.getAttribute('theme');
269+
if (!themeAttr) {
270+
// If no theme attribute, check for Aura color scheme CSS property
271+
var auraScheme = getComputedStyle(document.documentElement).getPropertyValue('--aura-color-scheme').trim();
272+
themeAttr = auraScheme || '';
273+
}
274+
params['v-theme'] = themeAttr;
275+
267276
/* Stringify each value (they are parsed on the server side) */
268277
Object.keys(params).forEach(function (key) {
269278
var value = params[key];

flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,8 @@ public List<Object> getParameters() {
230230

231231
private ExtendedClientDetails extendedClientDetails = null;
232232

233+
private String themeVariant = "";
234+
233235
private ArrayDeque<Component> modalComponentStack;
234236

235237
/**
@@ -1366,6 +1368,25 @@ public void setExtendedClientDetails(ExtendedClientDetails details) {
13661368
this.extendedClientDetails = details;
13671369
}
13681370

1371+
/**
1372+
* Gets the theme variant that is currently set for the page.
1373+
*
1374+
* @return the theme variant, or empty string if not set
1375+
*/
1376+
public String getThemeVariant() {
1377+
return themeVariant;
1378+
}
1379+
1380+
/**
1381+
* Sets the theme variant for the page.
1382+
*
1383+
* @param themeVariant
1384+
* the theme variant to set, or {@literal null} to clear
1385+
*/
1386+
public void setThemeVariant(String themeVariant) {
1387+
this.themeVariant = themeVariant == null ? "" : themeVariant;
1388+
}
1389+
13691390
/**
13701391
* Check if we have a modal component defined for the UI.
13711392
*

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public class ExtendedClientDetails implements Serializable {
4949
private double devicePixelRatio = -1.0D;
5050
private String windowName;
5151
private String navigatorPlatform;
52+
private String themeVariant;
5253

5354
/**
5455
* For internal use only. Updates all properties in the class according to
@@ -87,14 +88,16 @@ public class ExtendedClientDetails implements Serializable {
8788
* a unique browser window name which persists on reload
8889
* @param navigatorPlatform
8990
* navigation platform received from the browser
91+
* @param themeVariant
92+
* the current theme variant from the HTML element
9093
*/
9194
ExtendedClientDetails(String screenWidth, String screenHeight,
9295
String windowInnerWidth, String windowInnerHeight,
9396
String bodyClientWidth, String bodyClientHeight, String tzOffset,
9497
String rawTzOffset, String dstShift, String dstInEffect,
9598
String tzId, String curDate, String touchDevice,
9699
String devicePixelRatio, String windowName,
97-
String navigatorPlatform) {
100+
String navigatorPlatform, String themeVariant) {
98101
if (screenWidth != null) {
99102
try {
100103
this.screenWidth = Integer.parseInt(screenWidth);
@@ -170,6 +173,7 @@ public class ExtendedClientDetails implements Serializable {
170173

171174
this.windowName = windowName;
172175
this.navigatorPlatform = navigatorPlatform;
176+
this.themeVariant = themeVariant;
173177
}
174178

175179
/**
@@ -382,4 +386,13 @@ public boolean isIOS() {
382386
|| (navigatorPlatform != null
383387
&& navigatorPlatform.startsWith("iPod"));
384388
}
389+
390+
/**
391+
* Gets the theme variant that is currently set on the HTML element.
392+
*
393+
* @return the theme variant, or empty string if not set
394+
*/
395+
public String getThemeVariant() {
396+
return themeVariant;
397+
}
385398
}

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

Lines changed: 81 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,66 @@ public void setTitle(String title) {
8888
ui.getInternals().setTitle(title);
8989
}
9090

91+
/**
92+
* Sets the theme variant for the page. This method supports both Lumo and
93+
* Aura themes by setting the appropriate attributes and CSS properties.
94+
* <p>
95+
* For Lumo theme, this sets the {@code theme} attribute on the HTML
96+
* document element. For Aura theme, this sets the
97+
* {@code --aura-color-scheme} CSS custom property.
98+
* <p>
99+
* Example usage:
100+
*
101+
* <pre>
102+
* // Switch to dark mode
103+
* UI.getCurrent().getPage().setThemeVariant("dark");
104+
*
105+
* // Switch to light mode (or remove variant for Lumo)
106+
* UI.getCurrent().getPage().setThemeVariant("");
107+
* // or for explicit light mode in Aura
108+
* UI.getCurrent().getPage().setThemeVariant("light");
109+
* </pre>
110+
*
111+
* @param variant
112+
* the theme variant to set (e.g., "dark", "light"), or
113+
* {@code null} or empty string to remove the theme variant
114+
*/
115+
public void setThemeVariant(String variant) {
116+
if (variant == null || variant.isEmpty()) {
117+
executeJs("""
118+
document.documentElement.removeAttribute('theme');
119+
document.documentElement.style.removeProperty('--aura-color-scheme');
120+
""");
121+
ui.getInternals().setThemeVariant(null);
122+
} else {
123+
executeJs("""
124+
document.documentElement.setAttribute('theme', $0);
125+
document.documentElement.style.setProperty('--aura-color-scheme', $0);
126+
""", variant);
127+
ui.getInternals().setThemeVariant(variant);
128+
}
129+
}
130+
131+
/**
132+
* Gets the currently set theme variant for the page.
133+
* <p>
134+
* This returns the cached theme variant value that was either:
135+
* <ul>
136+
* <li>Set via {@link #setThemeVariant(String)}, or</li>
137+
* <li>Retrieved from the browser on page load (if the theme attribute was
138+
* set in index.html)</li>
139+
* </ul>
140+
* <p>
141+
* Note that this method returns the server-side cached value and will not
142+
* detect theme changes made directly via JavaScript or browser developer
143+
* tools.
144+
*
145+
* @return the theme variant, or empty string if not set
146+
*/
147+
public String getThemeVariant() {
148+
return ui.getInternals().getThemeVariant();
149+
}
150+
91151
/**
92152
* Adds the given style sheet to the page and ensures that it is loaded
93153
* successfully.
@@ -533,24 +593,27 @@ private void handleExtendedClientDetailsResponse(JsonNode json) {
533593
return null;
534594
}
535595
};
536-
ui.getInternals()
537-
.setExtendedClientDetails(new ExtendedClientDetails(
538-
getStringElseNull.apply("v-sw"),
539-
getStringElseNull.apply("v-sh"),
540-
getStringElseNull.apply("v-ww"),
541-
getStringElseNull.apply("v-wh"),
542-
getStringElseNull.apply("v-bw"),
543-
getStringElseNull.apply("v-bh"),
544-
getStringElseNull.apply("v-tzo"),
545-
getStringElseNull.apply("v-rtzo"),
546-
getStringElseNull.apply("v-dstd"),
547-
getStringElseNull.apply("v-dston"),
548-
getStringElseNull.apply("v-tzid"),
549-
getStringElseNull.apply("v-curdate"),
550-
getStringElseNull.apply("v-td"),
551-
getStringElseNull.apply("v-pr"),
552-
getStringElseNull.apply("v-wn"),
553-
getStringElseNull.apply("v-np")));
596+
ExtendedClientDetails details = new ExtendedClientDetails(
597+
getStringElseNull.apply("v-sw"),
598+
getStringElseNull.apply("v-sh"),
599+
getStringElseNull.apply("v-ww"),
600+
getStringElseNull.apply("v-wh"),
601+
getStringElseNull.apply("v-bw"),
602+
getStringElseNull.apply("v-bh"),
603+
getStringElseNull.apply("v-tzo"),
604+
getStringElseNull.apply("v-rtzo"),
605+
getStringElseNull.apply("v-dstd"),
606+
getStringElseNull.apply("v-dston"),
607+
getStringElseNull.apply("v-tzid"),
608+
getStringElseNull.apply("v-curdate"),
609+
getStringElseNull.apply("v-td"),
610+
getStringElseNull.apply("v-pr"),
611+
getStringElseNull.apply("v-wn"),
612+
getStringElseNull.apply("v-np"),
613+
getStringElseNull.apply("v-theme"));
614+
ui.getInternals().setExtendedClientDetails(details);
615+
// Cache the theme variant in UIInternals for quick access
616+
ui.getInternals().setThemeVariant(details.getThemeVariant());
554617
}
555618

556619
/**

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(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: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,4 +366,117 @@ public PendingJavaScriptResult executeJs(String expression,
366366
MatcherAssert.assertThat(capture.get(), CoreMatchers
367367
.startsWith("if ($1 == '_self') this.stopApplication();"));
368368
}
369+
370+
@Test
371+
public void setThemeVariant_setsAttribute() {
372+
AtomicReference<String> capturedExpression = new AtomicReference<>();
373+
AtomicReference<Object> capturedParam = new AtomicReference<>();
374+
MockUI mockUI = new MockUI();
375+
Page page = new Page(mockUI) {
376+
@Override
377+
public PendingJavaScriptResult executeJs(String expression,
378+
Object... parameters) {
379+
capturedExpression.set(expression);
380+
if (parameters.length > 0) {
381+
capturedParam.set(parameters[0]);
382+
}
383+
return Mockito.mock(PendingJavaScriptResult.class);
384+
}
385+
};
386+
387+
page.setThemeVariant("dark");
388+
389+
String js = capturedExpression.get();
390+
Assert.assertTrue(js.contains("setAttribute('theme', $0)"));
391+
Assert.assertTrue(
392+
js.contains("setProperty('--aura-color-scheme', $0)"));
393+
Assert.assertEquals("dark", capturedParam.get());
394+
Assert.assertEquals("dark", mockUI.getInternals().getThemeVariant());
395+
}
396+
397+
@Test
398+
public void setThemeVariant_null_removesAttribute() {
399+
MockUI mockUI = new MockUI();
400+
mockUI.getInternals().setThemeVariant("dark");
401+
402+
AtomicReference<String> capturedExpression = new AtomicReference<>();
403+
Page page = new Page(mockUI) {
404+
@Override
405+
public PendingJavaScriptResult executeJs(String expression,
406+
Object... parameters) {
407+
capturedExpression.set(expression);
408+
return Mockito.mock(PendingJavaScriptResult.class);
409+
}
410+
};
411+
412+
page.setThemeVariant(null);
413+
414+
String js = capturedExpression.get();
415+
Assert.assertTrue(js.contains("removeAttribute('theme')"));
416+
Assert.assertTrue(
417+
js.contains("removeProperty('--aura-color-scheme')"));
418+
Assert.assertEquals("", mockUI.getInternals().getThemeVariant());
419+
}
420+
421+
@Test
422+
public void setThemeVariant_emptyString_removesAttribute() {
423+
MockUI mockUI = new MockUI();
424+
mockUI.getInternals().setThemeVariant("dark");
425+
426+
AtomicReference<String> capturedExpression = new AtomicReference<>();
427+
Page page = new Page(mockUI) {
428+
@Override
429+
public PendingJavaScriptResult executeJs(String expression,
430+
Object... parameters) {
431+
capturedExpression.set(expression);
432+
return Mockito.mock(PendingJavaScriptResult.class);
433+
}
434+
};
435+
436+
page.setThemeVariant("");
437+
438+
String js = capturedExpression.get();
439+
Assert.assertTrue(js.contains("removeAttribute('theme')"));
440+
Assert.assertTrue(
441+
js.contains("removeProperty('--aura-color-scheme')"));
442+
Assert.assertEquals("", mockUI.getInternals().getThemeVariant());
443+
}
444+
445+
@Test
446+
public void getThemeVariant_returnsEmptyString_whenNotSet() {
447+
Page page = new Page(new MockUI());
448+
Assert.assertEquals("", page.getThemeVariant());
449+
}
450+
451+
@Test
452+
public void getThemeVariant_returnsCachedValue() {
453+
MockUI mockUI = new MockUI();
454+
mockUI.getInternals().setThemeVariant("dark");
455+
456+
Page page = new Page(mockUI);
457+
Assert.assertEquals("dark", page.getThemeVariant());
458+
}
459+
460+
@Test
461+
public void setThemeVariant_updatesCache() {
462+
MockUI mockUI = new MockUI();
463+
Page page = new Page(mockUI) {
464+
@Override
465+
public PendingJavaScriptResult executeJs(String expression,
466+
Object... parameters) {
467+
return Mockito.mock(PendingJavaScriptResult.class);
468+
}
469+
};
470+
471+
Assert.assertEquals("", page.getThemeVariant());
472+
473+
page.setThemeVariant("dark");
474+
Assert.assertEquals("dark", page.getThemeVariant());
475+
476+
page.setThemeVariant("light");
477+
Assert.assertEquals("light", page.getThemeVariant());
478+
479+
page.setThemeVariant(null);
480+
Assert.assertEquals("", page.getThemeVariant());
481+
}
369482
}

0 commit comments

Comments
 (0)