Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
286 changes: 286 additions & 0 deletions src/components/ThemeSelectDropdown.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
---
// The button is static HTML rendered by Astro — no hydration, no flash.
// An is:inline script reads localStorage synchronously before first paint
// to patch the icon and label in-place (same technique as Starlight's ThemeSelect).
//
// The dropdown menu is a React component (ThemeSelectMenu.tsx) that is lazily
// imported only when the user clicks the button. By that point React is already
// loaded and hydration is irrelevant — there is nothing to hydrate.
---

<starlight-theme-select>
{/* Hidden select keeps StarlightThemeProvider.updatePickers() working */}
<select aria-hidden="true" tabindex="-1" style="display:none">
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="auto" selected>Auto</option>
</select>

<button
id="theme-toggle"
aria-haspopup="listbox"
aria-expanded="false"
aria-label="Theme: Auto"
class="theme-toggle"
>
<span class="theme-toggle-inner">
<span class="theme-icon-slot" aria-hidden="true">
{/* Desktop/auto icon — patched synchronously by is:inline script */}
<svg
class="theme-icon"
stroke="currentColor"
fill="currentColor"
stroke-width="0"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg"
><path
d="M208,36H48A28,28,0,0,0,20,64V172a28,28,0,0,0,28,28h68v12H96a12,12,0,0,0,0,24h64a12,12,0,0,0,0-24H140V200h68a28,28,0,0,0,28-28V64A28,28,0,0,0,208,36ZM48,60H208a4,4,0,0,1,4,4v72H44V64A4,4,0,0,1,48,60ZM208,176H48a4,4,0,0,1-4-4V160H212v12A4,4,0,0,1,208,176Z"
></path></svg
>
</span>
<span class="theme-label">Auto</span>
</span>
<span class="theme-caret" aria-hidden="true">
<svg
class="caret-icon"
stroke="currentColor"
fill="currentColor"
stroke-width="0"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg"
><path
d="M216.49,104.49l-80,80a12,12,0,0,1-17,0l-80-80a12,12,0,0,1,17-17L128,159l71.51-71.52a12,12,0,0,1,17,17Z"
></path></svg
>
</span>
</button>
</starlight-theme-select>

{
/* Runs synchronously before paint — reads localStorage and patches the button DOM */
}
<script is:inline>
(function () {
const storageKey = "starlight-theme";
const stored = typeof localStorage !== "undefined" && localStorage.getItem(storageKey);
const theme = stored === "light" || stored === "dark" ? stored : "auto";

const labels = { light: "Light", dark: "Dark", auto: "Auto" };

const iconPaths = {
light:
"M116,36V20a12,12,0,0,1,24,0V36a12,12,0,0,1-24,0Zm80,92a68,68,0,1,1-68-68A68.07,68.07,0,0,1,196,128Zm-24,0a44,44,0,1,0-44,44A44.05,44.05,0,0,0,172,128ZM51.51,68.49a12,12,0,1,0,17-17l-12-12a12,12,0,0,0-17,17Zm0,119-12,12a12,12,0,0,0,17,17l12-12a12,12,0,1,0-17-17ZM196,72a12,12,0,0,0,8.49-3.51l12-12a12,12,0,0,0-17-17l-12,12A12,12,0,0,0,196,72Zm8.49,115.51a12,12,0,0,0-17,17l12,12a12,12,0,0,0,17-17ZM48,128a12,12,0,0,0-12-12H20a12,12,0,0,0,0,24H36A12,12,0,0,0,48,128Zm80,80a12,12,0,0,0-12,12v16a12,12,0,0,0,24,0V220A12,12,0,0,0,128,208Zm108-92H220a12,12,0,0,0,0,24h16a12,12,0,0,0,0-24Z",
dark: "M236.37,139.4a12,12,0,0,0-12-3A84.07,84.07,0,0,1,119.6,31.59a12,12,0,0,0-15-15A108.86,108.86,0,0,0,49.69,55.07,108,108,0,0,0,136,228a107.09,107.09,0,0,0,64.93-21.69,108.86,108.86,0,0,0,38.44-54.94A12,12,0,0,0,236.37,139.4Zm-49.88,47.74A84,84,0,0,1,68.86,69.51,84.93,84.93,0,0,1,92.27,48.29Q92,52.13,92,56A108.12,108.12,0,0,0,200,164q3.87,0,7.71-.27A84.79,84.79,0,0,1,186.49,187.14Z",
auto: "M208,36H48A28,28,0,0,0,20,64V172a28,28,0,0,0,28,28h68v12H96a12,12,0,0,0,0,24h64a12,12,0,0,0,0-24H140V200h68a28,28,0,0,0,28-28V64A28,28,0,0,0,208,36ZM48,60H208a4,4,0,0,1,4,4v72H44V64A4,4,0,0,1,48,60ZM208,176H48a4,4,0,0,1-4-4V160H212v12A4,4,0,0,1,208,176Z",
};

document
.querySelectorAll("starlight-theme-select")
.forEach(function (picker) {
const select = picker.querySelector("select");
if (select) select.value = theme;

const iconPath = picker.querySelector(".theme-icon path");
if (iconPath) iconPath.setAttribute("d", iconPaths[theme]);

const label = picker.querySelector(".theme-label");
if (label) label.textContent = labels[theme];

const btn = picker.querySelector("#theme-toggle");
if (btn) btn.setAttribute("aria-label", "Theme: " + labels[theme]);
});
})();
</script>

<script>
import { createElement } from "react";
import { createRoot } from "react-dom/client";
import type { Theme } from "./ThemeSelectMenu";

const storageKey = "starlight-theme";

const labels: Record<Theme, string> = {
light: "Light",
dark: "Dark",
auto: "Auto",
};

const iconPaths: Record<Theme, string> = {
light:
"M116,36V20a12,12,0,0,1,24,0V36a12,12,0,0,1-24,0Zm80,92a68,68,0,1,1-68-68A68.07,68.07,0,0,1,196,128Zm-24,0a44,44,0,1,0-44,44A44.05,44.05,0,0,0,172,128ZM51.51,68.49a12,12,0,1,0,17-17l-12-12a12,12,0,0,0-17,17Zm0,119-12,12a12,12,0,0,0,17,17l12-12a12,12,0,1,0-17-17ZM196,72a12,12,0,0,0,8.49-3.51l12-12a12,12,0,0,0-17-17l-12,12A12,12,0,0,0,196,72Zm8.49,115.51a12,12,0,0,0-17,17l12,12a12,12,0,0,0,17-17ZM48,128a12,12,0,0,0-12-12H20a12,12,0,0,0,0,24H36A12,12,0,0,0,48,128Zm80,80a12,12,0,0,0-12,12v16a12,12,0,0,0,24,0V220A12,12,0,0,0,128,208Zm108-92H220a12,12,0,0,0,0,24h16a12,12,0,0,0,0-24Z",
dark: "M236.37,139.4a12,12,0,0,0-12-3A84.07,84.07,0,0,1,119.6,31.59a12,12,0,0,0-15-15A108.86,108.86,0,0,0,49.69,55.07,108,108,0,0,0,136,228a107.09,107.09,0,0,0,64.93-21.69,108.86,108.86,0,0,0,38.44-54.94A12,12,0,0,0,236.37,139.4Zm-49.88,47.74A84,84,0,0,1,68.86,69.51,84.93,84.93,0,0,1,92.27,48.29Q92,52.13,92,56A108.12,108.12,0,0,0,200,164q3.87,0,7.71-.27A84.79,84.79,0,0,1,186.49,187.14Z",
auto: "M208,36H48A28,28,0,0,0,20,64V172a28,28,0,0,0,28,28h68v12H96a12,12,0,0,0,0,24h64a12,12,0,0,0,0-24H140V200h68a28,28,0,0,0,28-28V64A28,28,0,0,0,208,36ZM48,60H208a4,4,0,0,1,4,4v72H44V64A4,4,0,0,1,48,60ZM208,176H48a4,4,0,0,1-4-4V160H212v12A4,4,0,0,1,208,176Z",
};

function parseTheme(theme: unknown): Theme {
return theme === "auto" || theme === "dark" || theme === "light"
? theme
: "auto";
}

function loadTheme(): Theme {
return parseTheme(
typeof localStorage !== "undefined" && localStorage.getItem(storageKey),
);
}

function storeTheme(theme: Theme): void {
if (typeof localStorage !== "undefined") {
localStorage.setItem(
storageKey,
theme === "light" || theme === "dark" ? theme : "",
);
}
}

function getPreferredColorScheme(): "light" | "dark" {
return matchMedia("(prefers-color-scheme: light)").matches
? "light"
: "dark";
}

function applyTheme(theme: Theme): void {
document.documentElement.dataset.theme =
theme === "auto" ? getPreferredColorScheme() : theme;
storeTheme(theme);
if ((window as any).StarlightThemeProvider) {
(window as any).StarlightThemeProvider.updatePickers(theme);
}
}

function updateButton(picker: Element, theme: Theme) {
const select = picker.querySelector("select");
if (select) select.value = theme;

const iconPath = picker.querySelector(".theme-icon path");
if (iconPath) iconPath.setAttribute("d", iconPaths[theme]);

const label = picker.querySelector(".theme-label");
if (label) label.textContent = labels[theme];

const btn = picker.querySelector<HTMLElement>("#theme-toggle");
if (btn) btn.setAttribute("aria-label", `Theme: ${labels[theme]}`);
}

document.querySelectorAll("starlight-theme-select").forEach((picker) => {
const toggle = picker.querySelector<HTMLButtonElement>("#theme-toggle");
if (!toggle) return;

// React root for the dropdown — created once on first click, reused after.
let menuContainer: HTMLDivElement | null = null;
let root: ReturnType<typeof createRoot> | null = null;
let isOpen = false;

function renderMenu(open: boolean) {
isOpen = open;
toggle!.setAttribute("aria-expanded", String(open));

if (!open) {
if (root) root.render(null);
return;
}

if (!menuContainer) {
menuContainer = document.createElement("div");
document.body.appendChild(menuContainer);
root = createRoot(menuContainer);
}

// Lazy-import ThemeSelectMenu only on first open.
import("./ThemeSelectMenu").then(({ default: ThemeSelectMenu }) => {
root!.render(
createElement(ThemeSelectMenu, {
anchor: toggle!,
onSelect(theme: Theme) {
applyTheme(theme);
updateButton(picker, theme);
renderMenu(false);
},
}),
);
});
}

toggle.addEventListener("click", () => renderMenu(!isOpen));

document.addEventListener("mousedown", (e) => {
if (isOpen && !picker.contains(e.target as Node)) {
renderMenu(false);
}
});

document.addEventListener("keydown", (e) => {
if (isOpen && e.key === "Escape") renderMenu(false);
});

// React to system color scheme changes when set to auto.
matchMedia("(prefers-color-scheme: light)").addEventListener(
"change",
() => {
if (loadTheme() === "auto") {
document.documentElement.dataset.theme = getPreferredColorScheme();
}
},
);
});
</script>

<style>
.theme-toggle {
display: flex;
height: 2.25rem;
width: 100%;
cursor: pointer;
align-items: center;
justify-content: space-between;
gap: 0.375rem;
border-radius: 0.5rem;
background: transparent;
border: none;
padding: 0 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-header-text);
transition:
color 150ms,
background-color 150ms;
}

.theme-toggle:hover {
background-color: var(--color-header-fill);
color: var(--color-header-hover-text);
}

.theme-toggle-inner {
display: flex;
flex: 1;
align-items: center;
gap: 0.375rem;
}

.theme-icon-slot {
display: flex;
align-items: center;
color: var(--color-header-text-subtle);
}

.theme-toggle:hover .theme-icon-slot,
.theme-toggle:hover .theme-caret {
color: var(--color-header-hover-text);
}

.theme-icon {
width: 0.875rem;
height: 0.875rem;
}

.theme-caret {
flex-shrink: 0;
color: var(--color-header-text-subtle);
}

.caret-icon {
width: 0.625rem;
height: 0.625rem;
}
</style>
Loading
Loading