UNPKG

dark-reader-aware-theme-toggle-button

Version:

Dark mode toggle button that detects the Dark Reader extension and steps aside

1 lines 9.72 kB
{"version":3,"sources":["../src/index.ts","../src/is-dark-reader-active.ts","../src/watch-dark-reader.ts","../src/theme.ts","../src/mount-theme-toggle-button.ts"],"sourcesContent":["export { isDarkReaderActive } from './is-dark-reader-active.js';\nexport { watchDarkReader } from './watch-dark-reader.js';\nexport {\n getTheme,\n setTheme,\n getCurrentTheme,\n toggleTheme,\n initThemeEarly,\n type Theme,\n type ThemeOptions,\n} from './theme.js';\nexport {\n mountThemeToggleButton,\n type MountOptions,\n} from './mount-theme-toggle-button.js';\n","/**\n * Returns true when the Dark Reader browser extension is actively modifying\n * the page. Checks all known signals across dynamic, filter, and static modes.\n */\nexport function isDarkReaderActive(doc: Document = document): boolean {\n const html = doc.documentElement;\n return (\n html.hasAttribute('data-darkreader-scheme') ||\n html.hasAttribute('data-darkreader-mode') ||\n doc.querySelector('meta[name=\"darkreader\"]') != null ||\n doc.getElementById('dark-reader-style') != null\n );\n}\n","import { isDarkReaderActive } from './is-dark-reader-active.js';\n\n/**\n * Watch for Dark Reader being enabled or disabled. Calls `onChange` immediately\n * and whenever DR signals appear or disappear. Returns an unsubscribe function.\n */\nexport function watchDarkReader(\n onChange: (active: boolean) => void,\n doc: Document = document,\n): () => void {\n const html = doc.documentElement;\n const check = () => onChange(isDarkReaderActive(doc));\n\n check();\n\n const htmlObserver = new MutationObserver(check);\n htmlObserver.observe(html, {\n attributes: true,\n attributeFilter: ['data-darkreader-scheme', 'data-darkreader-mode'],\n });\n\n let headObserver: MutationObserver | null = null;\n if (doc.head) {\n headObserver = new MutationObserver(check);\n headObserver.observe(doc.head, { childList: true, subtree: true });\n }\n\n return () => {\n htmlObserver.disconnect();\n headObserver?.disconnect();\n };\n}\n","export type Theme = 'light' | 'dark';\n\nexport interface ThemeOptions {\n /** localStorage key. Default: `theme` */\n storageKey?: string;\n /** Attribute on `<html>`. Default: `data-theme` */\n attribute?: string;\n /** Attribute value for dark mode. Default: `dark` */\n darkValue?: string;\n /** Use prefers-color-scheme when no saved preference exists. Default: false */\n respectSystemPreference?: boolean;\n}\n\nfunction getStorage(doc: Document): Storage | null {\n return doc.defaultView?.localStorage ?? null;\n}\n\nfunction prefersDark(doc: Document): boolean {\n return doc.defaultView?.matchMedia('(prefers-color-scheme: dark)').matches ?? false;\n}\n\n/** Read saved theme, optionally falling back to system preference. */\nexport function getTheme(options: ThemeOptions = {}, doc: Document = document): Theme {\n const { storageKey = 'theme', respectSystemPreference = false } = options;\n const saved = getStorage(doc)?.getItem(storageKey);\n if (saved === 'dark' || saved === 'light') return saved;\n if (respectSystemPreference && prefersDark(doc)) return 'dark';\n return 'light';\n}\n\n/** Apply theme to `<html>` and persist to localStorage. */\nexport function setTheme(theme: Theme, options: ThemeOptions = {}, doc: Document = document): void {\n const { storageKey = 'theme', attribute = 'data-theme', darkValue = 'dark' } = options;\n const html = doc.documentElement;\n if (theme === 'dark') {\n html.setAttribute(attribute, darkValue);\n } else {\n html.removeAttribute(attribute);\n }\n getStorage(doc)?.setItem(storageKey, theme);\n}\n\n/** Read current theme from the DOM attribute (not localStorage). */\nexport function getCurrentTheme(options: ThemeOptions = {}, doc: Document = document): Theme {\n const { attribute = 'data-theme', darkValue = 'dark' } = options;\n return doc.documentElement.getAttribute(attribute) === darkValue ? 'dark' : 'light';\n}\n\n/** Toggle theme; returns the new value. */\nexport function toggleTheme(options: ThemeOptions = {}, doc: Document = document): Theme {\n const next: Theme = getCurrentTheme(options, doc) === 'dark' ? 'light' : 'dark';\n setTheme(next, options, doc);\n return next;\n}\n\n/** Apply saved theme before first paint to avoid flash. Call from an inline `<head>` script. */\nexport function initThemeEarly(options: ThemeOptions = {}, doc: Document = document): Theme {\n const theme = getTheme(options, doc);\n setTheme(theme, options, doc);\n return theme;\n}\n","import { isDarkReaderActive } from './is-dark-reader-active.js';\nimport { watchDarkReader } from './watch-dark-reader.js';\nimport {\n getTheme,\n setTheme,\n toggleTheme,\n type Theme,\n type ThemeOptions,\n} from './theme.js';\n\nexport interface MountOptions extends ThemeOptions {\n /** Button element or CSS selector */\n button: string | HTMLElement;\n /** Label element or CSS selector (optional; falls back to button textContent) */\n label?: string | HTMLElement;\n /** Button label text in each theme state */\n labels?: { light: string; dark: string };\n /** Hide the button while Dark Reader is active. Default: true */\n hideWhenDarkReaderActive?: boolean;\n /** Called after initial mount and on each toggle */\n onThemeChange?: (theme: Theme) => void;\n}\n\nfunction resolveElement(ref: string | HTMLElement, doc: Document): HTMLElement | null {\n return typeof ref === 'string' ? doc.querySelector<HTMLElement>(ref) : ref;\n}\n\n/**\n * Wire a button to toggle `data-theme` on `<html>`, persist preference, update\n * label text, and hide the button when Dark Reader is active.\n * Returns an unmount function.\n */\nexport function mountThemeToggleButton(\n options: MountOptions,\n doc: Document = document,\n): () => void {\n const button = resolveElement(options.button, doc);\n if (!button) {\n throw new Error('mountThemeToggleButton: button element not found');\n }\n\n const labelEl = options.label ? resolveElement(options.label, doc) : null;\n const labels = options.labels ?? { light: 'Dark', dark: 'Light' };\n const hideWhenDR = options.hideWhenDarkReaderActive !== false;\n\n const updateLabel = (theme: Theme) => {\n const text = theme === 'dark' ? labels.dark : labels.light;\n if (labelEl) {\n labelEl.textContent = text;\n } else {\n button.textContent = text;\n }\n };\n\n const theme = getTheme(options, doc);\n setTheme(theme, options, doc);\n updateLabel(theme);\n options.onThemeChange?.(theme);\n\n const onClick = () => {\n const next = toggleTheme(options, doc);\n updateLabel(next);\n options.onThemeChange?.(next);\n };\n button.addEventListener('click', onClick);\n\n let unwatchDR: (() => void) | undefined;\n if (hideWhenDR) {\n unwatchDR = watchDarkReader((active) => {\n button.style.visibility = active ? 'hidden' : '';\n }, doc);\n }\n\n return () => {\n button.removeEventListener('click', onClick);\n unwatchDR?.();\n };\n}\n\nexport { isDarkReaderActive, watchDarkReader, getTheme, setTheme, toggleTheme };\nexport type { Theme, ThemeOptions };\n"],"mappings":"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","names":["theme"]}