541 lines
19 KiB
Plaintext
541 lines
19 KiB
Plaintext
---
|
|
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);
|
|
|
|
const AR = showArrow && arrowEl ? 8 : 0; // arrow size
|
|
const SAFE = Math.max(shiftPadding, 8); // viewport padding
|
|
const GAP = offset + AR; // desired gap + arrow
|
|
|
|
let x = 0,
|
|
y = 0;
|
|
|
|
if (side === "bottom") y = t.bottom + GAP;
|
|
else if (side === "top") y = t.top - p.height - GAP;
|
|
else if (side === "right") x = t.right + GAP;
|
|
else if (side === "left") x = t.left - p.width - GAP;
|
|
|
|
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;
|
|
}
|
|
|
|
// Clamp into viewport
|
|
x = Math.max(SAFE, Math.min(x, vw - p.width - SAFE));
|
|
y = Math.max(SAFE, Math.min(y, vh - p.height - SAFE));
|
|
|
|
// Return overflow score (lower is better)
|
|
const over =
|
|
Math.max(0, SAFE - x) +
|
|
Math.max(0, SAFE - y) +
|
|
Math.max(0, x + p.width - (vw - SAFE)) +
|
|
Math.max(0, y + p.height - (vh - SAFE));
|
|
|
|
return { x: Math.round(x), y: Math.round(y), over };
|
|
}
|
|
|
|
function smartPosition() {
|
|
const s = sideOf(placement);
|
|
const a = alignOf(placement);
|
|
|
|
const flipSide = {
|
|
top: "bottom",
|
|
bottom: "top",
|
|
left: "right",
|
|
right: "left",
|
|
}[s];
|
|
|
|
// Try: requested → side flipped → align flipped → both flipped → orthogonal sides
|
|
const aligns = a
|
|
? [a, a === "start" ? "end" : "start", "center"]
|
|
: ["center", "start", "end"];
|
|
const candidates = [];
|
|
|
|
aligns.forEach((al) =>
|
|
candidates.push(`${s}${al === "center" ? "" : "-" + al}`),
|
|
);
|
|
aligns.forEach((al) =>
|
|
candidates.push(
|
|
`${flipSide}${al === "center" ? "" : "-" + al}`,
|
|
),
|
|
);
|
|
|
|
if (s === "top" || s === "bottom") {
|
|
aligns.forEach((al) =>
|
|
candidates.push(`left-${al === "center" ? "start" : al}`),
|
|
);
|
|
aligns.forEach((al) =>
|
|
candidates.push(`right-${al === "center" ? "start" : al}`),
|
|
);
|
|
} else {
|
|
aligns.forEach((al) =>
|
|
candidates.push(`top-${al === "center" ? "start" : al}`),
|
|
);
|
|
aligns.forEach((al) =>
|
|
candidates.push(`bottom-${al === "center" ? "start" : al}`),
|
|
);
|
|
}
|
|
|
|
let best = null;
|
|
for (const cand of candidates) {
|
|
const pos = placeOnce(cand);
|
|
if (!best || pos.over < best.over) best = { cand, ...pos };
|
|
if (pos.over === 0) break; // perfect fit
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function positionPanel() {
|
|
if (!open) return;
|
|
|
|
// Constrain size so the panel never forces overflow
|
|
const vw = document.documentElement.clientWidth;
|
|
const vh = document.documentElement.clientHeight;
|
|
const PAD = Math.max(shiftPadding, 8);
|
|
|
|
// Optional: match trigger width
|
|
if (matchTriggerWidth) {
|
|
const w = Math.max(192, trigger.getBoundingClientRect().width);
|
|
panel.style.minWidth = `${Math.round(w)}px`;
|
|
}
|
|
|
|
panel.style.maxWidth = `${vw - PAD * 2}px`;
|
|
panel.style.maxHeight = `${vh - PAD * 2}px`;
|
|
panel.style.overflow = "auto"; // scroll when needed
|
|
|
|
const best = smartPosition();
|
|
panel.style.left = best.x + "px";
|
|
panel.style.top = best.y + "px";
|
|
|
|
const side = sideOf(best.cand);
|
|
const align = alignOf(best.cand);
|
|
setOriginFor(side, align);
|
|
panel.dataset.side = side;
|
|
|
|
// Arrow
|
|
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 = 8;
|
|
|
|
if (side === "bottom") {
|
|
arrowEl.style.top = -AR + "px";
|
|
const cx = t.left + t.width / 2;
|
|
arrowEl.style.left =
|
|
Math.round(
|
|
Math.max(
|
|
PAD,
|
|
Math.min(
|
|
cx - p.left - aw / 2,
|
|
p.width - PAD - aw,
|
|
),
|
|
),
|
|
) + "px";
|
|
} else if (side === "top") {
|
|
arrowEl.style.bottom = -AR + "px";
|
|
const cx = t.left + t.width / 2;
|
|
arrowEl.style.left =
|
|
Math.round(
|
|
Math.max(
|
|
PAD,
|
|
Math.min(
|
|
cx - p.left - aw / 2,
|
|
p.width - PAD - aw,
|
|
),
|
|
),
|
|
) + "px";
|
|
} else if (side === "right") {
|
|
arrowEl.style.left = -AR + "px";
|
|
const cy = t.top + t.height / 2;
|
|
arrowEl.style.top =
|
|
Math.round(
|
|
Math.max(
|
|
PAD,
|
|
Math.min(
|
|
cy - p.top - ah / 2,
|
|
p.height - PAD - ah,
|
|
),
|
|
),
|
|
) + "px";
|
|
} else if (side === "left") {
|
|
arrowEl.style.right = -AR + "px";
|
|
const cy = t.top + t.height / 2;
|
|
arrowEl.style.top =
|
|
Math.round(
|
|
Math.max(
|
|
PAD,
|
|
Math.min(
|
|
cy - p.top - ah / 2,
|
|
p.height - PAD - ah,
|
|
),
|
|
),
|
|
) + "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>
|