/**
* --------------------------------------------
* @file AdminLTE color-mode.ts
* @description Color mode (light/dark/auto) switcher for AdminLTE.
* Resolves the theme from, in order: the visitor's stored choice, the theme
* the page itself declared in , and finally the OS
* preference. Keeps [data-bs-theme-value] toggles and [data-lte-theme-icon]
* indicator icons in sync.
*
* Ships in the bundle so applications no longer need to copy the demo's
* inline script. The tiny no-flash snippet in
(see _head.astro)
* remains inline by design — it must run before first paint. That snippet
* flags the values it computes itself with [data-lte-theme-resolved], so a
* theme authored in the markup can be told apart from one it resolved.
*
* Applications with their own theming opt out entirely with
* .
* @license MIT
* --------------------------------------------
*/
import { getLifecycleSignal, onDOMContentLoaded } from './util/index'
/**
* Constants
* ====================================================
*/
const DATA_KEY = 'lte.color-mode'
const EVENT_KEY = `.${DATA_KEY}`
const EVENT_CHANGED = `changed${EVENT_KEY}`
const STORAGE_KEY = 'lte-theme'
const ATTRIBUTE_THEME = 'data-bs-theme'
const ATTRIBUTE_TOGGLE = 'data-bs-theme-value'
const ATTRIBUTE_DISABLED = 'data-lte-color-mode'
const ATTRIBUTE_RESOLVED = 'data-lte-theme-resolved'
const SELECTOR_TOGGLE = `[${ATTRIBUTE_TOGGLE}]`
const SELECTOR_ICON = '[data-lte-theme-icon]'
type Theme = 'light' | 'dark' | 'auto'
const THEMES = new Set(['light', 'dark', 'auto'])
const isValidTheme = (value: string): value is Theme => THEMES.has(value)
/**
* Applications with their own theming take over by adding
* `data-lte-color-mode="off"` to : ColorMode then never writes
* `data-bs-theme` — not on load, not on a toggle click, not when the OS
* preference changes. This is also the escape hatch for custom Bootstrap
* themes, whose names ColorMode cannot resolve (#6084).
*
* Read live rather than captured, so it can be flipped at runtime.
*/
const isDisabled = (): boolean =>
document.documentElement.getAttribute(ATTRIBUTE_DISABLED) === 'off'
/**
* The theme the page itself declared in , or null when
* it declared none.
*
* Captured once, at module evaluation, because the attribute is both an input
* and an output: after the first `_applyTheme()` it holds ColorMode's own
* write, which must not be mistaken for the page's intent on a later lifecycle
* pass (Turbo, `initialize()`). Reading it here runs before any of those.
*
* The pre-paint snippet in writes before this module is even fetched,
* so it marks the values it computed itself with [data-lte-theme-resolved] —
* those are not authored, and are ignored.
*/
const readMarkupTheme = (): Theme | null => {
const { documentElement } = document
if (documentElement.hasAttribute(ATTRIBUTE_RESOLVED)) {
return null
}
const declared = documentElement.getAttribute(ATTRIBUTE_THEME)
return declared && isValidTheme(declared) ? declared : null
}
const MARKUP_THEME = readMarkupTheme()
/**
* Class Definition
* ====================================================
*/
class ColorMode {
/**
* Read the persisted theme choice, or null when nothing was stored or
* localStorage is unavailable (private mode, sandboxed iframe).
*/
getStoredTheme(): Theme | null {
try {
const stored = localStorage.getItem(STORAGE_KEY)
return stored && isValidTheme(stored) ? stored : null
} catch {
return null
}
}
/**
* The theme declared in the markup, for applications that render it
* server-side from a cookie or a user record. Null when the page declared
* none, or when the value is a custom Bootstrap theme ColorMode cannot
* resolve — see `isDisabled` for those.
*/
getMarkupTheme(): Theme | null {
return MARKUP_THEME
}
/**
* The user's effective choice: the stored theme, then the theme declared in
* the markup, falling back to the OS preference. Storage comes first because
* it is the visitor's own click on this device; markup is only the default
* the page shipped with.
*/
getPreferredTheme(): Theme {
const preferred = this.getStoredTheme() ?? this.getMarkupTheme()
if (preferred) {
return preferred
}
return this._prefersDark() ? 'dark' : 'light'
}
/**
* Resolve "auto" against the OS preference.
*/
resolveTheme(theme: Theme): 'light' | 'dark' {
if (theme === 'auto') {
return this._prefersDark() ? 'dark' : 'light'
}
return theme
}
/**
* Apply a theme and persist the choice. Dispatches
* `changed.lte.color-mode` on the document with { theme, resolved }.
*/
setTheme(theme: Theme): void {
try {
localStorage.setItem(STORAGE_KEY, theme)
} catch {
// localStorage may be unavailable; the theme still applies for this page.
}
this._applyTheme(theme)
this._showActiveTheme(theme)
document.dispatchEvent(new CustomEvent(EVENT_CHANGED, {
detail: { theme, resolved: this.resolveTheme(theme) }
}))
}
/**
* Apply without persisting — used on init and when the OS preference
* changes while in "auto" mode.
*/
_applyTheme(theme: Theme): void {
const resolved = this.resolveTheme(theme)
document.documentElement.setAttribute(ATTRIBUTE_THEME, resolved)
document.documentElement.style.colorScheme = resolved
}
/**
* Whether the OS preference is currently dark.
*/
_prefersDark(): boolean {
return globalThis.matchMedia('(prefers-color-scheme: dark)').matches
}
/**
* Sync the [data-bs-theme-value] toggles (active state, pressed state,
* check mark) and the [data-lte-theme-icon] indicator icons.
*/
_showActiveTheme(theme: Theme): void {
document.querySelectorAll(SELECTOR_TOGGLE).forEach(toggle => {
const isActive = toggle.getAttribute(ATTRIBUTE_TOGGLE) === theme
toggle.classList.toggle('active', isActive)
toggle.setAttribute('aria-pressed', String(isActive))
toggle.querySelector('.bi-check-lg')?.classList.toggle('d-none', !isActive)
})
document.querySelectorAll(SELECTOR_ICON).forEach(icon => {
icon.classList.toggle('d-none', (icon as HTMLElement).dataset.lteThemeIcon !== theme)
})
}
/**
* Apply the preferred theme and sync the UI without persisting anything.
*/
init(): void {
if (isDisabled()) {
return
}
const theme = this.getPreferredTheme()
this._applyTheme(theme)
this._showActiveTheme(theme)
}
}
/**
* Data API implementation
* ====================================================
* Toggle clicks are delegated on `document`, so switcher buttons added after
* load work and the listener survives Turbo's swaps. The class is
* stateless — everything lives in localStorage and the DOM.
*/
document.addEventListener('click', event => {
const target = event.target
if (!(target instanceof Element) || isDisabled()) {
return
}
const toggle = target.closest(SELECTOR_TOGGLE)
const theme = toggle?.getAttribute(ATTRIBUTE_TOGGLE)
if (theme && isValidTheme(theme)) {
new ColorMode().setTheme(theme)
}
})
onDOMContentLoaded(() => {
const colorMode = new ColorMode()
colorMode.init()
// Follow the OS only while the OS *is* the effective choice: nothing stored
// and nothing declared in the markup, or an explicit "auto". A theme the
// page declared is a preference too, and outlives an OS change (#6093).
globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (isDisabled()) {
return
}
const preferred = colorMode.getStoredTheme() ?? colorMode.getMarkupTheme()
if (!preferred || preferred === 'auto') {
colorMode._applyTheme('auto')
colorMode._showActiveTheme(preferred ?? 'auto')
}
}, { signal: getLifecycleSignal() })
})
export default ColorMode