Files
Astro-Component/src/components/ThemeSwitcher.astro
T

198 lines
8.4 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
import type { HTMLAttributes } from "astro/types";
import { Icon } from "astro-icon/components";
interface Props extends HTMLAttributes<"div"> {
storageKey?: string;
rootSelector?: string; // where to toggle theme, e.g. "html"
compact?: boolean; // smaller trigger button
darkBgClass?: string[]; // nonpure-black dark background class
lightBgClass?: string[]; // light background class
rootExtraClass?: string; // always-on root class (optional)
showLabel?: boolean; // show text next to icon in trigger
}
const {
storageKey = "wr-theme",
rootSelector = "html",
compact = false,
darkBgClass = ["wr-theme-dark-soft"],
lightBgClass = ["wr-theme-light"],
rootExtraClass = "",
showLabel = false,
class: className,
...rest
}: Props = Astro.props;
const iconBtn =
"wr:py-3 wr:px-4 wr:inline-flex wr:items-center wr:gap-x-2 wr:text-sm wr:font-medium wr:rounded-lg wr:border wr:border-gray-200 wr:bg-white wr:text-gray-800 wr:shadow-2xs wr:hover:bg-gray-50 wr:focus:outline-hidden wr:focus:bg-gray-50 wr:disabled:opacity-50 wr:disabled:pointer-events-none wr:dark:bg-neutral-800 wr:dark:border-neutral-700 wr:dark:text-white wr:dark:hover:bg-neutral-700 wr:dark:focus:bg-neutral-700";
const menuBtn =
"wr:py-3 wr:px-4 wr:inline-flex wr:items-center wr:gap-x-2 wr:text-sm wr:font-medium wr:rounded-lg wr:border wr:border-gray-200 wr:bg-white wr:text-gray-800 wr:shadow-2xs wr:hover:bg-gray-50 wr:focus:outline-hidden wr:focus:bg-gray-50 wr:disabled:opacity-50 wr:disabled:pointer-events-none wr:dark:bg-neutral-800 wr:dark:border-neutral-700 wr:dark:text-white wr:dark:hover:bg-neutral-700 wr:dark:focus:bg-neutral-700";
const item =
"wr:flex wr:items-center wr:gap-3 wr:w-full wr:px-3 wr:py-2.5 wr:rounded-lg wr:text-sm wr:hover:bg-muted/70 wr:transition wr:duration-150";
const icon = "wr:size-4";
const checkBase = "wr:size-4 wr:opacity-0 wr:scale-90 wr:transition";
const containerId = `ts-${Math.random().toString(36).slice(2)}`;
---
<div id={containerId} class={["wr:relative", className].join(" ")} {...rest}>
<!-- Trigger: icon-only by default; set showLabel=true to include text -->
<button
type="button"
class={showLabel ? menuBtn : [iconBtn, compact && "wr:h-8 wr:w-8"].filter(Boolean).join(" ")}
data-menu-trigger
aria-haspopup="menu"
aria-expanded="false"
title="Theme"
>
<span class="wr:inline-flex wr:items-center wr:gap-2">
<span data-current-icon="light" class="wr:inline"><Icon name="mdi:weather-sunny" class={icon} /></span>
<span data-current-icon="dark" class="wr:hidden"><Icon name="mdi:weather-night" class={icon} /></span>
<span data-current-icon="system" class="wr:hidden"><Icon name="mdi:laptop" class={icon} /></span>
{showLabel && <span data-current-label class="wr:text-sm wr:font-medium">System</span>}
</span>
{showLabel && <Icon name="mdi:chevron-down" class="wr:size-4 wr:opacity-70" />}
</button>
<!-- Dropdown -->
<div
class="wr:absolute wr:right-0 wr:z-50 wr:w-56 wr:border-border wr:bg-popover/90 wr:backdrop-blur-md wr:text-popover-foreground wr:p-1.5 wr:origin-top-right wr:scale-95 wr:duration-150 wr:ease-out wr:transition-[opacity,margin] wr:border wr:border-gray-100 wr:duration wr:hs-dropdown-open:opacity-100 wr:opacity-0 wr:hidden wr:min-w-60 wr:bg-white wr:shadow-md wr:rounded-lg wr:mt-2 wr:dark:bg-neutral-800 wr:dark:border wr:dark:border-neutral-700 wr:dark:divide-neutral-700 wr:after:h-4 wr:after:absolute wr:after:-bottom-4 wr:after:start-0 wr:after:w-full wr:before:h-4 wr:before:absolute wr:before:-top-4 wr:before:start-0 wr:before:w-full"
role="menu"
aria-label="Theme"
data-menu-panel
>
<button type="button" role="menuitemradio" aria-checked="false" class={item} data-set-theme="light">
<Icon name="mdi:weather-sunny" class={icon} />
<div class="wr:flex-1 wr:font-medium">Light</div>
<Icon name="mdi:check" class={checkBase} data-check />
</button>
<button type="button" role="menuitemradio" aria-checked="false" class={item} data-set-theme="dark">
<Icon name="mdi:weather-night" class={icon} />
<div class="wr:flex-1 wr:font-medium">Dark</div>
<Icon name="mdi:check" class={checkBase} data-check />
</button>
<button type="button" role="menuitemradio" aria-checked="true" class={item} data-set-theme="system">
<Icon name="mdi:laptop" class={icon} />
<div class="wr:flex-1 wr:font-medium">System</div>
<Icon name="mdi:check" class={checkBase} data-check />
</button>
</div>
</div>
<script is:inline define:vars={{ containerId, storageKey, rootSelector, darkBgClass, lightBgClass, rootExtraClass }}>
(() => {
const host = document.getElementById(containerId);
if (!host) return;
const root = rootSelector === "html"
? document.documentElement
: document.querySelector(rootSelector) || document.documentElement;
const mql = window.matchMedia("(prefers-color-scheme: dark)");
let lastThemeClasses = [];
function setRootClasses(list) {
lastThemeClasses.forEach(c => c && root.classList.remove(c));
list.filter(Boolean).forEach(c => root.classList.add(c));
lastThemeClasses = list.filter(Boolean);
}
function reflectUI(mode) {
// Trigger icon + optional label
const trig = host.querySelector("[data-menu-trigger]");
if (trig) {
const labelSpan = trig.querySelector("[data-current-label]");
if (labelSpan) labelSpan.textContent = mode[0].toUpperCase() + mode.slice(1);
trig.querySelectorAll("[data-current-icon]").forEach(el => {
const is = el.getAttribute("data-current-icon") === mode;
el.classList.toggle("wr:hidden", !is);
el.classList.toggle("wr:inline", is);
});
}
// Menu radios & checks
const panel = host.querySelector("[data-menu-panel]");
if (panel) {
panel.querySelectorAll("[role='menuitemradio']").forEach(item => {
const active = item.getAttribute("data-set-theme") === mode;
item.setAttribute("aria-checked", active ? "true" : "false");
item.classList.toggle("wr:bg-muted/60", active);
});
}
}
function openMenu(open) {
const trigger = host.querySelector("[data-menu-trigger]");
const panel = host.querySelector("[data-menu-panel]");
trigger?.setAttribute("aria-expanded", open ? "true" : "false");
if (!panel) return;
if (open) {
panel.classList.remove("wr:hidden");
requestAnimationFrame(() => {
panel.classList.remove("wr:scale-95","wr:opacity-0");
panel.classList.add("wr:scale-100","wr:opacity-100");
});
} else {
panel.classList.add("wr:scale-95","wr:opacity-0");
setTimeout(() => panel.classList.add("wr:hidden"), 150);
}
}
function apply(mode) {
const resolved = mode === "system" ? (mql.matches ? "dark" : "light") : mode;
root.classList.toggle("dark", resolved === "dark");
root.setAttribute("data-theme", resolved);
document.documentElement.style.colorScheme = resolved;
setRootClasses([rootExtraClass, ...(resolved === "dark" ? darkBgClass : lightBgClass)]);
localStorage.setItem(storageKey, mode);
reflectUI(mode);
}
// Init
const saved = (localStorage.getItem(storageKey) ) || "system";
apply(saved);
// react to system changes when mode=system
mql.addEventListener?.("change", () => {
if ((localStorage.getItem(storageKey) ) === "system") apply("system");
});
// Events (scoped to this component)
host.addEventListener("click", (e) => {
const trigger = (e.target )?.closest?.("[data-menu-trigger]");
if (trigger && host.contains(trigger)) {
const expanded = trigger.getAttribute("aria-expanded") === "true";
openMenu(!expanded);
return;
}
const choose = (e.target )?.closest?.("[data-set-theme]");
if (choose && host.contains(choose)) {
apply(choose.getAttribute("data-set-theme") );
openMenu(false);
return;
}
});
// Close on outside click
document.addEventListener("click", (e) => {
if (!host.contains(e.target )) openMenu(false);
});
// Esc to close
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") openMenu(false);
});
})();
</script>
<style is:inline>
/* Check icon ONLY on active item */
#{containerId} [role="menuitemradio"][aria-checked="true"] [data-check] {
opacity: 1 !important;
transform: scale(1) !important;
}
</style>