From 00a69108ddaee501882caf4bbf0ea98d8c5e658b Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Wed, 26 Nov 2025 10:23:41 -0800 Subject: [PATCH] ui: Implement a toggle menu for setting dark mode - Add a toggle menu for selecting the color scheme. JS code largely taken from Bootstrap documentation and slightly customized. - Use local storage to store the setting for dark/light/auto. Default to auto which means browser level preference is picked up (which could be system level preference). Tests: - Appearance of the toggle menu is consistent. Check box is shown on the currently selected value. - Deleting the local storage value reverts the preference to browser set value. - Menu is collapsed at smaller screen sizes. Appearance and functionality as expected. Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- debian/copyright | 5 ++ plinth/templates/base.html | 5 ++ plinth/templates/theme-menu.html | 46 ++++++++++++ static/themes/default/css/main.css | 4 ++ static/themes/default/js/color-modes.js | 96 +++++++++++++++++++++++++ 5 files changed, 156 insertions(+) create mode 100644 plinth/templates/theme-menu.html create mode 100644 static/themes/default/js/color-modes.js diff --git a/debian/copyright b/debian/copyright index 2d583c133..8e37e5817 100644 --- a/debian/copyright +++ b/debian/copyright @@ -38,6 +38,11 @@ Copyright: Marie Van den Broeck (https://thenounproject.com/marie49/) Comment: https://thenounproject.com/icon/162372/ License: CC-BY-SA-3.0 +Files: static/themes/default/js/color-modes.js +Copyright: 2011-2025 The Bootstrap Authors +Comment: https://getbootstrap.com/docs/5.3/customize/color-modes/ +License: CC-BY-3.0 + Files: plinth/modules/bepasty/static/icons/bepasty.svg Copyright: (c) 2014 by the Bepasty Team, see the AUTHORS file. Comment: https://github.com/bepasty/bepasty-server/blob/master/src/bepasty/static/app/bepasty.svg diff --git a/plinth/templates/base.html b/plinth/templates/base.html index 2a074b5a8..b727f68fb 100644 --- a/plinth/templates/base.html +++ b/plinth/templates/base.html @@ -58,6 +58,9 @@ {% endif %} + + @@ -148,6 +151,8 @@ {% include "notifications-dropdown.html" %} + {% include "theme-menu.html" %} + {% include "help-menu.html" %} diff --git a/static/themes/default/css/main.css b/static/themes/default/css/main.css index b8bb67ff6..d5e8adc5c 100644 --- a/static/themes/default/css/main.css +++ b/static/themes/default/css/main.css @@ -522,6 +522,10 @@ footer { border-bottom: var(--freedombox-navbar-color) 3px solid; } +.main-header .dropdown-menu .active .fa-check { + display: block !important; +} + /* Breadcrumbs */ .breadcrumb-item { --bs-breadcrumb-divider: ">"; diff --git a/static/themes/default/js/color-modes.js b/static/themes/default/js/color-modes.js new file mode 100644 index 000000000..efef6dbf3 --- /dev/null +++ b/static/themes/default/js/color-modes.js @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: CC-BY-3.0 +/* + This file is part of FreedomBox. Color mode toggler for Bootstrap's docs + (https://getbootstrap.com/). Copyright 2011-2025 The Bootstrap Authors. + + @licstart The following is the entire license notice for the + JavaScript code in this page. + + Licensed under the Creative Commons Attribution 3.0 Unported License. + + @licend The above is the entire license notice + for the JavaScript code in this page. +*/ + +(() => { + 'use strict'; + + const getStoredTheme = () => localStorage.getItem('theme'); + const setStoredTheme = theme => localStorage.setItem('theme', theme); + + const getBrowserTheme = () => { + return window.matchMedia('(prefers-color-scheme: dark)') + .matches ? 'dark' : 'light'; + }; + + const getPreferredTheme = () => { + const storedTheme = getStoredTheme(); + if (storedTheme) { + return storedTheme; + } + + return getBrowserTheme(); + }; + + const setTheme = (theme) => { + if (theme === 'auto') { + theme = getBrowserTheme(); + } + document.documentElement.setAttribute('data-bs-theme', theme); + }; + + setTheme(getPreferredTheme()); + + const showActiveTheme = (theme, focus = false) => { + const themeSwitcher = document.querySelector('#id_theme_menu_link'); + + if (!themeSwitcher) { + return; + } + + const themeSwitcherText = document.querySelector('#id_toggle_theme_text'); + const activeThemeIcon = document.querySelector('#id_active_theme_icon'); + const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`); + const iconOfActiveBtn = btnToActive.dataset.bsIconValue; + + document.querySelectorAll('[data-bs-theme-value]').forEach(element => { + element.classList.remove('active'); + element.setAttribute('aria-pressed', 'false'); + const iconOfBtn = element.dataset.bsIconValue; + if (activeThemeIcon.classList.contains(iconOfBtn)) { + activeThemeIcon.classList.remove(iconOfBtn); + } + }); + + btnToActive.classList.add('active'); + btnToActive.setAttribute('aria-pressed', 'true'); + activeThemeIcon.classList.add(iconOfActiveBtn); + const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`; + themeSwitcher.setAttribute('title', themeSwitcherLabel); + + if (focus) { + themeSwitcher.focus(); + }; + }; + + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + const storedTheme = getStoredTheme(); + if (storedTheme !== 'light' && storedTheme !== 'dark') { + setTheme(getPreferredTheme()); + } + }); + + window.addEventListener('DOMContentLoaded', () => { + showActiveTheme(getPreferredTheme()); + + document.querySelectorAll('[data-bs-theme-value]') + .forEach(toggle => { + toggle.addEventListener('click', () => { + const theme = toggle.getAttribute('data-bs-theme-value'); + setStoredTheme(theme); + setTheme(theme); + showActiveTheme(theme, true); + }); + }); + }); +})();