UNPKG

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
/** * *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()