mirror of
https://seed.radicle.dev/rad:z4D5UCArafTzTQpDZNQRuqswh3ury.git
synced 2026-11-11 06:10:37 +08:00
185 lines
4.7 KiB
Svelte
185 lines
4.7 KiB
Svelte
<script lang="ts">
|
|
import type { Snippet } from "svelte";
|
|
|
|
import { autoUpdate, computePosition, flip, shift } from "@floating-ui/dom";
|
|
import { onMount } from "svelte";
|
|
|
|
import { portal } from "@app/lib/portal";
|
|
|
|
import { getScrollViewport } from "@app/components/ScrollArea.svelte";
|
|
|
|
interface Props {
|
|
x: number;
|
|
y: number;
|
|
target?: HTMLElement;
|
|
onclose: () => void;
|
|
children: Snippet;
|
|
}
|
|
|
|
const { x, y, target, onclose, children }: Props = $props();
|
|
|
|
const getViewport = getScrollViewport();
|
|
|
|
function findScrollParent(el: HTMLElement | undefined): HTMLElement {
|
|
let node: HTMLElement | null = el ?? null;
|
|
while (node && node !== document.body) {
|
|
const style = getComputedStyle(node);
|
|
const overflowY = style.overflowY;
|
|
const overflowX = style.overflowX;
|
|
const scrolls =
|
|
(overflowY === "auto" ||
|
|
overflowY === "scroll" ||
|
|
overflowX === "auto" ||
|
|
overflowX === "scroll") &&
|
|
(node.scrollHeight > node.clientHeight ||
|
|
node.scrollWidth > node.clientWidth);
|
|
if (scrolls) return node;
|
|
node = node.parentElement;
|
|
}
|
|
return document.scrollingElement as HTMLElement;
|
|
}
|
|
|
|
function menuItems(): HTMLElement[] {
|
|
if (!menuEl) return [];
|
|
return Array.from(
|
|
menuEl.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
|
);
|
|
}
|
|
|
|
function focusItem(index: number) {
|
|
const items = menuItems();
|
|
if (items.length === 0) return;
|
|
const wrapped = ((index % items.length) + items.length) % items.length;
|
|
items[wrapped].focus();
|
|
}
|
|
|
|
function onMenuKeydown(ev: KeyboardEvent) {
|
|
const items = menuItems();
|
|
if (items.length === 0) return;
|
|
const current = items.findIndex(el => el === document.activeElement);
|
|
switch (ev.key) {
|
|
case "ArrowDown":
|
|
ev.preventDefault();
|
|
focusItem(current + 1);
|
|
break;
|
|
case "ArrowUp":
|
|
ev.preventDefault();
|
|
focusItem(current - 1);
|
|
break;
|
|
case "Home":
|
|
ev.preventDefault();
|
|
focusItem(0);
|
|
break;
|
|
case "End":
|
|
ev.preventDefault();
|
|
focusItem(items.length - 1);
|
|
break;
|
|
}
|
|
}
|
|
|
|
function onMenuClick(ev: MouseEvent) {
|
|
const el = ev.target as HTMLElement | null;
|
|
if (el?.closest('[role="menuitem"]')) onclose();
|
|
}
|
|
|
|
let menuEl: HTMLDivElement | undefined = $state();
|
|
|
|
const virtualEl = {
|
|
getBoundingClientRect: () => ({
|
|
x,
|
|
y,
|
|
top: y,
|
|
left: x,
|
|
right: x,
|
|
bottom: y,
|
|
width: 0,
|
|
height: 0,
|
|
toJSON: () => undefined,
|
|
}),
|
|
};
|
|
|
|
function onWindowClick(ev: MouseEvent | TouchEvent) {
|
|
if (!menuEl) return;
|
|
if (!ev.composedPath().includes(menuEl)) onclose();
|
|
}
|
|
|
|
function onWindowKeydown(ev: KeyboardEvent) {
|
|
if (ev.key === "Escape") onclose();
|
|
}
|
|
|
|
onMount(() => {
|
|
const ac = new AbortController();
|
|
const { signal } = ac;
|
|
|
|
requestAnimationFrame(() => {
|
|
if (signal.aborted) return;
|
|
window.addEventListener("click", onWindowClick, { signal });
|
|
window.addEventListener("contextmenu", onWindowClick, { signal });
|
|
window.addEventListener("mousedown", onWindowClick, { signal });
|
|
window.addEventListener("keydown", onWindowKeydown, { signal });
|
|
menuEl?.focus({ preventScroll: true });
|
|
});
|
|
|
|
const scrollParent = getViewport() ?? findScrollParent(target);
|
|
const preventScroll = (ev: Event) => ev.preventDefault();
|
|
scrollParent.addEventListener("wheel", preventScroll, {
|
|
passive: false,
|
|
signal,
|
|
});
|
|
scrollParent.addEventListener("touchmove", preventScroll, {
|
|
passive: false,
|
|
signal,
|
|
});
|
|
|
|
return () => ac.abort();
|
|
});
|
|
|
|
$effect(() => {
|
|
if (!menuEl) return;
|
|
return autoUpdate(virtualEl, menuEl, () => {
|
|
void computePosition(virtualEl, menuEl!, {
|
|
strategy: "fixed",
|
|
placement: "bottom-start",
|
|
middleware: [flip(), shift({ padding: 8 })],
|
|
}).then(({ x: px, y: py }) => {
|
|
if (menuEl) {
|
|
menuEl.style.left = `${px}px`;
|
|
menuEl.style.top = `${py}px`;
|
|
menuEl.style.visibility = "visible";
|
|
}
|
|
});
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
.menu {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
visibility: hidden;
|
|
z-index: 100;
|
|
min-width: 12rem;
|
|
padding: 0.25rem;
|
|
background-color: var(--color-surface-canvas);
|
|
border: 1px solid var(--color-border-subtle);
|
|
border-radius: var(--border-radius-md);
|
|
box-shadow: var(--elevation-low);
|
|
}
|
|
.menu:focus {
|
|
outline: none;
|
|
}
|
|
</style>
|
|
|
|
<div
|
|
use:portal
|
|
bind:this={menuEl}
|
|
class="menu"
|
|
role="menu"
|
|
tabindex="-1"
|
|
onclick={onMenuClick}
|
|
onkeydown={onMenuKeydown}
|
|
oncontextmenu={e => e.preventDefault()}>
|
|
{@render children()}
|
|
</div>
|