From d945dce5fb2ef716e8d8ea65efe4755117bcffd4 Mon Sep 17 00:00:00 2001 From: tristanwork Date: Sun, 31 May 2026 09:07:09 +0800 Subject: [PATCH 1/4] Run theme toggle tests --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" } From 01af43bf0eedaf94f5d848fcfd8ba9b2354d888e Mon Sep 17 00:00:00 2001 From: tristanwork Date: Sun, 31 May 2026 09:07:27 +0800 Subject: [PATCH 2/4] Add persisted theme toggle helper --- src/theme-toggle.js | 145 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 src/theme-toggle.js diff --git a/src/theme-toggle.js b/src/theme-toggle.js new file mode 100644 index 0000000..03fca59 --- /dev/null +++ b/src/theme-toggle.js @@ -0,0 +1,145 @@ +const THEME_STORAGE_KEY = "theme-preference"; +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 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"); + } + + const button = createThemeToggleButton(options); + navbar.appendChild(button); + return button; +} + +module.exports = { + DARK_THEME, + LIGHT_THEME, + THEME_STORAGE_KEY, + applyTheme, + createThemeToggleButton, + getInitialTheme, + mountThemeToggle, + nextTheme, + readStoredTheme, + setTheme, + toggleTheme, + writeStoredTheme, +}; From 5a53b6e29c02f5adb71b3e91e6ac9a13aca17d87 Mon Sep 17 00:00:00 2001 From: tristanwork Date: Sun, 31 May 2026 09:07:45 +0800 Subject: [PATCH 3/4] Test persisted theme toggle helper --- test/theme-toggle.test.js | 160 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 test/theme-toggle.test.js diff --git a/test/theme-toggle.test.js b/test/theme-toggle.test.js new file mode 100644 index 0000000..7764ea3 --- /dev/null +++ b/test/theme-toggle.test.js @@ -0,0 +1,160 @@ +const { + DARK_THEME, + LIGHT_THEME, + THEME_STORAGE_KEY, + applyTheme, + createThemeToggleButton, + getInitialTheme, + 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: {}, + }; + + return { + documentElement, + createElement(tagName) { + const listeners = {}; + return { + tagName, + attributes: {}, + children: [], + className: "", + dataset: {}, + 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); + }, + }; + }, + }; +} + +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"); +} + +console.log(`\nTheme toggle results: ${passed} passed, ${failed} failed\n`); +process.exit(failed > 0 ? 1 : 0); From b4ad3fc9fc1a52c3fbab27e724404c8092cea759 Mon Sep 17 00:00:00 2001 From: Tristan Tang Date: Sun, 31 May 2026 09:31:50 +0800 Subject: [PATCH 4/4] Add navbar theme toggle demo --- src/theme-toggle.js | 93 ++++++++++++++++++++++++++++++++++++++- test/theme-toggle.test.js | 57 ++++++++++++++++++++++++ theme-demo.html | 39 ++++++++++++++++ 3 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 theme-demo.html diff --git a/src/theme-toggle.js b/src/theme-toggle.js index 03fca59..3f4791f 100644 --- a/src/theme-toggle.js +++ b/src/theme-toggle.js @@ -1,4 +1,5 @@ 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]); @@ -67,6 +68,61 @@ function applyTheme(theme, documentRef = globalThis.document) { 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; } @@ -124,18 +180,45 @@ function mountThemeToggle(navbar, options = {}) { 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; } -module.exports = { +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, @@ -143,3 +226,11 @@ module.exports = { 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 index 7764ea3..ad39fcb 100644 --- a/test/theme-toggle.test.js +++ b/test/theme-toggle.test.js @@ -2,9 +2,13 @@ const { DARK_THEME, LIGHT_THEME, THEME_STORAGE_KEY, + THEME_TRANSITION_STYLE_ID, applyTheme, + buildThemeTransitionCss, createThemeToggleButton, + ensureThemeTransitionStyles, getInitialTheme, + installNavbarThemeToggle, mountThemeToggle, nextTheme, readStoredTheme, @@ -64,9 +68,21 @@ function createDocument() { 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 { @@ -75,6 +91,7 @@ function createDocument() { children: [], className: "", dataset: {}, + id: "", textContent: "", type: "", addEventListener(event, callback) { @@ -89,8 +106,25 @@ function createDocument() { 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; + }, }; } @@ -156,5 +190,28 @@ assert("next theme alternates from light to dark", nextTheme(LIGHT_THEME), DARK_ 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 @@ + + + + + + Theme Toggle Demo + + + + +
+

The navbar button switches between light and dark mode and persists the preference in localStorage.

+
+ + + +