UNPKG

apexcharts

Version:

A JavaScript Chart Library

145 lines (130 loc) 5.25 kB
// @ts-check import { Environment } from '../../utils/Environment' import { BrowserAPIs } from '../../ssr/BrowserAPIs' /** * Facet (#13): make `theme.follow: 'os'` reactive to the operating system's * `prefers-color-scheme` (light/dark) and `prefers-contrast` (more) settings, * with no JS on the consumer's side. * * Two responsibilities: * - **Initial:** on every `create()` (this runs in `_initOptionalModules`, * which is before `Core.setupElements` and `Theme.init`), write the resolved * mode / high-contrast onto `w.config.theme` so the first paint already * follows the OS. When `follow:'os'` is set, the OS drives the mode: it wins * over any explicit `theme.mode` (opting into follow IS the intent). * - **Live:** attach a `change` listener to the media queries ONCE per chart * instance (the listener lives on `ctx`, so it survives the re-render that * `updateOptions` performs) and re-theme via the existing `updateOptions` * path when the OS preference flips. Removed on full destroy. * * SSR-safe: every browser touch is guarded / routed through `BrowserAPIs`. * * @module modules/theme/OSThemeWatcher */ const DARK_QUERY = '(prefers-color-scheme: dark)' const CONTRAST_QUERY = '(prefers-contrast: more)' export default class OSThemeWatcher { /** * @param {any} w @param {any} ctx */ constructor(w, ctx) { this.w = w this.ctx = ctx if (w.config.theme.follow !== 'os' || !Environment.isBrowser()) return const media = this._ensureMedia() if (!media) return this._applyToConfig(media) // every create, before Theme.init this._ensureListeners(media) // once per instance } /** * Create (once per instance) the MediaQueryLists and stash them on `ctx` so * they persist across the re-render that `updateOptions` triggers. * @returns {{dark: MediaQueryList|null, contrast: MediaQueryList|null, handler: null|(()=>void)}|null} */ _ensureMedia() { if (!this.ctx._osThemeMedia) { const dark = BrowserAPIs.matchMedia(DARK_QUERY) const contrast = BrowserAPIs.matchMedia(CONTRAST_QUERY) if (!dark && !contrast) return null this.ctx._osThemeMedia = { dark, contrast, handler: null } } return this.ctx._osThemeMedia } /** * Write the OS-resolved mode / high-contrast onto the live `w.config.theme`. * @param {{dark: MediaQueryList|null, contrast: MediaQueryList|null}} media */ _applyToConfig(media) { const theme = this.w.config.theme if (media.dark) { theme.mode = media.dark.matches ? 'dark' : 'light' } if (media.contrast && media.contrast.matches) { theme.accessibility = theme.accessibility || {} theme.accessibility.colorBlindMode = 'highContrast' } } /** * Attach the `change` listener once. The handler closes over `ctx` + `media` * (both stable across re-renders), NOT over `this` (a fresh watcher is built * each create), so it never goes stale. * @param {{dark: MediaQueryList|null, contrast: MediaQueryList|null, handler: null|(()=>void)}} media */ _ensureListeners(media) { if (media.handler) return const ctx = this.ctx const handler = () => { const m = ctx._osThemeMedia if (!m) return /** @type {any} */ const themeOpt = { mode: m.dark && m.dark.matches ? 'dark' : 'light' } if (m.contrast && m.contrast.matches) { themeOpt.accessibility = { colorBlindMode: 'highContrast' } } else { // clear the contrast override when the OS leaves high-contrast themeOpt.accessibility = { colorBlindMode: '' } } // (options, redraw:false, animate:true, updateSyncedCharts:false). // Synced charts are NOT updated from here: following the OS is per-chart // opt-in, and every follow:'os' group member has its own watcher firing // on the same media event, so syncing would double-update each of them. ctx.updateOptions({ theme: themeOpt }, false, true, false) } OSThemeWatcher._add(media.dark, handler) OSThemeWatcher._add(media.contrast, handler) media.handler = handler } /** Remove the listeners and drop the stashed media. Called on full destroy. */ teardown() { const media = this.ctx._osThemeMedia if (!media) return if (media.handler) { OSThemeWatcher._remove(media.dark, media.handler) OSThemeWatcher._remove(media.contrast, media.handler) } this.ctx._osThemeMedia = null } /** * @param {MediaQueryList|null} mql @param {()=>void} handler */ static _add(mql, handler) { if (!mql) return if (typeof mql.addEventListener === 'function') { mql.addEventListener('change', handler) } else if (typeof (/** @type {any} */ (mql).addListener) === 'function') { // Safari < 14 fallback /** @type {any} */ (mql).addListener(handler) } } /** * @param {MediaQueryList|null} mql @param {()=>void} handler */ static _remove(mql, handler) { if (!mql) return if (typeof mql.removeEventListener === 'function') { mql.removeEventListener('change', handler) } else if (typeof (/** @type {any} */ (mql).removeListener) === 'function') { /** @type {any} */ (mql).removeListener(handler) } } }