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 <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2025-11-26 10:23:41 -08:00 committed by James Valleroy
parent 0419eb02cf
commit 00a69108dd
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
5 changed files with 156 additions and 0 deletions

5
debian/copyright vendored
View File

@ -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

View File

@ -58,6 +58,9 @@
<link rel="stylesheet" href="{% static user_css %}"/>
{% endif %}
<!-- This script is not loaded in defer mode because it needs to run before
page is rendered -->
<script type="text/javascript" src="{% static 'theme/js/color-modes.js' %}"></script>
<!-- Local link to system Bootstrap JS -->
<script type="text/javascript" src="{% static '/javascript/popperjs2/popper.min.js' %}" defer></script>
<script type="text/javascript" src="{% static '/javascript/bootstrap5/js/bootstrap.bundle.min.js' %}" defer></script>
@ -148,6 +151,8 @@
{% include "notifications-dropdown.html" %}
{% include "theme-menu.html" %}
{% include "help-menu.html" %}
<li id="id_user_menu" class="nav-item dropdown">

View File

@ -0,0 +1,46 @@
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load i18n %}
<li id="id_theme_menu" class="dropdown nav-item">
<a href="#" title="{% trans "Toggle theme (auto)" %}"
class="nav-link dropdown-toggle" data-bs-toggle="dropdown"
role="button" aria-expanded="false" aria-haspopup="true"
id="id_theme_menu_link">
<span class="fa fa-adjust nav-icon" id="id_active_theme_icon"></span>
<span class="nav-text d-md-none" id="id_toggle_theme_text">
{% trans "Toggle theme" %}
</span>
</a>
<ul class="dropdown-menu" role="menu" aria-labelledby="id_theme_menu_link">
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
data-bs-theme-value="light" data-bs-icon-value="fa-sun"
aria-pressed="false">
<span class="fa fa-sun nav-icon me-2"></span>
{% trans "Light" %}
<span class="fa fa-check nav-icon ms-auto d-none"></span>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
data-bs-theme-value="dark" data-bs-icon-value="fa-moon"
aria-pressed="false">
<span class="fa fa-moon nav-icon me-2"></span>
{% trans "Dark" %}
<span class="fa fa-check nav-icon ms-auto d-none"></span>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
data-bs-theme-value="auto" data-bs-icon-value="fa-adjust"
aria-pressed="true">
<span class="fa fa-adjust nav-icon me-2"></span>
{% trans "Auto" %}
<span class="fa fa-check nav-icon ms-auto d-none"></span>
</button>
</li>
</ul>
</li>

View File

@ -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: ">";

View File

@ -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);
});
});
});
})();