mkdocs-light-dark-toggle 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mkdocs_light_dark_toggle/__init__.py +0 -0
- mkdocs_light_dark_toggle/plugin.py +83 -0
- mkdocs_light_dark_toggle/static/light_dark_toggle.css +140 -0
- mkdocs_light_dark_toggle/static/light_dark_toggle.js +149 -0
- mkdocs_light_dark_toggle-0.1.0.dist-info/METADATA +199 -0
- mkdocs_light_dark_toggle-0.1.0.dist-info/RECORD +9 -0
- mkdocs_light_dark_toggle-0.1.0.dist-info/WHEEL +4 -0
- mkdocs_light_dark_toggle-0.1.0.dist-info/entry_points.txt +2 -0
- mkdocs_light_dark_toggle-0.1.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""MkDocs plugin: an always-visible two-button light/dark switch for Material's palette."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from mkdocs.config import config_options
|
|
7
|
+
from mkdocs.plugins import BasePlugin
|
|
8
|
+
from mkdocs.utils import copy_file
|
|
9
|
+
|
|
10
|
+
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
|
11
|
+
JS_FILENAME = "light_dark_toggle.js"
|
|
12
|
+
CSS_FILENAME = "light_dark_toggle.css"
|
|
13
|
+
ASSET_PREFIX = "assets/light_dark_toggle"
|
|
14
|
+
CONFIG_SCRIPT_ID = "light-dark-config"
|
|
15
|
+
|
|
16
|
+
DEFAULT_DESCRIPTIONS = {
|
|
17
|
+
"light": "Switch to light mode",
|
|
18
|
+
"dark": "Switch to dark mode",
|
|
19
|
+
}
|
|
20
|
+
DEFAULT_ANNOUNCEMENTS = {
|
|
21
|
+
"light": "Light mode",
|
|
22
|
+
"dark": "Dark mode",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LightDarkTogglePlugin(BasePlugin):
|
|
27
|
+
"""Replaces Material's native palette radios with an always-visible two-button toggle."""
|
|
28
|
+
|
|
29
|
+
_runtime_config = None
|
|
30
|
+
|
|
31
|
+
config_scheme = (
|
|
32
|
+
("light", config_options.Type(dict, default={})),
|
|
33
|
+
("dark", config_options.Type(dict, default={})),
|
|
34
|
+
("aria_label", config_options.Type(str, default="Color theme")),
|
|
35
|
+
("show_toast", config_options.Type(bool, default=True)),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def on_config(self, config):
|
|
39
|
+
modes = {}
|
|
40
|
+
for name in ("light", "dark"):
|
|
41
|
+
raw = self.config.get(name) or {}
|
|
42
|
+
modes[name] = {
|
|
43
|
+
"description": raw.get("description", DEFAULT_DESCRIPTIONS[name]),
|
|
44
|
+
"announcement": raw.get("announcement", DEFAULT_ANNOUNCEMENTS[name]),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
self._runtime_config = {
|
|
48
|
+
"modes": modes,
|
|
49
|
+
"ariaLabel": self.config["aria_label"],
|
|
50
|
+
"showToast": self.config["show_toast"],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
extra_css = list(config.get("extra_css", []))
|
|
54
|
+
extra_js = list(config.get("extra_javascript", []))
|
|
55
|
+
css_uri = f"{ASSET_PREFIX}/{CSS_FILENAME}"
|
|
56
|
+
js_uri = f"{ASSET_PREFIX}/{JS_FILENAME}"
|
|
57
|
+
if css_uri not in extra_css:
|
|
58
|
+
extra_css.append(css_uri)
|
|
59
|
+
if js_uri not in extra_js:
|
|
60
|
+
extra_js.append(js_uri)
|
|
61
|
+
config["extra_css"] = extra_css
|
|
62
|
+
config["extra_javascript"] = extra_js
|
|
63
|
+
|
|
64
|
+
return config
|
|
65
|
+
|
|
66
|
+
def on_post_build(self, config):
|
|
67
|
+
for filename in (JS_FILENAME, CSS_FILENAME):
|
|
68
|
+
src_path = os.path.join(STATIC_DIR, filename)
|
|
69
|
+
dest_path = os.path.join(config["site_dir"], ASSET_PREFIX, filename)
|
|
70
|
+
copy_file(src_path, dest_path)
|
|
71
|
+
|
|
72
|
+
def on_post_page(self, output, page, config):
|
|
73
|
+
if not self._runtime_config:
|
|
74
|
+
return output
|
|
75
|
+
script = (
|
|
76
|
+
f'<script id="{CONFIG_SCRIPT_ID}" type="application/json">'
|
|
77
|
+
f"{json.dumps(self._runtime_config)}"
|
|
78
|
+
f"</script>"
|
|
79
|
+
)
|
|
80
|
+
marker = "</body>"
|
|
81
|
+
if marker in output:
|
|
82
|
+
return output.replace(marker, script + marker, 1)
|
|
83
|
+
return output + script
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/* mkdocs-light-dark-toggle
|
|
2
|
+
|
|
3
|
+
Custom properties (set on #light-dark-toggle or a parent such as :root):
|
|
4
|
+
--light-dark-accent border and highlight color (default: currentColor)
|
|
5
|
+
--light-dark-track-bg toggle background (default: transparent)
|
|
6
|
+
--light-dark-active-fg icon color of the active option (default: Canvas)
|
|
7
|
+
--light-dark-radius corner radius of the toggle and highlight
|
|
8
|
+
--light-dark-height toggle height
|
|
9
|
+
--light-dark-icon-size icon size
|
|
10
|
+
--light-dark-icon-light mask-image for the light-mode icon (default: a sun)
|
|
11
|
+
--light-dark-icon-dark mask-image for the dark-mode icon (default: a moon)
|
|
12
|
+
--light-dark-toast-bg toast background (default: CanvasText)
|
|
13
|
+
--light-dark-toast-fg toast text color (default: Canvas)
|
|
14
|
+
|
|
15
|
+
Sensible sun/moon defaults mean this works with zero configuration, unlike an
|
|
16
|
+
N-way toggle (e.g. mkdocs-audience-toggle) where mode icons have no universal
|
|
17
|
+
meaning and must always be supplied. */
|
|
18
|
+
|
|
19
|
+
.light-dark-toggle {
|
|
20
|
+
position: relative;
|
|
21
|
+
display: inline-flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
height: var(--light-dark-height, 1.2rem);
|
|
24
|
+
border: 0.05rem solid var(--light-dark-accent, currentColor);
|
|
25
|
+
border-radius: var(--light-dark-radius, 1rem);
|
|
26
|
+
overflow: hidden;
|
|
27
|
+
background-color: var(--light-dark-track-bg, transparent);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* left and width are set by the script to match the active option. */
|
|
31
|
+
.light-dark-highlight {
|
|
32
|
+
position: absolute;
|
|
33
|
+
top: 0;
|
|
34
|
+
left: 0;
|
|
35
|
+
width: 50%;
|
|
36
|
+
height: 100%;
|
|
37
|
+
border-radius: var(--light-dark-radius, 1rem);
|
|
38
|
+
background-color: var(--light-dark-accent, currentColor);
|
|
39
|
+
transition: transform 0.2s ease;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.light-dark-toggle[data-active="light"] .light-dark-highlight {
|
|
43
|
+
transform: translateX(100%);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.light-dark-option {
|
|
47
|
+
position: relative;
|
|
48
|
+
z-index: 1;
|
|
49
|
+
display: inline-flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
justify-content: center;
|
|
52
|
+
flex: 1;
|
|
53
|
+
padding: 0 0.35rem;
|
|
54
|
+
border: none;
|
|
55
|
+
background: none;
|
|
56
|
+
color: inherit;
|
|
57
|
+
cursor: pointer;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* Active option's icon sits on the highlight fill, so it flips to a
|
|
61
|
+
contrasting color; inactive stays plain currentColor. */
|
|
62
|
+
.light-dark-toggle[data-active="light"] .light-dark-option[data-scheme="default"],
|
|
63
|
+
.light-dark-toggle[data-active="dark"] .light-dark-option[data-scheme="slate"] {
|
|
64
|
+
color: var(--light-dark-active-fg, Canvas);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.light-dark-option::before {
|
|
68
|
+
content: "";
|
|
69
|
+
display: block;
|
|
70
|
+
width: var(--light-dark-icon-size, 0.7rem);
|
|
71
|
+
height: var(--light-dark-icon-size, 0.7rem);
|
|
72
|
+
background-color: currentColor;
|
|
73
|
+
-webkit-mask-repeat: no-repeat;
|
|
74
|
+
mask-repeat: no-repeat;
|
|
75
|
+
-webkit-mask-size: contain;
|
|
76
|
+
mask-size: contain;
|
|
77
|
+
-webkit-mask-position: center;
|
|
78
|
+
mask-position: center;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.light-dark-option[data-scheme="default"]::before {
|
|
82
|
+
-webkit-mask-image: var(
|
|
83
|
+
--light-dark-icon-light,
|
|
84
|
+
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 960'%3E%3Cpath d='M579-381q41-41 41-99t-41-99q-41-41-99-41t-99 41q-41 41-41 99t41 99q41 41 99 41t99-41Zm-240.5 42.5Q280-397 280-480t58.5-141.5Q397-680 480-680t141.5 58.5Q680-563 680-480t-58.5 141.5Q563-280 480-280t-141.5-58.5ZM200-450H40v-60h160v60Zm720 0H760v-60h160v60ZM450-760v-160h60v160h-60Zm0 720v-160h60v160h-60ZM262-658l-100-97 43-44 96 100-39 41Zm494 496-98-100 41-41 99 98-42 43Zm-99-537 98-99 44 42-99 98-43-41ZM162-205l99-98 42 42-98 99-43-43Zm318-275Z'/%3E%3C/svg%3E")
|
|
85
|
+
);
|
|
86
|
+
mask-image: var(
|
|
87
|
+
--light-dark-icon-light,
|
|
88
|
+
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 960'%3E%3Cpath d='M579-381q41-41 41-99t-41-99q-41-41-99-41t-99 41q-41 41-41 99t41 99q41 41 99 41t99-41Zm-240.5 42.5Q280-397 280-480t58.5-141.5Q397-680 480-680t141.5 58.5Q680-563 680-480t-58.5 141.5Q563-280 480-280t-141.5-58.5ZM200-450H40v-60h160v60Zm720 0H760v-60h160v60ZM450-760v-160h60v160h-60Zm0 720v-160h60v160h-60ZM262-658l-100-97 43-44 96 100-39 41Zm494 496-98-100 41-41 99 98-42 43Zm-99-537 98-99 44 42-99 98-43-41ZM162-205l99-98 42 42-98 99-43-43Zm318-275Z'/%3E%3C/svg%3E")
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.light-dark-option[data-scheme="slate"]::before {
|
|
93
|
+
-webkit-mask-image: var(
|
|
94
|
+
--light-dark-icon-dark,
|
|
95
|
+
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 960'%3E%3Cpath d='M480-120q-150 0-255-105T120-480q0-150 105-255t255-105q8 0 17 .5t23 1.5q-36 32-56 79t-20 99q0 90 63 153t153 63q52 0 99-18.5t79-51.5q1 12 1.5 19.5t.5 14.5q0 150-105 255T480-120Zm0-60q109 0 190-67.5T771-406q-25 11-53.67 16.5Q688.67-384 660-384q-114.69 0-195.34-80.66Q384-545.31 384-660q0-24 5-51.5t18-62.5q-98 27-162.5 109.5T180-480q0 125 87.5 212.5T480-180Zm-4-297Z'/%3E%3C/svg%3E")
|
|
96
|
+
);
|
|
97
|
+
mask-image: var(
|
|
98
|
+
--light-dark-icon-dark,
|
|
99
|
+
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 960'%3E%3Cpath d='M480-120q-150 0-255-105T120-480q0-150 105-255t255-105q8 0 17 .5t23 1.5q-36 32-56 79t-20 99q0 90 63 153t153 63q52 0 99-18.5t79-51.5q1 12 1.5 19.5t.5 14.5q0 150-105 255T480-120Zm0-60q109 0 190-67.5T771-406q-25 11-53.67 16.5Q688.67-384 660-384q-114.69 0-195.34-80.66Q384-545.31 384-660q0-24 5-51.5t18-62.5q-98 27-162.5 109.5T180-480q0 125 87.5 212.5T480-180Zm-4-297Z'/%3E%3C/svg%3E")
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* Material's own [hidden] rules (.md-header__option, .md-option:checked +
|
|
104
|
+
label) beat the plain [hidden] { display: none } UA rule on specificity,
|
|
105
|
+
so the palette form we hide via JS (paletteForm.hidden = true) can stay
|
|
106
|
+
silently visible without this. Scoped to the palette form specifically,
|
|
107
|
+
not a sweeping [hidden] override, so this package can't interfere with
|
|
108
|
+
`hidden` elements from other plugins or the page's own content. */
|
|
109
|
+
[data-md-component="palette"][hidden] {
|
|
110
|
+
display: none !important;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.light-dark-toast {
|
|
114
|
+
position: fixed;
|
|
115
|
+
left: 50%;
|
|
116
|
+
z-index: 10;
|
|
117
|
+
padding: 0.5rem 0.9rem;
|
|
118
|
+
border-radius: 0.4rem;
|
|
119
|
+
background-color: var(--light-dark-toast-bg, CanvasText);
|
|
120
|
+
color: var(--light-dark-toast-fg, Canvas);
|
|
121
|
+
font-size: 0.7rem;
|
|
122
|
+
font-weight: 700;
|
|
123
|
+
white-space: nowrap;
|
|
124
|
+
opacity: 0;
|
|
125
|
+
pointer-events: none;
|
|
126
|
+
transform: translate(-50%, -0.3rem);
|
|
127
|
+
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.light-dark-toast--visible {
|
|
131
|
+
opacity: 1;
|
|
132
|
+
transform: translate(-50%, 0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@media (prefers-reduced-motion: reduce) {
|
|
136
|
+
.light-dark-highlight,
|
|
137
|
+
.light-dark-toast {
|
|
138
|
+
transition: none;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
// Always-visible two-button light/dark switch, replacing Material's native
|
|
3
|
+
// single-knob palette switch (whose knob was the only clickable spot).
|
|
4
|
+
// Native radios stay in the DOM, hidden; their own JS still applies and
|
|
5
|
+
// persists the scheme via localStorage, we just flip `checked` and
|
|
6
|
+
// dispatch change.
|
|
7
|
+
|
|
8
|
+
function readConfig() {
|
|
9
|
+
const script = document.getElementById("light-dark-config");
|
|
10
|
+
if (!script) return null;
|
|
11
|
+
return JSON.parse(script.textContent);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let toastTimer = null;
|
|
15
|
+
function showToast(text) {
|
|
16
|
+
let toast = document.getElementById("light-dark-toast");
|
|
17
|
+
if (!toast) {
|
|
18
|
+
toast = document.createElement("div");
|
|
19
|
+
toast.id = "light-dark-toast";
|
|
20
|
+
toast.className = "light-dark-toast";
|
|
21
|
+
// status + polite: announced to screen readers without interrupting
|
|
22
|
+
// whatever they're already reading, same as a visual toast doesn't
|
|
23
|
+
// steal focus.
|
|
24
|
+
toast.setAttribute("role", "status");
|
|
25
|
+
toast.setAttribute("aria-live", "polite");
|
|
26
|
+
document.body.appendChild(toast);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
toast.textContent = text;
|
|
30
|
+
|
|
31
|
+
// The header's own height isn't fixed across breakpoints (taller with
|
|
32
|
+
// a tab bar on tablet/desktop) or over time (Material can hide/reveal
|
|
33
|
+
// it on scroll), so position below it fresh on every call rather than
|
|
34
|
+
// hardcoding an offset in CSS.
|
|
35
|
+
const header = document.querySelector(".md-header");
|
|
36
|
+
const headerBottom = header ? header.getBoundingClientRect().bottom : 0;
|
|
37
|
+
toast.style.top = Math.max(headerBottom, 0) + 12 + "px";
|
|
38
|
+
|
|
39
|
+
// Retrigger the transition even if a toast is already showing (rapid
|
|
40
|
+
// clicks): drop the class, force layout, then re-add it, instead of
|
|
41
|
+
// just extending the existing timer.
|
|
42
|
+
toast.classList.remove("light-dark-toast--visible");
|
|
43
|
+
void toast.offsetWidth;
|
|
44
|
+
toast.classList.add("light-dark-toast--visible");
|
|
45
|
+
|
|
46
|
+
clearTimeout(toastTimer);
|
|
47
|
+
toastTimer = setTimeout(function () {
|
|
48
|
+
toast.classList.remove("light-dark-toast--visible");
|
|
49
|
+
}, 1400);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getOrCreateToggle(config) {
|
|
53
|
+
let container = document.getElementById("light-dark-toggle");
|
|
54
|
+
if (container) return container;
|
|
55
|
+
|
|
56
|
+
const paletteForm = document.querySelector('[data-md-component="palette"]');
|
|
57
|
+
if (!paletteForm) return null;
|
|
58
|
+
|
|
59
|
+
const lightRadio = paletteForm.querySelector('input[data-md-color-scheme="default"]');
|
|
60
|
+
const darkRadio = paletteForm.querySelector('input[data-md-color-scheme="slate"]');
|
|
61
|
+
if (!lightRadio || !darkRadio) return null;
|
|
62
|
+
|
|
63
|
+
paletteForm.hidden = true;
|
|
64
|
+
|
|
65
|
+
container = document.createElement("div");
|
|
66
|
+
container.id = "light-dark-toggle";
|
|
67
|
+
container.className = "light-dark-toggle";
|
|
68
|
+
container.setAttribute("role", "group");
|
|
69
|
+
container.setAttribute("aria-label", config.ariaLabel);
|
|
70
|
+
|
|
71
|
+
const highlight = document.createElement("span");
|
|
72
|
+
highlight.className = "light-dark-highlight";
|
|
73
|
+
highlight.setAttribute("aria-hidden", "true");
|
|
74
|
+
|
|
75
|
+
const dark = document.createElement("button");
|
|
76
|
+
dark.type = "button";
|
|
77
|
+
dark.className = "light-dark-option";
|
|
78
|
+
dark.dataset.scheme = "slate";
|
|
79
|
+
dark.title = config.modes.dark.description;
|
|
80
|
+
dark.setAttribute("aria-label", config.modes.dark.description);
|
|
81
|
+
|
|
82
|
+
const light = document.createElement("button");
|
|
83
|
+
light.type = "button";
|
|
84
|
+
light.className = "light-dark-option";
|
|
85
|
+
light.dataset.scheme = "default";
|
|
86
|
+
light.title = config.modes.light.description;
|
|
87
|
+
light.setAttribute("aria-label", config.modes.light.description);
|
|
88
|
+
|
|
89
|
+
container.append(highlight, dark, light);
|
|
90
|
+
paletteForm.insertAdjacentElement("beforebegin", container);
|
|
91
|
+
|
|
92
|
+
let previousMode = null;
|
|
93
|
+
container.addEventListener("click", function (event) {
|
|
94
|
+
const option = event.target.closest(".light-dark-option");
|
|
95
|
+
if (!option) return;
|
|
96
|
+
const scheme = option.dataset.scheme;
|
|
97
|
+
// Not radio.checked: Material applies the scheme to <body> on init
|
|
98
|
+
// without syncing the matching radio's `checked` property, so that
|
|
99
|
+
// stays false for whichever scheme loaded by default until a real
|
|
100
|
+
// click sets it below.
|
|
101
|
+
if (scheme === document.body.getAttribute("data-md-color-scheme")) return;
|
|
102
|
+
const radio = scheme === "slate" ? darkRadio : lightRadio;
|
|
103
|
+
radio.checked = true;
|
|
104
|
+
radio.dispatchEvent(new Event("change", { bubbles: true }));
|
|
105
|
+
applyState(container);
|
|
106
|
+
const mode = scheme === "slate" ? "dark" : "light";
|
|
107
|
+
if (config.showToast) showToast(config.modes[mode].announcement);
|
|
108
|
+
document.dispatchEvent(
|
|
109
|
+
new CustomEvent("light-dark:modechange", { detail: { mode: mode, previousMode: previousMode } })
|
|
110
|
+
);
|
|
111
|
+
previousMode = mode;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Sync to whatever scheme Material's own JS actually lands on, not
|
|
115
|
+
// just what we clicked.
|
|
116
|
+
lightRadio.addEventListener("change", function () {
|
|
117
|
+
applyState(container);
|
|
118
|
+
});
|
|
119
|
+
darkRadio.addEventListener("change", function () {
|
|
120
|
+
applyState(container);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return container;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function applyState(container) {
|
|
127
|
+
const scheme = document.body.getAttribute("data-md-color-scheme");
|
|
128
|
+
container.dataset.active = scheme === "slate" ? "dark" : "light";
|
|
129
|
+
container.querySelectorAll(".light-dark-option").forEach(function (option) {
|
|
130
|
+
option.setAttribute("aria-pressed", String(option.dataset.scheme === scheme));
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function setUp() {
|
|
135
|
+
const config = readConfig();
|
|
136
|
+
if (!config) return;
|
|
137
|
+
const toggle = getOrCreateToggle(config);
|
|
138
|
+
if (toggle) applyState(toggle);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// navigation.instant swaps page content via JS without a full reload, so
|
|
142
|
+
// DOMContentLoaded only fires once. document$ is Material's own
|
|
143
|
+
// observable that emits on every page change, instant or not.
|
|
144
|
+
if (window.document$) {
|
|
145
|
+
window.document$.subscribe(setUp);
|
|
146
|
+
} else {
|
|
147
|
+
document.addEventListener("DOMContentLoaded", setUp);
|
|
148
|
+
}
|
|
149
|
+
})();
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mkdocs-light-dark-toggle
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Material for MkDocs plugin: an always-visible two-button light/dark switch, replacing Material's native single-knob palette toggle.
|
|
5
|
+
Project-URL: Homepage, https://github.com/luka-sherman/mkdocs-light-dark-toggle
|
|
6
|
+
Author-email: Luka Sherman <luka.msherman@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Framework :: MkDocs
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Requires-Dist: mkdocs>=1.5
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: mkdocs-material; extra == 'test'
|
|
16
|
+
Requires-Dist: playwright; extra == 'test'
|
|
17
|
+
Requires-Dist: pytest; extra == 'test'
|
|
18
|
+
Requires-Dist: pytest-playwright; extra == 'test'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# mkdocs-light-dark-toggle
|
|
22
|
+
|
|
23
|
+
A [MkDocs](https://www.mkdocs.org/) plugin (built for and tested with
|
|
24
|
+
[Material for MkDocs](https://squidfunk.github.io/mkdocs-material/)) that replaces Material's
|
|
25
|
+
native light/dark switch with an always-visible two-button toggle. Material's own switch is a
|
|
26
|
+
single knob: the current scheme is the only thing you can see, and the knob itself is the only
|
|
27
|
+
clickable spot. This plugin shows both options side by side, with a sliding highlight behind
|
|
28
|
+
whichever is active, so switching is a single click on either option rather than a click-to-cycle
|
|
29
|
+
knob.
|
|
30
|
+
|
|
31
|
+
The native palette radios stay in the DOM, hidden. Their own JavaScript still applies and persists
|
|
32
|
+
the scheme via `localStorage` — this plugin only drives them, it doesn't reimplement scheme
|
|
33
|
+
switching.
|
|
34
|
+
|
|
35
|
+
Light mode, sun active:
|
|
36
|
+
|
|
37
|
+

|
|
38
|
+
|
|
39
|
+
Dark mode, moon active:
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
plugins:
|
|
45
|
+
- light_dark_toggle
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
That's it — the toggle needs no configuration to work, since it ships with built-in sun/moon
|
|
49
|
+
icons and sensible default text.
|
|
50
|
+
|
|
51
|
+
## Requirements
|
|
52
|
+
|
|
53
|
+
Python 3.9+ and MkDocs 1.5+. Built for Material for MkDocs: the plugin looks for Material's own
|
|
54
|
+
`[data-md-component="palette"]` form and the two radio inputs inside it
|
|
55
|
+
(`input[data-md-color-scheme="default"]` and `="slate"`). Your `mkdocs.yml` needs both schemes
|
|
56
|
+
configured under `theme.palette`:
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
theme:
|
|
60
|
+
name: material
|
|
61
|
+
palette:
|
|
62
|
+
- scheme: default
|
|
63
|
+
toggle:
|
|
64
|
+
icon: material/brightness-7
|
|
65
|
+
name: Switch to dark mode
|
|
66
|
+
- scheme: slate
|
|
67
|
+
toggle:
|
|
68
|
+
icon: material/brightness-4
|
|
69
|
+
name: Switch to light mode
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
If either radio isn't found, the plugin leaves Material's native switch alone.
|
|
73
|
+
|
|
74
|
+
## Configure
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
plugins:
|
|
78
|
+
- light_dark_toggle:
|
|
79
|
+
light:
|
|
80
|
+
description: Switch to light mode
|
|
81
|
+
announcement: Lights on
|
|
82
|
+
dark:
|
|
83
|
+
description: Switch to dark mode
|
|
84
|
+
announcement: Lights off
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| Key | Default | Description |
|
|
88
|
+
| ------------- | ------------------------------------ | -------------------------------------------------------------------- |
|
|
89
|
+
| `light` | see below | `description`/`announcement` for the light option. |
|
|
90
|
+
| `dark` | see below | `description`/`announcement` for the dark option. |
|
|
91
|
+
| `aria_label` | `Color theme` | Accessible label for the toggle group. |
|
|
92
|
+
| `show_toast` | `true` | Show a short message after the scheme changes. |
|
|
93
|
+
|
|
94
|
+
`light`/`dark` each take:
|
|
95
|
+
|
|
96
|
+
| Key | Default | Description |
|
|
97
|
+
| -------------- | ------------------------- | -------------------------------------------------------- |
|
|
98
|
+
| `description` | `Switch to light/dark mode` | Button title and accessible label. |
|
|
99
|
+
| `announcement` | `Light mode`/`Dark mode` | Text shown in the toast after switching to this scheme. |
|
|
100
|
+
|
|
101
|
+
## Styling
|
|
102
|
+
|
|
103
|
+
The toggle's CSS is controlled with custom properties. Override them in your `extra_css` file on
|
|
104
|
+
`#light-dark-toggle`, or on a parent element such as `:root`:
|
|
105
|
+
|
|
106
|
+
```css
|
|
107
|
+
#light-dark-toggle {
|
|
108
|
+
--light-dark-accent: #2e7d32; /* border and highlight color (default: currentColor) */
|
|
109
|
+
--light-dark-track-bg: #fdf6e3; /* toggle background (default: transparent) */
|
|
110
|
+
--light-dark-active-fg: #fdf6e3; /* icon color of the active option (default: Canvas) */
|
|
111
|
+
--light-dark-radius: 1rem; /* corner radius of the toggle and highlight (default: 1rem) */
|
|
112
|
+
--light-dark-height: 1.2rem; /* toggle height (default: 1.2rem) */
|
|
113
|
+
--light-dark-icon-size: 0.7rem; /* icon size (default: 0.7rem) */
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Icons are CSS `mask-image` values and default to a built-in sun and moon:
|
|
118
|
+
|
|
119
|
+
```css
|
|
120
|
+
#light-dark-toggle {
|
|
121
|
+
--light-dark-icon-light: url("data:image/svg+xml,...");
|
|
122
|
+
--light-dark-icon-dark: url("data:image/svg+xml,...");
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The toast has its own properties, kept separate from `--light-dark-accent`/`--light-dark-active-fg`
|
|
127
|
+
so theming the toggle doesn't also recolor the toast:
|
|
128
|
+
|
|
129
|
+
```css
|
|
130
|
+
#light-dark-toast {
|
|
131
|
+
--light-dark-toast-bg: #2e7d32; /* toast background (default: CanvasText) */
|
|
132
|
+
--light-dark-toast-fg: #fdf6e3; /* toast text color (default: Canvas) */
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Left at their defaults, `CanvasText`/`Canvas` auto-invert the toast against the page's
|
|
137
|
+
`color-scheme` CSS property. If your site switches schemes manually rather than relying on
|
|
138
|
+
`prefers-color-scheme` — which, using this plugin, it does — set `color-scheme: light`/`dark`
|
|
139
|
+
yourself on the selector your palette CSS already scopes to (e.g.
|
|
140
|
+
`[data-md-color-scheme="slate"] { color-scheme: dark; }`) for that to track the active scheme
|
|
141
|
+
instead of the OS preference.
|
|
142
|
+
|
|
143
|
+
For other changes, target the classes `.light-dark-toggle`, `.light-dark-highlight`,
|
|
144
|
+
`.light-dark-option`, and `.light-dark-toast`. The script sets the highlight's position via
|
|
145
|
+
`[data-active]` on `.light-dark-toggle`, not inline styles.
|
|
146
|
+
|
|
147
|
+
## Accessibility
|
|
148
|
+
|
|
149
|
+
- The color properties aren't checked for contrast. Check your color choices against WCAG
|
|
150
|
+
contrast requirements.
|
|
151
|
+
- Each option is a toggle button with `aria-pressed` and its own tab stop. The toggle doesn't use
|
|
152
|
+
the ARIA radio group pattern, which has a single tab stop and arrow-key navigation.
|
|
153
|
+
- Transitions are turned off when `prefers-reduced-motion: reduce` is set.
|
|
154
|
+
- If the plugin's JavaScript doesn't run, Material's native palette switch is left visible and
|
|
155
|
+
fully functional — nothing is hidden until this plugin's own script confirms it found both
|
|
156
|
+
radios and successfully built its replacement.
|
|
157
|
+
|
|
158
|
+
## Analytics
|
|
159
|
+
|
|
160
|
+
Listen for the `light-dark:modechange` event on `document` to record scheme changes.
|
|
161
|
+
`event.detail` contains `mode` (`"light"` or `"dark"`) and `previousMode`:
|
|
162
|
+
|
|
163
|
+
```js
|
|
164
|
+
document.addEventListener("light-dark:modechange", (event) => {
|
|
165
|
+
const { mode, previousMode } = event.detail;
|
|
166
|
+
gtag("event", "color_scheme_change", { mode, previous_mode: previousMode });
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The event fires only on a click that changes the scheme — not on page load, and not when clicking
|
|
171
|
+
the option that's already active. To read the scheme at any time, including on page load, use
|
|
172
|
+
Material's own `data-md-color-scheme` attribute on `<body>`:
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
const scheme = document.body.getAttribute("data-md-color-scheme"); // "default" or "slate"
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Testing
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
python3 -m venv .venv
|
|
182
|
+
source .venv/bin/activate
|
|
183
|
+
pip install -e ".[test]"
|
|
184
|
+
playwright install chromium
|
|
185
|
+
pytest
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`tests/fixture_site/` is a small Material for MkDocs site that uses the plugin. The tests build it
|
|
189
|
+
once, serve it locally, and run Playwright against it:
|
|
190
|
+
|
|
191
|
+
- `test_behavior.py`: replacing the native form, switching scheme, persistence, toast, events.
|
|
192
|
+
- `test_accessibility.py`: axe-core checks.
|
|
193
|
+
- `test_keyboard.py`: keyboard use and focus.
|
|
194
|
+
|
|
195
|
+
axe-core is included in `tests/vendor/`, so the tests don't need network access.
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
mkdocs_light_dark_toggle/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
mkdocs_light_dark_toggle/plugin.py,sha256=a_3Kk6zAjqoSZoD8GoQafoNa-b6Zako5Dhb0zNQy6II,2785
|
|
3
|
+
mkdocs_light_dark_toggle/static/light_dark_toggle.css,sha256=O_kxVt3Xmhb3INryAWHf1S-BHitfo0UtM-XkAH-cwcU,6275
|
|
4
|
+
mkdocs_light_dark_toggle/static/light_dark_toggle.js,sha256=8uG-ju2oddGrQaKNtcrs6bfPx8NO_rzpKVjxTvZH2BI,5863
|
|
5
|
+
mkdocs_light_dark_toggle-0.1.0.dist-info/METADATA,sha256=HH_Vm8TESt5lJf3kX0KP7br-xtR38C1YUaapRTjZD58,8028
|
|
6
|
+
mkdocs_light_dark_toggle-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
mkdocs_light_dark_toggle-0.1.0.dist-info/entry_points.txt,sha256=oHM2f4DaEq-5LX05T54UOzvCFU5TNyT9xHyJrpNW8xI,91
|
|
8
|
+
mkdocs_light_dark_toggle-0.1.0.dist-info/licenses/LICENSE,sha256=8eSg7z3uZ_8PXMr30y4f3GlWv6ND5QUAIOB3eccrtc0,1069
|
|
9
|
+
mkdocs_light_dark_toggle-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Luka Sherman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|