167 lines
6.1 KiB
Plaintext
167 lines
6.1 KiB
Plaintext
---
|
|
import type { HTMLAttributes } from "astro/types";
|
|
import Dropdown from "./Dropdown.astro";
|
|
import { Icon } from "astro-icon/components";
|
|
|
|
interface Props extends HTMLAttributes<"div"> {
|
|
storageKey?: string;
|
|
rootSelector?: string;
|
|
compact?: boolean;
|
|
showLabel?: boolean;
|
|
darkBgClass?: string[];
|
|
lightBgClass?: string[];
|
|
rootExtraClass?: string;
|
|
onChange?: string;
|
|
}
|
|
|
|
const {
|
|
storageKey = "wr-theme",
|
|
rootSelector = "html",
|
|
compact = false,
|
|
showLabel = false,
|
|
darkBgClass = [],
|
|
lightBgClass = [],
|
|
rootExtraClass = "",
|
|
class: className,
|
|
onChange = "onThemeChange",
|
|
...rest
|
|
}: Props = Astro.props;
|
|
|
|
const cookiesValue = Astro.cookies.get(storageKey)?.value ?? "system";
|
|
|
|
const containerId = `ts-${Math.random().toString(36).slice(2)}`;
|
|
const icon = "wr:size-4";
|
|
const item =
|
|
"wr:flex wr:w-full wr:items-center wr:gap-x-3.5 wr:rounded-lg wr:px-3 wr:py-2 wr:text-sm wr:hover:bg-gray-100 wr:dark:hover:bg-neutral-900 wr:focus:bg-gray-100 focus:outline-hidden dark:hover:bg-neutral-700 dark:hover:text-neutral-300 dark:focus:bg-neutral-700"
|
|
|
|
const checkBase = "wr:size-4 wr:opacity-0 wr:scale-90 wr:transition";
|
|
---
|
|
|
|
<div id={containerId} class={["wr:inline-flex", className].join(" ")} data-compact={compact ? "1" : ""} {...rest}>
|
|
<Dropdown openOn="click" placement="bottom-end" offset={5} shiftPadding={10} showArrow={true} closeOnSelect={true}>
|
|
<span slot="trigger" 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>}
|
|
<svg data-dd-chev class="wr:size-4 wr:opacity-70 wr:transition-transform wr:duration-150" viewBox="0 0 24 24" fill="none">
|
|
<path d="M7 10l5 5 5-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
</span>
|
|
|
|
<!-- Note: each item carries data-owner={containerId} so we can find it after portal -->
|
|
<button type="button" role="menuitemradio" aria-checked="false" class={item} data-dd-item data-set-theme="light" data-owner={containerId}>
|
|
<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-dd-item data-set-theme="dark" data-owner={containerId}>
|
|
<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-dd-item data-set-theme="system" data-owner={containerId}>
|
|
<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>
|
|
</Dropdown>
|
|
</div>
|
|
|
|
<style is:inline>
|
|
/* Compact trigger tweak without touching Dropdown source */
|
|
#{containerId}[data-compact="1"] [data-dd-trigger]{
|
|
height: 2.25rem;
|
|
padding-left: .5rem;
|
|
padding-right: .5rem;
|
|
}
|
|
|
|
/* Show check icon ONLY on active */
|
|
#{containerId} [role="menuitemradio"][aria-checked="true"] [data-check]{
|
|
opacity: 1 !important;
|
|
transform: scale(1) !important;
|
|
}
|
|
|
|
/* Subtle active row styling */
|
|
#{containerId} [role="menuitemradio"][aria-checked="true"]{
|
|
background-color: color-mix(in hsl, hsl(var(--muted)) 70%, transparent);
|
|
}
|
|
</style>
|
|
|
|
<script is:inline define:vars={{ containerId, storageKey, cookiesValue, rootSelector, darkBgClass, lightBgClass, rootExtraClass, onChange }}>
|
|
(() => {
|
|
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 = [];
|
|
const itemSelector = `[data-set-theme][data-owner="${containerId}"]`;
|
|
|
|
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) {
|
|
const label = host.querySelector("[data-current-label]");
|
|
if (label) label.textContent = mode[0].toUpperCase() + mode.slice(1);
|
|
|
|
host.querySelectorAll("[data-current-icon]").forEach(el => {
|
|
const on = el.getAttribute("data-current-icon") === mode;
|
|
el.classList.toggle("wr:hidden", !on);
|
|
el.classList.toggle("wr:inline", on);
|
|
});
|
|
|
|
// Items are portaled; query document by owner
|
|
document.querySelectorAll(itemSelector).forEach((el) => {
|
|
const active = el.getAttribute("data-set-theme") === mode;
|
|
el.setAttribute("aria-checked", active ? "true" : "false");
|
|
});
|
|
}
|
|
|
|
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);
|
|
|
|
const fn = window[onChange];
|
|
if (typeof fn === "function") fn(mode);
|
|
}
|
|
|
|
// Init
|
|
const saved = cookiesValue || (localStorage.getItem(storageKey)) || "system";
|
|
apply(saved);
|
|
|
|
// React to system changes when mode=system
|
|
mql.addEventListener?.("change", () => {
|
|
if ((localStorage.getItem(storageKey)) === "system") apply("system");
|
|
});
|
|
|
|
// Listen globally because panel is portaled outside host
|
|
document.addEventListener("click", (e) => {
|
|
const btn = (e.target)?.closest?.(itemSelector);
|
|
if (!btn) return;
|
|
apply(btn.getAttribute("data-set-theme"));
|
|
});
|
|
})();
|
|
</script>
|