Fixed Some Bugs
This commit is contained in:
+115
-155
@@ -1,198 +1,158 @@
|
||||
---
|
||||
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; // where to toggle theme, e.g. "html"
|
||||
compact?: boolean; // smaller trigger button
|
||||
darkBgClass?: string[]; // non–pure-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
|
||||
rootSelector?: string;
|
||||
compact?: boolean;
|
||||
showLabel?: boolean;
|
||||
darkBgClass?: string[];
|
||||
lightBgClass?: string[];
|
||||
rootExtraClass?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
storageKey = "wr-theme",
|
||||
rootSelector = "html",
|
||||
compact = false,
|
||||
darkBgClass = ["wr-theme-dark-soft"],
|
||||
lightBgClass = ["wr-theme-light"],
|
||||
rootExtraClass = "",
|
||||
showLabel = false,
|
||||
darkBgClass = [],
|
||||
lightBgClass = [],
|
||||
rootExtraClass = "",
|
||||
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 containerId = `ts-${Math.random().toString(36).slice(2)}`;
|
||||
const icon = "wr:size-4";
|
||||
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>
|
||||
<div id={containerId} class={["wr:inline-flex", className].join(" ")} data-compact={compact ? "1" : ""} {...rest}>
|
||||
<Dropdown openOn="click" placement="bottom-end" offset={10} 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>
|
||||
{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">
|
||||
<!-- 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-set-theme="dark">
|
||||
|
||||
<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-set-theme="system">
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</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] {
|
||||
/* 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;
|
||||
}
|
||||
</style>
|
||||
|
||||
/* 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, 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 = [];
|
||||
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);
|
||||
}
|
||||
|
||||
// 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");
|
||||
});
|
||||
|
||||
// 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>
|
||||
|
||||
Reference in New Issue
Block a user