Updated Theme Switcher

This commit is contained in:
2025-08-17 09:14:53 +05:30
parent a9a2de2262
commit 6fcb58df49
3 changed files with 177 additions and 297 deletions
+4 -4
View File
@@ -61,7 +61,7 @@ const item =
aria-label={p.label} aria-label={p.label}
> >
<!-- Scope just this swatch to the palette so it inherits --primary --> <!-- Scope just this swatch to the palette so it inherits --primary -->
<span data-swatch-scope data-color={p.key}> <span data-swatch-scope data-color={p.key} class="wr:flex wr:items-center wr:justify-center">
<span <span
class={["wr:inline-block wr:rounded-full wr:ring-2 wr:ring-offset-2 wr:ring-offset-background wr:transition-all wr:group-hover:ring-primary/50", dot].join(" ")} class={["wr:inline-block wr:rounded-full wr:ring-2 wr:ring-offset-2 wr:ring-offset-background wr:transition-all wr:group-hover:ring-primary/50", dot].join(" ")}
data-swatch data-swatch
@@ -85,7 +85,7 @@ const item =
> >
<span class="wr:inline-flex wr:items-center wr:gap-3"> <span class="wr:inline-flex wr:items-center wr:gap-3">
<!-- Trigger dot gets its own scoped palette --> <!-- Trigger dot gets its own scoped palette -->
<span data-current-scope data-color={palettes[0].key}> <span data-current-scope data-color={palettes[0].key} class="wr:flex wr:items-center wr:justify-center">
<span class={["wr:inline-block wr:rounded-full wr:ring-2 wr:ring-offset-2 wr:ring-offset-background", dot].join(" ")} data-swatch /> <span class={["wr:inline-block wr:rounded-full wr:ring-2 wr:ring-offset-2 wr:ring-offset-background", dot].join(" ")} data-swatch />
</span> </span>
<span data-current-label class="wr:text-sm wr:font-medium">{palettes[0].label}</span> <span data-current-label class="wr:text-sm wr:font-medium">{palettes[0].label}</span>
@@ -101,7 +101,7 @@ const item =
aria-expanded="false" aria-expanded="false"
title="Brand color" title="Brand color"
> >
<span data-current-scope data-color={palettes[0].key}> <span data-current-scope data-color={palettes[0].key} class="wr:flex wr:items-center wr:justify-center">
<span class={["wr:inline-block wr:rounded-full wr:ring-2 wr:ring-offset-2 wr:ring-offset-background", dot].join(" ")} data-swatch /> <span class={["wr:inline-block wr:rounded-full wr:ring-2 wr:ring-offset-2 wr:ring-offset-background", dot].join(" ")} data-swatch />
</span> </span>
</button> </button>
@@ -116,7 +116,7 @@ const item =
> >
{palettes.map(p => ( {palettes.map(p => (
<button type="button" role="menuitemradio" aria-checked="false" class={item} data-palette={p.key}> <button type="button" role="menuitemradio" aria-checked="false" class={item} data-palette={p.key}>
<span data-swatch-scope data-color={p.key}> <span data-swatch-scope data-color={p.key} class="wr:flex wr:items-center wr:justify-center">
<span class={["wr:inline-block wr:rounded-full wr:ring-2 wr:ring-offset-2 wr:ring-offset-background", dot].join(" ")} data-swatch /> <span class={["wr:inline-block wr:rounded-full wr:ring-2 wr:ring-offset-2 wr:ring-offset-background", dot].join(" ")} data-swatch />
</span> </span>
<span class="wr:flex-1">{p.label}</span> <span class="wr:flex-1">{p.label}</span>
+80 -199
View File
@@ -3,127 +3,65 @@ import type { HTMLAttributes } from "astro/types";
import { Icon } from "astro-icon/components"; import { Icon } from "astro-icon/components";
interface Props extends HTMLAttributes<"div"> { interface Props extends HTMLAttributes<"div"> {
ui?: "icons" | "menu";
storageKey?: string; storageKey?: string;
rootSelector?: string; rootSelector?: string; // where to toggle theme, e.g. "html"
label?: string; compact?: boolean; // smaller trigger button
compact?: boolean; darkBgClass?: string[]; // nonpure-black dark background class
darkBgClass?: string[]; lightBgClass?: string[]; // light background class
lightBgClass?: string[]; rootExtraClass?: string; // always-on root class (optional)
rootExtraClass?: string; showLabel?: boolean; // show text next to icon in trigger
} }
const { const {
ui = "menu",
storageKey = "wr-theme", storageKey = "wr-theme",
rootSelector = "html", rootSelector = "html",
label = "",
compact = false, compact = false,
darkBgClass = ["wr-theme-dark-soft"], darkBgClass = ["wr-theme-dark-soft"],
lightBgClass = ["wr-theme-light"], lightBgClass = ["wr-theme-light"],
rootExtraClass = "", rootExtraClass = "",
showLabel = false,
class: className, class: className,
...rest ...rest
}: Props = Astro.props; }: Props = Astro.props;
const btn =
"wr:inline-flex wr:items-center wr:gap-2 wr:rounded-xl wr:border wr:border-border wr:bg-background/80 wr:backdrop-blur-sm " +
"wr:text-foreground wr:h-10 wr:px-3 wr:text-sm wr:shadow-sm wr:transition wr:duration-200 wr:ease-out " +
"wr:hover:bg-muted/70 wr:focus-visible:outline-none wr:focus-visible:ring-2 wr:focus-visible:ring-primary/40 wr:focus-visible:ring-offset-2";
const iconBtn = const iconBtn =
"wr:inline-flex wr:items-center wr:justify-center wr:h-9 wr:w-9 wr:rounded-xl wr:border wr:border-border wr:bg-background/80 wr:backdrop-blur-sm " + "wr:inline-flex wr:items-center wr:justify-center wr:h-9 wr:w-9 wr:rounded-xl wr:border wr:border-border wr:bg-background/80 wr:backdrop-blur-sm " +
"wr:text-foreground wr:shadow-sm wr:transition wr:duration-200 wr:ease-out wr:hover:bg-muted/70 " + "wr:text-foreground wr:shadow-sm wr:transition wr:duration-200 wr:ease-out wr:hover:bg-muted/70 " +
"wr:focus-visible:outline-none wr:focus-visible:ring-2 wr:focus-visible:ring-primary/40 wr:focus-visible:ring-offset-2"; "wr:focus-visible:outline-none wr:focus-visible:ring-2 wr:focus-visible:ring-primary/40 wr:focus-visible:ring-offset-2 wr:ring-offset-background";
const menuBtn =
"wr:inline-flex wr:items-center wr:gap-2 wr:rounded-xl wr:border wr:border-border wr:bg-background/80 wr:backdrop-blur-sm " +
"wr:text-foreground wr:h-10 wr:px-3 wr:text-sm wr:shadow-sm wr:transition wr:duration-200 wr:ease-out " +
"wr:hover:bg-muted/70 wr:focus-visible:outline-none wr:focus-visible:ring-2 wr:focus-visible:ring-primary/40 wr:focus-visible:ring-offset-2 wr:ring-offset-background";
const item = 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"; "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 icon = "wr:size-4";
const checkBase = "wr:size-4 wr:opacity-0 wr:scale-90 wr:transition"; const checkBase = "wr:size-4 wr:opacity-0 wr:scale-90 wr:transition";
const containerId = `ts-${Math.random().toString(36).slice(2)}`;
--- ---
<div <div id={containerId} class={["wr:relative", className].join(" ")} {...rest}>
class={["wr:flex wr:items-center wr:gap-3", className].join(" ")} <!-- Trigger: icon-only by default; set showLabel=true to include text -->
{...rest}
>
{label && <span class="wr:text-sm wr:text-muted-foreground">{label}</span>}
{
ui === "icons" && (
<div class="wr:flex wr:items-center wr:gap-2" data-theme-switcher>
<button <button
type="button" type="button"
class={[iconBtn, compact && "wr:h-8 wr:w-8"] class={showLabel ? menuBtn : [iconBtn, compact && "wr:h-8 wr:w-8"].filter(Boolean).join(" ")}
.filter(Boolean)
.join(" ")}
data-set-theme="light"
title="Light"
aria-label="Light"
>
<Icon name="mdi:weather-sunny" class={icon} />
</button>
<button
type="button"
class={[iconBtn, compact && "wr:h-8 wr:w-8"]
.filter(Boolean)
.join(" ")}
data-set-theme="dark"
title="Dark"
aria-label="Dark"
>
<Icon name="mdi:weather-night" class={icon} />
</button>
<button
type="button"
class={[iconBtn, compact && "wr:h-8 wr:w-8"]
.filter(Boolean)
.join(" ")}
data-set-theme="system"
title="System"
aria-label="System"
>
<Icon name="mdi:laptop" class={icon} />
</button>
</div>
)
}
{
ui === "menu" && (
<div class="wr:relative" data-theme-switcher data-theme-menu>
<button
type="button"
class={[btn, "wr:min-w-[10.5rem] wr:justify-between"].join(
" ",
)}
data-menu-trigger data-menu-trigger
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded="false" aria-expanded="false"
title="Theme"
> >
<span class="wr:inline-flex wr:items-center wr:gap-2"> <span class="wr:inline-flex wr:items-center wr:gap-2">
<span data-current-icon="light" class="wr:inline"> <span data-current-icon="light" class="wr:inline"><Icon name="mdi:weather-sunny" class={icon} /></span>
<Icon name="mdi:weather-sunny" class={icon} /> <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> </span>
<span data-current-icon="dark" class="wr:hidden"> {showLabel && <Icon name="mdi:chevron-down" class="wr:size-4 wr:opacity-70" />}
<Icon name="mdi:weather-night" class={icon} />
</span>
<span data-current-icon="system" class="wr:hidden">
<Icon name="mdi:laptop" class={icon} />
</span>
<span
data-current-label
class="wr:text-sm wr:font-medium"
>
System
</span>
</span>
<Icon
name="mdi:chevron-down"
class="wr:size-4 wr:opacity-70"
/>
</button> </button>
<!-- Dropdown -->
<div <div
class="wr:absolute wr:right-0 wr:z-50 wr:mt-2 wr:w-56 wr:rounded-2xl wr:border wr:border-border wr:bg-popover/90 wr:backdrop-blur-md wr:text-popover-foreground wr:shadow-lg wr:p-1.5 wr:hidden class="wr:absolute wr:right-0 wr:z-50 wr:mt-2 wr:w-56 wr:rounded-2xl wr:border wr:border-border wr:bg-popover/90 wr:backdrop-blur-md wr:text-popover-foreground wr:shadow-lg wr:p-1.5 wr:hidden
wr:origin-top-right wr:scale-95 wr:opacity-0 wr:transition wr:duration-150 wr:ease-out" wr:origin-top-right wr:scale-95 wr:opacity-0 wr:transition wr:duration-150 wr:ease-out"
@@ -131,120 +69,69 @@ const checkBase = "wr:size-4 wr:opacity-0 wr:scale-90 wr:transition";
aria-label="Theme" aria-label="Theme"
data-menu-panel data-menu-panel
> >
<button <button type="button" role="menuitemradio" aria-checked="false" class={item} data-set-theme="light">
type="button"
role="menuitemradio"
aria-checked="false"
class={item}
data-set-theme="light"
>
<Icon name="mdi:weather-sunny" class={icon} /> <Icon name="mdi:weather-sunny" class={icon} />
<div class="wr:flex-1 wr:font-medium">Light</div> <div class="wr:flex-1 wr:font-medium">Light</div>
<Icon name="mdi:check" class={checkBase} data-check /> <Icon name="mdi:check" class={checkBase} data-check />
</button> </button>
<button <button type="button" role="menuitemradio" aria-checked="false" class={item} data-set-theme="dark">
type="button"
role="menuitemradio"
aria-checked="false"
class={item}
data-set-theme="dark"
>
<Icon name="mdi:weather-night" class={icon} /> <Icon name="mdi:weather-night" class={icon} />
<div class="wr:flex-1 wr:font-medium">Dark</div> <div class="wr:flex-1 wr:font-medium">Dark</div>
<Icon name="mdi:check" class={checkBase} data-check /> <Icon name="mdi:check" class={checkBase} data-check />
</button> </button>
<button <button type="button" role="menuitemradio" aria-checked="true" class={item} data-set-theme="system">
type="button"
role="menuitemradio"
aria-checked="true"
class={item}
data-set-theme="system"
>
<Icon name="mdi:laptop" class={icon} /> <Icon name="mdi:laptop" class={icon} />
<div class="wr:flex-1 wr:font-medium">System</div> <div class="wr:flex-1 wr:font-medium">System</div>
<Icon name="mdi:check" class={checkBase} data-check /> <Icon name="mdi:check" class={checkBase} data-check />
</button> </button>
</div> </div>
</div> </div>
)
}
</div>
<script <script is:inline define:vars={{ containerId, storageKey, rootSelector, darkBgClass, lightBgClass, rootExtraClass }}>
is:inline
define:vars={{
storageKey,
rootSelector,
darkBgClass,
lightBgClass,
rootExtraClass,
}}
>
(() => { (() => {
const root = const host = document.getElementById(containerId);
rootSelector === "html" if (!host) return;
const root = rootSelector === "html"
? document.documentElement ? document.documentElement
: document.querySelector(rootSelector) || : document.querySelector(rootSelector) || document.documentElement;
document.documentElement;
const mql = window.matchMedia("(prefers-color-scheme: dark)"); const mql = window.matchMedia("(prefers-color-scheme: dark)");
let lastThemeClasses = []; let lastThemeClasses = [];
function setRootClasses(list) { function setRootClasses(list) {
lastThemeClasses.forEach((c) => c && root.classList.remove(c)); lastThemeClasses.forEach(c => c && root.classList.remove(c));
list.filter(Boolean).forEach((c) => root.classList.add(c)); list.filter(Boolean).forEach(c => root.classList.add(c));
lastThemeClasses = list.filter(Boolean); lastThemeClasses = list.filter(Boolean);
} }
function reflectUI(mode) { function reflectUI(mode) {
const wrap = document.querySelector("[data-theme-switcher]"); // Trigger icon + optional label
if (!wrap) return; const trig = host.querySelector("[data-menu-trigger]");
// icon buttons
wrap.querySelectorAll("[data-set-theme]").forEach((btn) => {
const active = btn.getAttribute("data-set-theme") === mode;
btn.classList.toggle("wr:bg-muted/70", active);
});
// menu radios + checks
const panel = wrap.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);
// check icon visibility handled by CSS via [aria-checked="true"] [data-check]
});
const trig = wrap.querySelector("[data-menu-trigger]");
if (trig) { if (trig) {
const labelSpan = trig.querySelector( const labelSpan = trig.querySelector("[data-current-label]");
"[data-current-label]", if (labelSpan) labelSpan.textContent = mode[0].toUpperCase() + mode.slice(1);
); trig.querySelectorAll("[data-current-icon]").forEach(el => {
if (labelSpan) const is = el.getAttribute("data-current-icon") === mode;
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:hidden", !is);
el.classList.toggle("wr:inline", 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(wrap, open) { function openMenu(open) {
const trigger = wrap.querySelector("[data-menu-trigger]"); const trigger = host.querySelector("[data-menu-trigger]");
const panel = wrap.querySelector("[data-menu-panel]"); const panel = host.querySelector("[data-menu-panel]");
trigger?.setAttribute("aria-expanded", open ? "true" : "false"); trigger?.setAttribute("aria-expanded", open ? "true" : "false");
if (!panel) return; if (!panel) return;
if (open) { if (open) {
@@ -260,62 +147,56 @@ const checkBase = "wr:size-4 wr:opacity-0 wr:scale-90 wr:transition";
} }
function apply(mode) { function apply(mode) {
const resolved = const resolved = mode === "system" ? (mql.matches ? "dark" : "light") : mode;
mode === "system" ? (mql.matches ? "dark" : "light") : mode;
root.classList.toggle("dark", resolved === "dark"); root.classList.toggle("dark", resolved === "dark");
root.setAttribute("data-theme", resolved); root.setAttribute("data-theme", resolved);
document.documentElement.style.colorScheme = resolved; document.documentElement.style.colorScheme = resolved;
setRootClasses([ setRootClasses([rootExtraClass, ...(resolved === "dark" ? darkBgClass : lightBgClass)]);
rootExtraClass,
...(resolved === "dark" ? darkBgClass : lightBgClass),
]);
localStorage.setItem(storageKey, mode); localStorage.setItem(storageKey, mode);
reflectUI(mode); reflectUI(mode);
} }
const saved = localStorage.getItem(storageKey) || "system"; // Init
const saved = (localStorage.getItem(storageKey) ) || "system";
apply(saved); apply(saved);
// react to system changes when mode=system
mql.addEventListener?.("change", () => { mql.addEventListener?.("change", () => {
if ((localStorage.getItem(storageKey) || "system") === "system") if ((localStorage.getItem(storageKey) ) === "system") apply("system");
apply("system");
}); });
document.addEventListener("click", (e) => { // Events (scoped to this component)
const menuWrap = e.target.closest?.("[data-theme-menu]"); host.addEventListener("click", (e) => {
const trigger = e.target.closest?.("[data-menu-trigger]"); const trigger = (e.target )?.closest?.("[data-menu-trigger]");
const choose = e.target.closest?.("[data-set-theme]"); if (trigger && host.contains(trigger)) {
const expanded = trigger.getAttribute("aria-expanded") === "true";
openMenu(!expanded);
return;
}
if (choose) { const choose = (e.target )?.closest?.("[data-set-theme]");
if (choose && host.contains(choose)) {
apply(choose.getAttribute("data-set-theme") ); apply(choose.getAttribute("data-set-theme") );
const wrap = choose.closest("[data-theme-menu]"); openMenu(false);
if (wrap) openMenu(wrap, false);
return; return;
} }
if (trigger && menuWrap) {
const expanded =
trigger.getAttribute("aria-expanded") === "true";
openMenu(menuWrap, !expanded);
return;
}
document
.querySelectorAll("[data-theme-menu]")
.forEach((w) => openMenu(w, false));
}); });
// Close on outside click
document.addEventListener("click", (e) => {
if (!host.contains(e.target )) openMenu(false);
});
// Esc to close
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key === "Escape") { if (e.key === "Escape") openMenu(false);
document
.querySelectorAll("[data-theme-menu]")
.forEach((w) => openMenu(w, false));
}
}); });
})(); })();
</script> </script>
<style is:inline> <style is:inline>
/* Show the check icon ONLY on the active item in the menu */ /* Check icon ONLY on active item */
[data-theme-menu] [role="menuitemradio"][aria-checked="true"] [data-check] { #{containerId} [role="menuitemradio"][aria-checked="true"] [data-check] {
opacity: 1 !important; opacity: 1 !important;
transform: scale(1) !important; transform: scale(1) !important;
} }
+3 -4
View File
@@ -8,13 +8,12 @@ import Base from "../layouts/Base.astro";
<div <div
class="wrnx wr:flex wr:items-center wr:flex-col wr:gap-4 wr:w-full wr:h-screen" class="wrnx wr:flex wr:items-center wr:flex-col wr:gap-4 wr:w-full wr:h-screen"
> >
<!-- Icon-only trigger (default) -->
<ThemeSwitcher <ThemeSwitcher
ui="select" darkBgClass={["wr:dark:bg-neutral-500", "wr:text-white"]}
darkBgClass={["wr:dark:bg-neutral-700", "wr:text-white"]}
lightBgClass={["wr:bg-white", "wr:text-black"]} lightBgClass={["wr:bg-white", "wr:text-black"]}
ui="menu"
rootExtraClass=""
/> />
<ColorSwitcher ui="menu" trigger="dot" /> <ColorSwitcher ui="menu" trigger="dot" />
<div> <div>