Packages

phoenix_kit

2.7.0
2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web components theme_controller_script.ex
Raw

lib/phoenix_kit_web/components/theme_controller_script.ex

defmodule PhoenixKitWeb.Components.ThemeControllerScript do
@moduledoc """
The one theme-controller script — the behavior behind every element the
`PhoenixKitWeb.Components.Core.ThemeController` picker renders.
Before this module the same behavior existed three times: a hand-written
script in the dashboard layout, a near-copy inside the admin layout
wrapper, and the deleted static `phoenix_kit_themes.js`. They drifted —
the dashboard copy carried its own hardcoded base map that missed newer
themes and could never know host-defined ones, and both resolved
`"system"` to hardcoded `phoenix-*` names, wrong for any host whose
configured pair uses other names (see `PhoenixKit.ThemeConfig.system_pair/0`).
Everything data-shaped is generated from `PhoenixKit.ThemeConfig` at
render time, so host `:theme_definitions` flow through with zero JS
changes. The script is idempotent per page (`window.__pkThemeController`
guard) and every DOM hook is optional — a page with no label element or
no toggle simply skips those branches.
Event contract: picker elements dispatch `phx:set-theme` themselves (a
`JS.dispatch` bubbling to `window`), so hosts hear each selection exactly
once whether or not this script is present. The script CONSUMES that
event to persist and reflect; it re-announces only changes that did not
arrive as a window event (the legacy `phx:set-admin-theme` translation).
Renders at the end of `<body>`; the pre-paint half of the story is
`PhoenixKitWeb.Components.ThemeBootstrap` in `<head>`.
"""
use Phoenix.Component
alias PhoenixKit.ThemeConfig
@doc """
The shared theme controller. Render once, at the end of `<body>`.
"""
def theme_controller_script(assigns) do
{light, dark} = ThemeConfig.system_pair()
# :html_safe turns < and > into \\u003C/\\u003E so no config- or
# translation-derived string can close this <script> tag. The theme
# NAMES are validated upstream (ThemeConfig), but labels are free text.
assigns =
assigns
|> assign(:light, light)
|> assign(:dark, dark)
|> assign(:base_map_json, Jason.encode!(ThemeConfig.base_map(), escape: :html_safe))
|> assign(
:labels_json,
Jason.encode!(ThemeConfig.translated_label_map(), escape: :html_safe)
)
~H"""
<script>
(function () {
// One instance per page, whichever layout renders first.
if (window.__pkThemeController) return;
window.__pkThemeController = true;
const STORAGE_KEY = 'phx:theme';
const themeBaseMap = <%= Phoenix.HTML.raw(@base_map_json) %>;
const themeLabels = <%= Phoenix.HTML.raw(@labels_json) %>;
// The CONFIGURED light/dark pair "system" resolves to.
const systemPair = { light: '<%= @light %>', dark: '<%= @dark %>' };
const media = window.matchMedia?.('(prefers-color-scheme: dark)') || null;
let dispatching = false;
// localStorage can throw wholesale (sandboxed iframes, storage-blocked
// modes). Guarded here so a throw degrades to non-persistent theming
// instead of killing init before any listener attaches.
const LEGACY_KEY = 'phoenix_kit_theme';
const storage = {
get() {
try {
return localStorage.getItem(STORAGE_KEY) || localStorage.getItem(LEGACY_KEY);
} catch (e) {
return null;
}
},
set(value) {
try {
localStorage.setItem(STORAGE_KEY, value);
} catch (e) {}
},
remove() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch (e) {}
}
};
function resolve(theme) {
if (theme !== 'system') return theme;
return media && media.matches ? systemPair.dark : systemPair.light;
}
function toTitle(value) {
return value
.split('-')
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join(' ');
}
// Stamp the theme on the document and mirror it into every picker
// labels, dropdown option indicators, and the pair toggle. Does NOT
// persist or dispatch; that is setTheme's half.
function apply(theme) {
const resolved = resolve(theme);
[document.documentElement, document.body].forEach((el) => {
if (!el) return;
el.setAttribute('data-theme', resolved);
el.style.colorScheme = themeBaseMap[resolved] || 'light';
});
document.querySelectorAll('[data-theme-current-label]').forEach((el) => {
el.textContent = themeLabels[theme] || toTitle(theme);
});
document.querySelectorAll('[data-theme-target]').forEach((btn) => {
const targets = (btn.dataset.themeTarget || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const isActive = targets.includes(theme) || targets.includes(resolved);
if (btn.dataset.themeRole === 'toggle') {
// The persistent pair toggle: aria-pressed = dark half on, and
// data-phx-theme always points at the OTHER theme — it is what
// the button's JS.dispatch carries, so the click needs no
// state of its own. Icon visibility is CSS (keyed off
// html[data-theme], rendered next to the button), correct from
// the first paint with no JS involved.
const isDark = resolved === btn.dataset.themeDark;
btn.setAttribute('aria-pressed', String(isDark));
btn.dataset.phxTheme = isDark ? btn.dataset.themeLight : btn.dataset.themeDark;
} else if (btn.dataset.themeRole === 'dropdown-option') {
btn.classList.toggle('bg-base-200', isActive);
btn.classList.toggle('ring-2', isActive);
btn.classList.toggle('ring-primary/70', isActive);
btn.setAttribute('aria-selected', String(isActive));
btn.querySelectorAll('[data-theme-active-indicator]').forEach((icon) => {
icon.classList.toggle('opacity-100', isActive);
icon.classList.toggle('scale-100', isActive);
icon.classList.toggle('scale-75', !isActive);
});
}
});
}
// announce=false when the change arrived as a window event: it
// already bubbled past every host listener, and re-dispatching made
// hosts hear each selection twice.
function setTheme(theme, announce) {
apply(theme);
if (theme === 'system') {
storage.remove();
} else {
storage.set(theme);
}
if (announce && !dispatching) {
dispatching = true;
try {
window.dispatchEvent(new CustomEvent('phx:set-theme', { detail: { theme } }));
} catch (error) {
console.warn('PhoenixKit theme controller: unable to dispatch phx:set-theme', error);
} finally {
dispatching = false;
}
}
}
function init() {
// apply, not setTheme: initialization reflects the saved choice
// into the UI but should neither rewrite storage nor announce a
// change nobody made.
apply(storage.get() || 'system');
// Follow the OS while in system mode.
media?.addEventListener('change', () => {
if ((storage.get() || 'system') === 'system') apply('system');
});
// A theme picked in another tab: ThemeBootstrap already restamps
// the attribute pre-paint on fresh loads; this keeps LIVE pages'
// pickers in sync too. apply, not setTheme — the other tab
// already persisted.
window.addEventListener('storage', (e) => {
if (e.key === STORAGE_KEY) apply(e.newValue || 'system');
});
// LiveView patches rebuild picker markup mid-session, wiping the
// client-written state (aria-pressed, data-phx-theme, icon
// visibility) back to its SSR defaults. Re-stamp after every
// completed navigation/patch.
window.addEventListener('phx:page-loading-stop', () => apply(storage.get() || 'system'));
// Theme selections: every picker element (dropdown options and the
// pair toggle alike) dispatches phx:set-theme itself via
// JS.dispatch, carrying the theme in detail or data-phx-theme.
// Skip our own re-dispatches — see the guard in setTheme.
window.addEventListener('phx:set-theme', (e) => {
const theme = e?.detail?.theme ?? e?.target?.dataset?.phxTheme;
if (theme && !dispatching) setTheme(theme, false);
});
// Legacy event name — nothing in the kit emits it anymore, kept
// only so hosts that adopted it keep working. Announced, so host
// listeners for the modern event still hear the change.
document.addEventListener('phx:set-admin-theme', (e) => {
if (e?.detail?.theme) setTheme(e.detail.theme, true);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
"""
end
end