Added DropDown and Updated Config with Theme and Color Switcher
This commit is contained in:
@@ -0,0 +1,518 @@
|
||||
---
|
||||
import type { HTMLAttributes } from "astro/types";
|
||||
|
||||
interface Props extends HTMLAttributes<"div"> {
|
||||
openOn?: "click" | "hover" | "contextmenu";
|
||||
placement?:
|
||||
| "bottom-start"
|
||||
| "bottom"
|
||||
| "bottom-end"
|
||||
| "top-start"
|
||||
| "top"
|
||||
| "top-end"
|
||||
| "right-start"
|
||||
| "right"
|
||||
| "right-end"
|
||||
| "left-start"
|
||||
| "left"
|
||||
| "left-end";
|
||||
offset?: number;
|
||||
shiftPadding?: number;
|
||||
showArrow?: boolean;
|
||||
closeOnSelect?: boolean;
|
||||
disabled?: boolean;
|
||||
matchTriggerWidth?: boolean;
|
||||
initialOpen?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
openOn = "click",
|
||||
placement = "bottom-start",
|
||||
offset = 8,
|
||||
shiftPadding = 8,
|
||||
showArrow = true,
|
||||
closeOnSelect = true,
|
||||
disabled = false,
|
||||
matchTriggerWidth = false,
|
||||
initialOpen = false,
|
||||
class: className,
|
||||
...rest
|
||||
}: Props = Astro.props;
|
||||
|
||||
const id = `dd-${Math.random().toString(36).slice(2)}`;
|
||||
---
|
||||
|
||||
<div
|
||||
id={id}
|
||||
class={["wr:relative wr:inline-flex", className].join(" ")}
|
||||
{...rest}
|
||||
data-dd-root
|
||||
data-open-on={openOn}
|
||||
data-placement={placement}
|
||||
data-offset={offset}
|
||||
data-shift-padding={shiftPadding}
|
||||
data-show-arrow={showArrow ? "1" : ""}
|
||||
data-close-on-select={closeOnSelect ? "1" : ""}
|
||||
data-match-trigger-width={matchTriggerWidth ? "1" : ""}
|
||||
data-initial-open={initialOpen ? "1" : ""}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="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"
|
||||
data-dd-trigger
|
||||
aria-haspopup="menu"
|
||||
aria-expanded="false"
|
||||
aria-controls={`${id}-menu`}
|
||||
disabled={disabled}
|
||||
>
|
||||
<slot name="trigger">
|
||||
<span>Menu</span>
|
||||
</slot>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id={`${id}-menu`}
|
||||
class="wr:hidden wr:z-50 wr:min-w-60 wr:rounded-lg wr:bg-white wr:shadow-md wr:mt-2
|
||||
wr:ring-1 wr:ring-border/70 wr:dark:bg-neutral-800 wr:dark:ring-neutral-700
|
||||
wr:will-change-[transform,opacity] wr:transform-gpu
|
||||
wr:transition-[transform,opacity] wr:duration-200 wr:ease-[cubic-bezier(0.16,1,0.3,1)]
|
||||
wr:origin-[var(--dd-origin,top_left)] wr:translate-y-1 wr:scale-95 wr:opacity-0"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
data-dd-panel
|
||||
aria-labelledby={id}
|
||||
>
|
||||
{
|
||||
showArrow && (
|
||||
<div data-dd-arrow class="wr:absolute wr:size-2 wr:rotate-45" />
|
||||
)
|
||||
}
|
||||
|
||||
<div class="wr:flex wr:flex-col wr:py-0.5" data-dd-list>
|
||||
<slot>
|
||||
<button
|
||||
class="wr:text-left wr:px-3 wr:py-2 wr:rounded-xl wr:text-sm wr:hover:bg-muted/70 wr:transition-colors"
|
||||
role="menuitem"
|
||||
data-dd-item
|
||||
>
|
||||
Item One
|
||||
</button>
|
||||
<button
|
||||
class="wr:text-left wr:px-3 wr:py-2 wr:rounded-xl wr:text-sm wr:hover:bg-muted/70 wr:transition-colors"
|
||||
role="menuitem"
|
||||
data-dd-item
|
||||
>
|
||||
Item Two
|
||||
</button>
|
||||
<div class="wr:h-px wr:bg-border/60 wr:my-1" aria-hidden="true">
|
||||
</div>
|
||||
<button
|
||||
class="wr:text-left wr:px-3 wr:py-2 wr:rounded-xl wr:text-sm wr:text-danger wr:hover:bg-danger/10 wr:transition-colors"
|
||||
role="menuitem"
|
||||
data-dd-item
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Single outline (no double rings) handled via ring utilities above */
|
||||
|
||||
/* Arrow: no border — soft shadow only, so it blends with the panel ring */
|
||||
[data-dd-arrow] {
|
||||
background: hsl(var(--popover));
|
||||
filter: drop-shadow(0 8px 14px rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
|
||||
/* Rotate any user-provided chevron (add data-dd-chev on your icon) */
|
||||
[data-dd-root][data-open="1"] [data-dd-chev] {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Open state animation */
|
||||
[data-dd-panel].is-open {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="module" is:inline define:vars={{ id }}>
|
||||
(() => {
|
||||
const root = document.getElementById(id);
|
||||
if (!root) return;
|
||||
|
||||
const trigger = root.querySelector("[data-dd-trigger]");
|
||||
const panel = root.querySelector("[data-dd-panel]");
|
||||
const arrowEl = root.querySelector("[data-dd-arrow]");
|
||||
|
||||
const openOn = root.dataset.openOn || "click";
|
||||
const placement = root.dataset.placement || "bottom-start";
|
||||
const offset = Number(root.dataset.offset || 8);
|
||||
const shiftPadding = Number(root.dataset.shiftPadding || 8);
|
||||
const showArrow = !!root.dataset.showArrow;
|
||||
const closeOnSelect = !!root.dataset.closeOnSelect;
|
||||
const matchTriggerWidth = !!root.dataset.matchTriggerWidth;
|
||||
const initialOpen = !!root.dataset.initialOpen;
|
||||
|
||||
// Portal for fixed positioning
|
||||
const portal = document.createElement("div");
|
||||
portal.style.position = "fixed";
|
||||
document.body.appendChild(portal);
|
||||
panel.style.position = "fixed";
|
||||
portal.appendChild(panel);
|
||||
|
||||
let open = false,
|
||||
hoverTimer = 0;
|
||||
|
||||
const sideOf = (p) => p.split("-")[0];
|
||||
const alignOf = (p) => p.split("-")[1] || "center";
|
||||
|
||||
function setOriginFor(side, align) {
|
||||
const mapSide =
|
||||
{ top: "bottom", bottom: "top", left: "right", right: "left" }[
|
||||
side
|
||||
] || "top";
|
||||
const mapAlign = (() => {
|
||||
if (side === "top" || side === "bottom") {
|
||||
if (align === "start") return "left";
|
||||
if (align === "end") return "right";
|
||||
return "center";
|
||||
} else {
|
||||
if (align === "start") return "top";
|
||||
if (align === "end") return "bottom";
|
||||
return "center";
|
||||
}
|
||||
})();
|
||||
panel.style.setProperty("--dd-origin", `${mapSide} ${mapAlign}`);
|
||||
}
|
||||
|
||||
function placeOnce(pref) {
|
||||
const t = trigger.getBoundingClientRect();
|
||||
const p = panel.getBoundingClientRect();
|
||||
const vw = document.documentElement.clientWidth;
|
||||
const vh = document.documentElement.clientHeight;
|
||||
|
||||
const side = sideOf(pref);
|
||||
const align = alignOf(pref);
|
||||
let x = 0,
|
||||
y = 0;
|
||||
|
||||
if (side === "bottom") y = t.bottom + offset;
|
||||
else if (side === "top") y = t.top - p.height - offset;
|
||||
else if (side === "right") x = t.right + offset;
|
||||
else if (side === "left") x = t.left - p.width - offset;
|
||||
|
||||
if (side === "top" || side === "bottom") {
|
||||
if (align === "start") x = t.left;
|
||||
else if (align === "end") x = t.right - p.width;
|
||||
else x = t.left + (t.width - p.width) / 2;
|
||||
} else {
|
||||
if (align === "start") y = t.top;
|
||||
else if (align === "end") y = t.bottom - p.height;
|
||||
else y = t.top + (t.height - p.height) / 2;
|
||||
}
|
||||
|
||||
const pad = shiftPadding;
|
||||
x = Math.max(pad, Math.min(x, vw - p.width - pad));
|
||||
y = Math.max(pad, Math.min(y, vh - p.height - pad));
|
||||
return { x, y }; // no Math.round
|
||||
}
|
||||
|
||||
function positionPanel() {
|
||||
if (!open) return;
|
||||
if (matchTriggerWidth) {
|
||||
const w = Math.max(192, trigger.getBoundingClientRect().width);
|
||||
panel.style.minWidth = `${w}px`;
|
||||
}
|
||||
const best = smartPosition();
|
||||
panel.style.left = `${best.x}px`;
|
||||
panel.style.top = `${best.y}px`;
|
||||
|
||||
const side = sideOf(best.cand),
|
||||
align = alignOf(best.cand);
|
||||
setOriginFor(side, align);
|
||||
panel.dataset.side = side;
|
||||
|
||||
if (showArrow && arrowEl) {
|
||||
Object.assign(arrowEl.style, {
|
||||
left: "",
|
||||
top: "",
|
||||
right: "",
|
||||
bottom: "",
|
||||
});
|
||||
const t = trigger.getBoundingClientRect();
|
||||
const p = panel.getBoundingClientRect();
|
||||
const aw = arrowEl.getBoundingClientRect().width;
|
||||
const ah = arrowEl.getBoundingClientRect().height;
|
||||
const AR = 7;
|
||||
|
||||
if (side === "bottom") {
|
||||
arrowEl.style.top = `${-AR}px`;
|
||||
const cx = t.left + t.width / 2;
|
||||
arrowEl.style.left = `${cx - p.left - aw / 2}px`;
|
||||
} else if (side === "top") {
|
||||
arrowEl.style.bottom = `${-AR}px`;
|
||||
const cx = t.left + t.width / 2;
|
||||
arrowEl.style.left = `${cx - p.left - aw / 2}px`;
|
||||
} else if (side === "right") {
|
||||
arrowEl.style.left = `${-AR}px`;
|
||||
const cy = t.top + t.height / 2;
|
||||
arrowEl.style.top = `${cy - p.top - ah / 2}px`;
|
||||
} else if (side === "left") {
|
||||
arrowEl.style.right = `${-AR}px`;
|
||||
const cy = t.top + t.height / 2;
|
||||
arrowEl.style.top = `${cy - p.top - ah / 2}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function smartPosition() {
|
||||
const s = sideOf(placement),
|
||||
a = alignOf(placement);
|
||||
const flips = {
|
||||
top: "bottom",
|
||||
bottom: "top",
|
||||
left: "right",
|
||||
right: "left",
|
||||
};
|
||||
const candidates = [placement, `${flips[s]}${a ? `-${a}` : ""}`];
|
||||
if (s === "top" || s === "bottom")
|
||||
candidates.push(
|
||||
`left-${a || "start"}`,
|
||||
`right-${a || "start"}`,
|
||||
);
|
||||
else
|
||||
candidates.push(
|
||||
`top-${a || "start"}`,
|
||||
`bottom-${a || "start"}`,
|
||||
);
|
||||
|
||||
const vw = document.documentElement.clientWidth;
|
||||
const vh = document.documentElement.clientHeight;
|
||||
|
||||
let best = null;
|
||||
for (const cand of candidates) {
|
||||
const pos = placeOnce(cand);
|
||||
const p = panel.getBoundingClientRect();
|
||||
const w = p.width,
|
||||
h = p.height;
|
||||
const over =
|
||||
Math.max(0, -pos.x) +
|
||||
Math.max(0, -pos.y) +
|
||||
Math.max(0, pos.x + w - vw) +
|
||||
Math.max(0, pos.y + h - vh);
|
||||
if (!best || over < best.score)
|
||||
best = { cand, ...pos, score: over };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function positionPanel() {
|
||||
if (!open) return;
|
||||
if (matchTriggerWidth) {
|
||||
const w = Math.max(192, trigger.getBoundingClientRect().width);
|
||||
panel.style.minWidth = `${Math.round(w)}px`;
|
||||
}
|
||||
const best = smartPosition();
|
||||
panel.style.left = best.x + "px";
|
||||
panel.style.top = best.y + "px";
|
||||
|
||||
const side = sideOf(best.cand),
|
||||
align = alignOf(best.cand);
|
||||
setOriginFor(side, align);
|
||||
panel.dataset.side = side;
|
||||
|
||||
if (showArrow && arrowEl) {
|
||||
Object.assign(arrowEl.style, {
|
||||
left: "",
|
||||
top: "",
|
||||
right: "",
|
||||
bottom: "",
|
||||
});
|
||||
const t = trigger.getBoundingClientRect();
|
||||
const p = panel.getBoundingClientRect();
|
||||
const aw = arrowEl.getBoundingClientRect().width;
|
||||
const ah = arrowEl.getBoundingClientRect().height;
|
||||
const AR = 7;
|
||||
|
||||
if (side === "bottom") {
|
||||
arrowEl.style.top = -AR + "px";
|
||||
const cx = t.left + t.width / 2;
|
||||
arrowEl.style.left =
|
||||
Math.round(cx - p.left - aw / 2) + "px";
|
||||
} else if (side === "top") {
|
||||
arrowEl.style.bottom = -AR + "px";
|
||||
const cx = t.left + t.width / 2;
|
||||
arrowEl.style.left =
|
||||
Math.round(cx - p.left - aw / 2) + "px";
|
||||
} else if (side === "right") {
|
||||
arrowEl.style.left = -AR + "px";
|
||||
const cy = t.top + t.height / 2;
|
||||
arrowEl.style.top = Math.round(cy - p.top - ah / 2) + "px";
|
||||
} else if (side === "left") {
|
||||
arrowEl.style.right = -AR + "px";
|
||||
const cy = t.top + t.height / 2;
|
||||
arrowEl.style.top = Math.round(cy - p.top - ah / 2) + "px";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setOpen(v, { focusFirst = false } = {}) {
|
||||
if (open === v) return;
|
||||
open = v;
|
||||
root.dataset.open = open ? "1" : "";
|
||||
trigger.setAttribute("aria-expanded", String(open));
|
||||
|
||||
if (open) {
|
||||
// 1) Make present but invisible, then position to final spot
|
||||
panel.style.visibility = "hidden";
|
||||
panel.classList.remove("wr:hidden");
|
||||
// enforce start state (closed pose)
|
||||
panel.classList.remove("is-open");
|
||||
panel.classList.add(
|
||||
"wr:translate-y-1",
|
||||
"wr:scale-95",
|
||||
"wr:opacity-0",
|
||||
);
|
||||
|
||||
positionPanel(); // measure & place before user sees it
|
||||
|
||||
// 2) Reveal and animate to open pose on next frame
|
||||
requestAnimationFrame(() => {
|
||||
panel.style.visibility = "";
|
||||
panel.classList.add("is-open");
|
||||
panel.classList.remove(
|
||||
"wr:translate-y-1",
|
||||
"wr:scale-95",
|
||||
"wr:opacity-0",
|
||||
);
|
||||
});
|
||||
|
||||
// listeners while open
|
||||
window.addEventListener("resize", positionPanel, {
|
||||
passive: true,
|
||||
});
|
||||
window.addEventListener("scroll", positionPanel, {
|
||||
passive: true,
|
||||
});
|
||||
if (focusFirst) {
|
||||
panel
|
||||
.querySelector(
|
||||
"[data-dd-item], [role='menuitem'], a[href], button:not([disabled])",
|
||||
)
|
||||
?.focus?.();
|
||||
}
|
||||
} else {
|
||||
// animate to closed pose, then hide on transition end
|
||||
const onDone = (ev) => {
|
||||
if (ev.target !== panel) return; // ignore children transitions
|
||||
panel.removeEventListener("transitionend", onDone);
|
||||
panel.classList.add("wr:hidden");
|
||||
};
|
||||
|
||||
panel.addEventListener("transitionend", onDone);
|
||||
panel.classList.remove("is-open");
|
||||
panel.classList.add(
|
||||
"wr:translate-y-1",
|
||||
"wr:scale-95",
|
||||
"wr:opacity-0",
|
||||
);
|
||||
|
||||
window.removeEventListener("resize", positionPanel);
|
||||
window.removeEventListener("scroll", positionPanel);
|
||||
}
|
||||
}
|
||||
|
||||
const openDelayed = () => {
|
||||
clearTimeout(hoverTimer);
|
||||
hoverTimer = window.setTimeout(() => setOpen(true), 60);
|
||||
};
|
||||
const closeDelayed = () => {
|
||||
clearTimeout(hoverTimer);
|
||||
hoverTimer = window.setTimeout(() => setOpen(false), 120);
|
||||
};
|
||||
|
||||
if (openOn === "click") {
|
||||
trigger.addEventListener("click", () => setOpen(!open));
|
||||
} else if (openOn === "hover") {
|
||||
trigger.addEventListener("pointerenter", openDelayed);
|
||||
trigger.addEventListener("pointerleave", closeDelayed);
|
||||
panel.addEventListener("pointerenter", () =>
|
||||
clearTimeout(hoverTimer),
|
||||
);
|
||||
panel.addEventListener("pointerleave", closeDelayed);
|
||||
trigger.addEventListener("click", (e) => e.preventDefault());
|
||||
} else if (openOn === "contextmenu") {
|
||||
trigger.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
setOpen(true, { focusFirst: true });
|
||||
positionPanel();
|
||||
});
|
||||
}
|
||||
|
||||
trigger.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setOpen(true, { focusFirst: true });
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setOpen(!open, { focusFirst: true });
|
||||
}
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
});
|
||||
|
||||
panel.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
trigger.focus();
|
||||
}
|
||||
if (e.key === "Tab") setOpen(false);
|
||||
});
|
||||
|
||||
document.addEventListener("pointerdown", (e) => {
|
||||
if (!open) return;
|
||||
if (root.contains(e.target) || panel.contains(e.target)) return;
|
||||
setOpen(false);
|
||||
});
|
||||
|
||||
panel.addEventListener("click", (e) => {
|
||||
const item = e.target.closest(
|
||||
"[data-dd-item], [role='menuitem'], a[href], button",
|
||||
);
|
||||
if (item && closeOnSelect) setOpen(false);
|
||||
});
|
||||
|
||||
if (initialOpen) setOpen(true);
|
||||
|
||||
document.addEventListener("astro:before-swap", () => {
|
||||
try {
|
||||
panel.remove();
|
||||
} catch {}
|
||||
try {
|
||||
portal.remove();
|
||||
} catch {}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
[data-dd-arrow] {
|
||||
background: hsl(var(--popover));
|
||||
filter: drop-shadow(0 8px 14px rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-dd-panel] {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user