The navbar button switches between light and dark mode and persists the preference in localStorage.
+diff --git a/package.json b/package.json index 6ec1d3f..89bc245 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "A simple CSV parser - BountyPay workflow demo", "main": "src/parser.js", "scripts": { - "test": "node test/parser.test.js" + "test": "node test/parser.test.js && node test/theme-toggle.test.js" }, "license": "MIT" } diff --git a/src/theme-toggle.js b/src/theme-toggle.js new file mode 100644 index 0000000..3f4791f --- /dev/null +++ b/src/theme-toggle.js @@ -0,0 +1,236 @@ +const THEME_STORAGE_KEY = "theme-preference"; +const THEME_TRANSITION_STYLE_ID = "theme-toggle-transition-styles"; +const LIGHT_THEME = "light"; +const DARK_THEME = "dark"; +const VALID_THEMES = new Set([LIGHT_THEME, DARK_THEME]); + +function normalizeTheme(theme) { + return VALID_THEMES.has(theme) ? theme : null; +} + +function readStoredTheme(storage = globalThis.localStorage) { + if (!storage || typeof storage.getItem !== "function") { + return null; + } + + return normalizeTheme(storage.getItem(THEME_STORAGE_KEY)); +} + +function writeStoredTheme(theme, storage = globalThis.localStorage) { + const normalized = normalizeTheme(theme); + if (!normalized) { + throw new Error(`Unsupported theme: ${theme}`); + } + + if (storage && typeof storage.setItem === "function") { + storage.setItem(THEME_STORAGE_KEY, normalized); + } + + return normalized; +} + +function getInitialTheme({ + storage = globalThis.localStorage, + matchMedia = globalThis.matchMedia, +} = {}) { + const stored = readStoredTheme(storage); + if (stored) { + return stored; + } + + if ( + typeof matchMedia === "function" && + matchMedia("(prefers-color-scheme: dark)")?.matches + ) { + return DARK_THEME; + } + + return LIGHT_THEME; +} + +function applyTheme(theme, documentRef = globalThis.document) { + const normalized = normalizeTheme(theme); + if (!normalized) { + throw new Error(`Unsupported theme: ${theme}`); + } + + if (documentRef?.documentElement) { + documentRef.documentElement.dataset.theme = normalized; + documentRef.documentElement.style.colorScheme = normalized; + + const classList = documentRef.documentElement.classList; + if (classList) { + classList.toggle(DARK_THEME, normalized === DARK_THEME); + classList.toggle(LIGHT_THEME, normalized === LIGHT_THEME); + } + } + + return normalized; +} + +function buildThemeTransitionCss() { + return ` +:root { + color-scheme: light; + background-color: #ffffff; + color: #111827; +} + +:root[data-theme="dark"] { + color-scheme: dark; + background-color: #111827; + color: #f9fafb; +} + +:root[data-theme="light"] { + color-scheme: light; + background-color: #ffffff; + color: #111827; +} + +body, +.theme-toggle, +[data-theme-navbar] { + transition: + background-color 180ms ease, + border-color 180ms ease, + color 180ms ease; +} + +.theme-toggle { + border: 1px solid currentColor; + border-radius: 999px; + cursor: pointer; + padding: 0.4rem 0.75rem; +} +`.trim(); +} + +function ensureThemeTransitionStyles(documentRef = globalThis.document) { + if (!documentRef?.createElement || !documentRef?.head) { + return null; + } + + const existing = documentRef.getElementById?.(THEME_TRANSITION_STYLE_ID); + if (existing) { + return existing; + } + + const style = documentRef.createElement("style"); + style.id = THEME_TRANSITION_STYLE_ID; + style.textContent = buildThemeTransitionCss(); + documentRef.head.appendChild(style); + return style; +} + +function nextTheme(theme) { + return normalizeTheme(theme) === DARK_THEME ? LIGHT_THEME : DARK_THEME; +} + +function setTheme(theme, options = {}) { + const normalized = writeStoredTheme(theme, options.storage); + applyTheme(normalized, options.document); + return normalized; +} + +function toggleTheme(options = {}) { + const current = + normalizeTheme(options.currentTheme) ?? + readStoredTheme(options.storage) ?? + options.document?.documentElement?.dataset?.theme ?? + getInitialTheme(options); + + return setTheme(nextTheme(current), options); +} + +function updateToggleButton(button, theme) { + const normalized = normalizeTheme(theme) ?? LIGHT_THEME; + button.textContent = normalized === DARK_THEME ? "Light mode" : "Dark mode"; + button.setAttribute("aria-label", `Switch to ${nextTheme(normalized)} mode`); + button.setAttribute("aria-pressed", String(normalized === DARK_THEME)); +} + +function createThemeToggleButton(options = {}) { + const documentRef = options.document ?? globalThis.document; + if (!documentRef?.createElement) { + throw new Error("A DOM document is required to create a theme toggle button"); + } + + const button = documentRef.createElement("button"); + button.type = "button"; + button.className = "theme-toggle"; + button.dataset.themeToggle = "true"; + + const initialTheme = setTheme(getInitialTheme(options), options); + updateToggleButton(button, initialTheme); + + button.addEventListener("click", () => { + const theme = toggleTheme({ + ...options, + currentTheme: documentRef.documentElement?.dataset?.theme, + }); + updateToggleButton(button, theme); + }); + + return button; +} + +function mountThemeToggle(navbar, options = {}) { + if (!navbar || typeof navbar.appendChild !== "function") { + throw new Error("A navbar element is required to mount the theme toggle"); + } + + ensureThemeTransitionStyles(options.document); + navbar.dataset.themeNavbar = "true"; + + const button = createThemeToggleButton(options); + navbar.appendChild(button); + return button; +} + +function installNavbarThemeToggle(options = {}) { + const documentRef = options.document ?? globalThis.document; + const selector = options.navbarSelector ?? "nav, [role='navigation'], [data-theme-navbar]"; + const navbar = options.navbar ?? documentRef?.querySelector?.(selector); + + if (!navbar) { + throw new Error("No navbar element found for theme toggle installation"); + } + + const existing = navbar.querySelector?.("[data-theme-toggle='true']"); + if (existing) { + return existing; + } + + return mountThemeToggle(navbar, { + ...options, + document: documentRef, + }); +} + +const themeToggleApi = { + DARK_THEME, + LIGHT_THEME, + THEME_STORAGE_KEY, + THEME_TRANSITION_STYLE_ID, + applyTheme, + buildThemeTransitionCss, + createThemeToggleButton, + ensureThemeTransitionStyles, + getInitialTheme, + installNavbarThemeToggle, + mountThemeToggle, + nextTheme, + readStoredTheme, + setTheme, + toggleTheme, + writeStoredTheme, +}; + +if (typeof module !== "undefined" && module.exports) { + module.exports = themeToggleApi; +} + +if (typeof window !== "undefined") { + window.ThemeToggle = themeToggleApi; +} diff --git a/test/theme-toggle.test.js b/test/theme-toggle.test.js new file mode 100644 index 0000000..ad39fcb --- /dev/null +++ b/test/theme-toggle.test.js @@ -0,0 +1,217 @@ +const { + DARK_THEME, + LIGHT_THEME, + THEME_STORAGE_KEY, + THEME_TRANSITION_STYLE_ID, + applyTheme, + buildThemeTransitionCss, + createThemeToggleButton, + ensureThemeTransitionStyles, + getInitialTheme, + installNavbarThemeToggle, + mountThemeToggle, + nextTheme, + readStoredTheme, + toggleTheme, +} = require("../src/theme-toggle"); + +let passed = 0; +let failed = 0; + +function assert(name, actual, expected) { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a === e) { + console.log(` OK ${name}`); + passed += 1; + } else { + console.log(` FAIL ${name}`); + console.log(` Expected: ${e}`); + console.log(` Actual: ${a}`); + failed += 1; + } +} + +function createStorage(initial = {}) { + const values = { ...initial }; + return { + getItem(key) { + return Object.prototype.hasOwnProperty.call(values, key) ? values[key] : null; + }, + setItem(key, value) { + values[key] = String(value); + }, + values, + }; +} + +function createClassList() { + const values = new Set(); + return { + contains(value) { + return values.has(value); + }, + toggle(value, force) { + if (force) { + values.add(value); + } else { + values.delete(value); + } + }, + values, + }; +} + +function createDocument() { + const documentElement = { + classList: createClassList(), + dataset: {}, + style: {}, + }; + const elementsById = {}; + + const head = { + children: [], + appendChild(child) { + this.children.push(child); + if (child.id) { + elementsById[child.id] = child; + } + }, + }; + + return { + documentElement, + head, + createElement(tagName) { + const listeners = {}; + return { + tagName, + attributes: {}, + children: [], + className: "", + dataset: {}, + id: "", + textContent: "", + type: "", + addEventListener(event, callback) { + listeners[event] = callback; + }, + appendChild(child) { + this.children.push(child); + }, + click() { + listeners.click?.(); + }, + setAttribute(name, value) { + this.attributes[name] = String(value); + }, + querySelector(selector) { + if (selector === "[data-theme-toggle='true']") { + return this.children.find((child) => child.dataset?.themeToggle === "true") || null; + } + + return null; + }, + }; + }, + getElementById(id) { + return elementsById[id] || null; + }, + querySelector(selector) { + if (selector === "nav, [role='navigation'], [data-theme-navbar]") { + return this.navbar || null; + } + + return null; + }, + }; +} + +console.log("\nTheme Toggle Tests\n"); + +assert("uses stored theme first", getInitialTheme({ + storage: createStorage({ [THEME_STORAGE_KEY]: DARK_THEME }), +}), DARK_THEME); + +assert("falls back to system dark preference", getInitialTheme({ + storage: createStorage(), + matchMedia: () => ({ matches: true }), +}), DARK_THEME); + +assert("defaults to light theme", getInitialTheme({ + storage: createStorage(), + matchMedia: () => ({ matches: false }), +}), LIGHT_THEME); + +assert("ignores invalid stored theme", readStoredTheme( + createStorage({ [THEME_STORAGE_KEY]: "blue" }), +), null); + +assert("next theme alternates from dark to light", nextTheme(DARK_THEME), LIGHT_THEME); +assert("next theme alternates from light to dark", nextTheme(LIGHT_THEME), DARK_THEME); + +{ + const documentRef = createDocument(); + applyTheme(DARK_THEME, documentRef); + assert("sets document data theme", documentRef.documentElement.dataset.theme, DARK_THEME); + assert("sets color scheme", documentRef.documentElement.style.colorScheme, DARK_THEME); + assert("adds dark class", documentRef.documentElement.classList.contains(DARK_THEME), true); + assert("does not keep light class", documentRef.documentElement.classList.contains(LIGHT_THEME), false); +} + +{ + const storage = createStorage({ [THEME_STORAGE_KEY]: LIGHT_THEME }); + const documentRef = createDocument(); + const theme = toggleTheme({ document: documentRef, storage }); + assert("toggle returns dark theme", theme, DARK_THEME); + assert("toggle persists theme", storage.values[THEME_STORAGE_KEY], DARK_THEME); + assert("toggle applies theme", documentRef.documentElement.dataset.theme, DARK_THEME); +} + +{ + const storage = createStorage(); + const documentRef = createDocument(); + const button = createThemeToggleButton({ document: documentRef, storage }); + assert("button is visible text for dark mode", button.textContent, "Dark mode"); + assert("button has accessible label", button.attributes["aria-label"], "Switch to dark mode"); + button.click(); + assert("click changes text to light mode", button.textContent, "Light mode"); + assert("click persists dark mode", storage.values[THEME_STORAGE_KEY], DARK_THEME); +} + +{ + const storage = createStorage(); + const documentRef = createDocument(); + const navbar = documentRef.createElement("nav"); + const button = mountThemeToggle(navbar, { document: documentRef, storage }); + assert("mount appends one toggle button", navbar.children.length, 1); + assert("mounted child is returned button", navbar.children[0] === button, true); + assert("button is marked as theme toggle", button.dataset.themeToggle, "true"); +} + +{ + const documentRef = createDocument(); + const css = buildThemeTransitionCss(); + const style = ensureThemeTransitionStyles(documentRef); + const again = ensureThemeTransitionStyles(documentRef); + assert("transition css includes easing", css.includes("180ms ease"), true); + assert("style element is added once", documentRef.head.children.length, 1); + assert("style element has stable id", style.id, THEME_TRANSITION_STYLE_ID); + assert("style injection is idempotent", again === style, true); +} + +{ + const storage = createStorage(); + const documentRef = createDocument(); + const navbar = documentRef.createElement("nav"); + documentRef.navbar = navbar; + const button = installNavbarThemeToggle({ document: documentRef, storage }); + const again = installNavbarThemeToggle({ document: documentRef, storage }); + assert("installer mounts button in navbar", navbar.children.length, 1); + assert("installer returns same existing button", again === button, true); + assert("installer marks navbar for transitions", navbar.dataset.themeNavbar, "true"); +} + +console.log(`\nTheme toggle results: ${passed} passed, ${failed} failed\n`); +process.exit(failed > 0 ? 1 : 0); diff --git a/theme-demo.html b/theme-demo.html new file mode 100644 index 0000000..26ea9f5 --- /dev/null +++ b/theme-demo.html @@ -0,0 +1,39 @@ + + +
+ + +The navbar button switches between light and dark mode and persists the preference in localStorage.
+