bootstrap-dark-5
Version:
The Ancillary Guide to Dark Mode and Bootstrap 5 - A continuation of the v4 Dark Mode POC
471 lines (437 loc) • 16.6 kB
text/typescript
/**
* *bootstrap-dark-5* `darkmode.js` -- the JavaScript module.
*
* ***class*** **DarkMode**
*
* Use this JS file, and its `darkmode` object, in your HTML to automatically capture `prefers-color-scheme` media query
* events and also initialize tags with the `data-bs-color-scheme` attribute, or the document root (`<HTML>` tag) with the
* user preferred color scheme.
*
* The `darkmode` object can also be used to drive a dark mode toggle event, with optional persistance
* storage in either a cookie (if GDPR consent is given) or the browsers `localStorage` object.
*
* The module can be loaded into a html page using a standard script command.
* ```html
* <script src="darkmode.js"></script>
* ```
*
* This will create a variable `darkmode` that is an instance of the DarkMode class.
*
* Once the DOM is loaded the script will then look for any html tag with a `data-bs-color-scheme` attribute, and, if found
* will use these tags to populate the current mode. If this data attribute is not found then the script will use the document
* root (`<HTML>` tag) with the class `dark` or `light`.
*
* For example, the `bootstrap-blackbox.css` variant requires the `<HTML>` to be initialized:
*
* ```html
* <!doctype html>
* <html lang="en" data-bs-color-scheme>
* <head>
* <!-- ... -->
* ```
* You can also pre-initialize the mode by populating the data attribute:
*
* ```html
* <html lang="en" data-bs-color-scheme="dark">
* ```
*
* @module DarkMode
* @_author Vino Rodrigues
*/
class DarkMode {
/** ***const*** -- Name of the cookie or localStorage->name when saving */
static readonly DATA_KEY = "bs.prefers-color-scheme"
//** ***const*** -- Data selector, when present in HTML will populate with `dark` or `light` as appropriate */
static readonly DATA_SELECTOR = "bs-color-scheme";
/** ***const*** -- String used to identify light mode *(do not change)*, @see https://www.w3.org/TR/mediaqueries-5/#prefers-color-scheme */
static readonly VALUE_LIGHT = "light"
/** ***const*** -- String used to identify dark mode *(do not change)*, @see https://www.w3.org/TR/mediaqueries-5/#prefers-color-scheme */
static readonly VALUE_DARK = "dark"
/** ***const*** -- String used to identify light mode as a class in the `<HTML>` tag */
static readonly CLASS_NAME_LIGHT = "light"
/** ***const*** -- String used to identify dark mode as a class in the `<HTML>` tag */
static readonly CLASS_NAME_DARK = "dark"
/**
* ***property***
*
* Used to get the current state, `true` when in dark mode, `false` when in light mode or when mode not set
*
* Can also be used to set the current mode *(with no persistance saving)*
*
* @example <caption>Get if page is in "Dark" mode</caption>
* var myVal = darkmode.inDarkMode;
*
* @example <caption>Set the page to the "Dark" mode</caption>
* darkmode.inDarkMode = true;
*
* @public
* @type {boolean}
*/
get inDarkMode() {
return DarkMode.getColorScheme() == DarkMode.VALUE_DARK
}
set inDarkMode(val: boolean) {
this.setDarkMode(val, false)
}
/** @private */
private _hasGDPRConsent = false
/**
* Variable to store GDPR Consent. This setting drives the persistance mechanism.
*
* Used in {@link #saveValue} to determine if a cookie or the `localStorage` object should be used.
* * Set to `true` when GDPR Consent has been given to enable storage to cookie *(useful in Server-Side knowledge of user preference)*
* * The setter takes care of swapping the cookie and localStorage if appropriate
* * Default is `false`, thus storage will use the browsers localStorage object *(Note: No expiry is set)*
*
* @example <caption>Set once GDPR consent is given by the user</caption>
* darkmode.hasGDPRConsent = true;
*/
get hasGDPRConsent() {
return this._hasGDPRConsent
}
set hasGDPRConsent(val: boolean) {
this._hasGDPRConsent = val
if (val) {
// delete cookie if it exists
const prior = DarkMode.readCookie(DarkMode.DATA_KEY)
if (prior) {
DarkMode.saveCookie(DarkMode.DATA_KEY, "", -1)
localStorage.setItem(DarkMode.DATA_KEY, prior)
}
} else {
// delete localStorage if it exists
const prior = localStorage.getItem(DarkMode.DATA_KEY)
if (prior) {
localStorage.removeItem(DarkMode.DATA_KEY)
DarkMode.saveCookie(DarkMode.DATA_KEY, prior)
}
}
}
/** Expiry time in days when saving and GDPR consent is give */
cookieExpiry = 365;
/**
* Saves the instance of the documentRoot (i.e. `<html>` tag) when the object is created.
*/
get documentRoot(): HTMLHtmlElement {
return document.getElementsByTagName("html")[0]
}
/**
* @constructor
* The constructor initializes the `darkmode` object (that should be used as a singleton).
*/
constructor() {
if (document.readyState === 'loading') {
document.addEventListener("DOMContentLoaded", function() {
DarkMode.onDOMContentLoaded()
})
} else {
DarkMode.onDOMContentLoaded()
}
}
/**
* Writes a cookie, assumes SameSite = Strict & path = /
*
* @private
* @param name -- Name of the cookie
* @param value -- Value to be saved
* @param days -- Number of days to expire the cookie
* @returns {void}
*/
static saveCookie(name: string, value = "", days = 365): void {
let exp = ""
if (days) {
const date = new Date()
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000))
exp = "; expires=" + date.toUTCString()
}
document.cookie = name + "=" + value + exp + "; SameSite=Strict; path=/"
}
/**
* Save the current color-scheme mode
*
* @param {string} name -- Name of the cookie or localStorage->name, is dependant on {@link #hasGDPRConsent}
* @param {string} value -- Should be one of `light` or `dark`
* @param {number} days -- Number of days to expire the cookie when the cookie is used, ignored for `localStorage`
* @returns {void}
*/
private saveValue(name: string, value: string, days = this.cookieExpiry): void {
if (this.hasGDPRConsent) {
// use cookies
DarkMode.saveCookie(name, value, days)
} else {
// use local storage
localStorage.setItem(name, value)
}
return
}
/**
* Reads a cookie
* @private
*/
static readCookie(name: string): string {
const n = name + "="
const parts = document.cookie.split(";")
for(let i=0; i < parts.length; i++) {
const part = parts[i].trim()
if (part.startsWith(n)) {
// found it
return part.substring(n.length)
}
}
return ""
}
/**
* Retrieves the color-scheme last saved
*
* **NOTE:** is dependant on {@link #hasGDPRConsent}
*
* @param {string} name -- Name of the cookie or localStorage->name
* @returns {string} -- The saved value, either `light` or `dark`, or an empty string if not saved prior
*/
readValue(name: string): string {
if (this.hasGDPRConsent) {
return DarkMode.readCookie(name)
} else {
const ret = localStorage.getItem(name)
return ret ? ret : ""
}
}
/**
* Deletes the saved color-scheme
*
* **NOTE:** is dependant on {@link #hasGDPRConsent}
*
* @param {string} name
* @returns {void} -- Nothing, erasure is assumed
*/
eraseValue(name: string): void {
if (this.hasGDPRConsent) {
this.saveValue(name, "", -1)
} else {
localStorage.removeItem(name)
}
}
/**
* Queries the `<HTML>` tag for the current color-scheme
*
* *(This value is set prior via the {@link #setDarkMode}) function.)*
*
* @returns {string} -- The current value, either `light` or `dark`, or an empty string if not saved prior
*/
getSavedColorScheme(): string {
const val = this.readValue(DarkMode.DATA_KEY)
return val ? val : ""
}
/**
* Queries the `prefers-color-scheme` media query for the current color-scheme
*
* *(This value is set prior via the {@link #setDarkMode}) function.)*
*
* @returns {string} -- The current value, either `light` or `dark`, or an empty string if the media query is not supported
*/
getPreferedColorScheme(): string {
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
return DarkMode.VALUE_DARK
} else if (window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches) {
return DarkMode.VALUE_LIGHT
} else {
return ""
}
}
/**
* Sets the color-scheme in the `<HTML>` tag as a class called either `light` or `dark`
*
* **Note:** This function will modify your document root element, i.e. the `<HTML>` tag
*
* Default behavior when setting dark mode `true`
*
* ```html
* <html lang="en" class="other-classes dark">
* <!-- Note: the "light" class is removed -->
* ```
*
* Default behavior when setting dark mode `false`
*
* ```html
* <html lang="en" class="other-classes light">
* <!-- Note: the "dark" class is removed -->
* ```
*
* Behavior when setting dark mode `true`, and `dataSelector = "data-bs-color-scheme"`
*
* ```html
* <html lang="en" data-bs-color-scheme="dark">
* ```
*
* Behavior when setting dark mode `false`, and `dataSelector = "data-bs-color-scheme"`
*
* ```html
* <html lang="en" data-bs-color-scheme="light">
* ```
*
* @example <caption>Set the color scheme to ***dark***, saving the state to the persistance mechanism</caption>
* document.querySelector("#darkmode-on-button").onclick = function(e){
* darkmode.setDarkMode(true); // save=true is default
* }
*
* @example <caption>Set the color scheme to ***light***, but not saving the state</caption>
* document.querySelector("#darkmode-off-button-no-save").onclick = function(e){
* darkmode.setDarkMode(false, false);
* }
*
* @param {boolean} darkMode -- `true` for "dark", `false` for 'light'
* @param {boolean} doSave -- If `true`, then will also call {@link #saveValue} to save that state
* @returns {void} -- Nothing, assumes saved
*/
setDarkMode(darkMode: boolean, doSave = true): void {
const nodeList = document.querySelectorAll("[data-"+DarkMode.DATA_SELECTOR+"]")
if (nodeList.length == 0) {
if (!darkMode) {
// light
this.documentRoot.classList.remove(DarkMode.CLASS_NAME_DARK)
this.documentRoot.classList.add(DarkMode.CLASS_NAME_LIGHT)
} else {
// dark
this.documentRoot.classList.remove(DarkMode.CLASS_NAME_LIGHT)
this.documentRoot.classList.add(DarkMode.CLASS_NAME_DARK)
}
} else {
for (let i = 0; i < nodeList.length; i++) {
nodeList[i].setAttribute("data-"+DarkMode.DATA_SELECTOR, darkMode ? DarkMode.VALUE_DARK : DarkMode.VALUE_LIGHT)
}
}
if (doSave) this.saveValue(DarkMode.DATA_KEY, darkMode ? DarkMode.VALUE_DARK : DarkMode.VALUE_LIGHT)
}
/**
* Toggles the color scheme in the `<HTML>` tag as a class called either `light` or `dark`
* based on the inverse of it's prior state.
*
* When {@link #dataSelector} is set, this is set in the given data selector as the data value.
*
* *(See {@link #setDarkMode})*
*
* @example <caption>Bind an UI Element `click` event to toggle dark mode</caption>
* document.querySelector("#darkmode-button").onclick = function(e){
* darkmode.toggleDarkMode();
* }
*
* @returns {void} - Nothing, assumes success
*/
toggleDarkMode(doSave = true): void {
let dm
const node = document.querySelector("[data-"+DarkMode.DATA_SELECTOR+"]") // only get first one
if (!node) {
dm = this.documentRoot.classList.contains(DarkMode.CLASS_NAME_DARK)
} else {
dm = node.getAttribute("data-"+DarkMode.DATA_SELECTOR) == DarkMode.VALUE_DARK
}
this.setDarkMode( !dm, doSave )
}
/**
* Clears the persistance state of the module and resets the document to the default mode.
*
* Calls {@link #eraseValue} to erase any saved value, and then
* calls {@link #getPreferedColorScheme} to retrieve the `prefers-color-scheme` media query,
* passing its value to {@link #setDarkMode} to reset the users preference.
*
* @example <caption>Bind a reset UI Element `click` event to reset the dark mode </caption>
* document.querySelector("#darkmode-forget").onclick = function(e){
* darkmode.resetDarkMode();
* }
*
* @returns {void} - Nothing, no error handling is performed.
*/
resetDarkMode(): void {
this.eraseValue(DarkMode.DATA_KEY)
const dm = this.getPreferedColorScheme()
if (dm) {
this.setDarkMode( dm == DarkMode.VALUE_DARK, false )
} else {
// make good when `prefers-color-scheme` not supported
const nodeList = document.querySelectorAll("[data-"+DarkMode.DATA_SELECTOR+"]")
if (nodeList.length == 0) {
this.documentRoot.classList.remove(DarkMode.CLASS_NAME_LIGHT)
this.documentRoot.classList.remove(DarkMode.CLASS_NAME_DARK)
} else {
for (let i = 0; i < nodeList.length; i++) {
nodeList[i].setAttribute("data-"+DarkMode.DATA_SELECTOR, "")
}
}
}
}
/**
* Gets the current color-scheme from the document `<HTML>` tag
*
* @returns {string} -- The current value, either `light` or `dark`, or an empty string if not present
*/
static getColorScheme(): string {
const node = document.querySelector("[data-"+DarkMode.DATA_SELECTOR+"]")
if (!node) {
if (darkmode.documentRoot.classList.contains(DarkMode.CLASS_NAME_DARK)) {
return DarkMode.VALUE_DARK
} else if (darkmode.documentRoot.classList.contains(DarkMode.CLASS_NAME_LIGHT)) {
return DarkMode.VALUE_LIGHT
} else {
return ""
}
} else {
const data = node.getAttribute("data-"+DarkMode.DATA_SELECTOR)
// exact match only
return ((data == DarkMode.VALUE_DARK) || (data == DarkMode.VALUE_LIGHT)) ? data : ""
}
}
/**
* ***static*** -- function called by the media query on change event.
*
* First retrieves any persistent/saved value, and if present ignores the event, but
* if not set then triggers the {@link #setDarkMode} function to change the current mode.
*
* @returns {void} -- Nothing, assumes success
*/
static updatePreferedColorSchemeEvent(): void {
let dm = darkmode.getSavedColorScheme()
if (!dm) {
dm = darkmode.getPreferedColorScheme()
if (dm) darkmode.setDarkMode( dm == DarkMode.VALUE_DARK, false )
}
}
/**
* ***static*** -- function called when the DOM finishes loading.
*
* Does all the DarkMode initialization, including:
* * Loading any prior stored preference (GDPR consent is ***not*** assumed)
* * else, honoring any `<HTML>` tag `class="dark|light"` that Server-Side may set
* * else, honoring the browser / OS `prefers-color-scheme` preference
* and setting the derived mode by calling {@link #setDarkMode}
*
* Followed by setting up the media query on change event
*
* ***Warning:*** This function is automatically called when loading this module.
*
* @returns {void}
*/
static onDOMContentLoaded(): void {
let pref = darkmode.readValue(DarkMode.DATA_KEY)
if (!pref) {
// user has not set pref. so get from `<HTML>` tag in case developer has set pref.
pref = DarkMode.getColorScheme()
if (!pref) {
// when all else fails, get pref. from OS/browser
pref = darkmode.getPreferedColorScheme()
}
}
const dm = (pref == DarkMode.VALUE_DARK)
// initialize the `HTML` tag
darkmode.setDarkMode(dm, false)
// update every time it changes
if (window.matchMedia) {
window.matchMedia("(prefers-color-scheme: dark)").addEventListener( "change", function() {
DarkMode.updatePreferedColorSchemeEvent()
})
}
}
}
/**
* ***const*** -- This is the global instance (object) of the DarkMode class.
*/
const darkmode = new DarkMode()