118 lines
3.3 KiB
Svelte
118 lines
3.3 KiB
Svelte
<script lang="ts">
|
|
import { Portal } from "bits-ui";
|
|
import type { Snippet } from "svelte";
|
|
import { onScreen, place } from "../lib/place";
|
|
import { cn } from "../lib/utils";
|
|
import { useView } from "./context";
|
|
|
|
interface Props {
|
|
/** CSS selector for whatever this should sit under. */
|
|
anchor: string;
|
|
/** Off when the panel's contents put the caret somewhere themselves. */
|
|
autofocus?: boolean;
|
|
children: Snippet;
|
|
class?: string;
|
|
onclose: () => void;
|
|
onkeydown?: (event: KeyboardEvent) => void;
|
|
}
|
|
|
|
let {
|
|
anchor,
|
|
autofocus = true,
|
|
children,
|
|
class: className,
|
|
onclose,
|
|
onkeydown,
|
|
}: Props = $props();
|
|
|
|
const view = useView();
|
|
|
|
let panel = $state<HTMLElement | null>(null);
|
|
let at = $state({ top: 0, left: 0 });
|
|
let placed = $state(false);
|
|
|
|
/**
|
|
* Placed by hand rather than by a floating layer: these panels are summoned
|
|
* by a key press, and a floating layer wants a trigger element to hang off.
|
|
*/
|
|
function measure(): void {
|
|
const node = panel;
|
|
if (!node) return;
|
|
|
|
const boxes = [...document.querySelectorAll(anchor)].map((element) =>
|
|
element.getBoundingClientRect(),
|
|
);
|
|
at = place(onScreen(boxes), node.getBoundingClientRect(), {
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
});
|
|
placed = true;
|
|
}
|
|
|
|
function mount(node: HTMLElement) {
|
|
panel = node;
|
|
const win = node.ownerDocument.defaultView ?? window;
|
|
measure();
|
|
if (autofocus) {
|
|
(node.querySelector<HTMLElement>("input") ?? node).focus({
|
|
preventScroll: true,
|
|
});
|
|
}
|
|
|
|
const outside = (event: PointerEvent): void => {
|
|
if (!node.contains(event.target as Node)) onclose();
|
|
};
|
|
const reflow = (): void => measure();
|
|
document.addEventListener("pointerdown", outside, true);
|
|
window.addEventListener("resize", reflow);
|
|
win.addEventListener("keydown", keys, true);
|
|
|
|
return {
|
|
destroy() {
|
|
document.removeEventListener("pointerdown", outside, true);
|
|
window.removeEventListener("resize", reflow);
|
|
win.removeEventListener("keydown", keys, true);
|
|
panel = null;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Read at the window's capture phase rather than on the panel itself.
|
|
* Obsidian's keymap listens there first, and a plugin that claims a key
|
|
* through `app.scope` — Tasks claims Tab — stops the event before it ever
|
|
* reaches an element handler. A listener on the same node still runs after
|
|
* that, so this is the one place the panel's keys cannot be taken away.
|
|
*/
|
|
function keys(event: KeyboardEvent): void {
|
|
if (!panel?.contains(event.target as Node)) return;
|
|
event.stopPropagation();
|
|
if (event.key === "Escape" && !event.defaultPrevented) {
|
|
event.preventDefault();
|
|
onclose();
|
|
return;
|
|
}
|
|
onkeydown?.(event);
|
|
}
|
|
|
|
/** Re-measures once the body has grown or shrunk under the cursor. */
|
|
export function reposition(): void {
|
|
measure();
|
|
}
|
|
</script>
|
|
|
|
<Portal to={view.portal()}>
|
|
<div
|
|
class={cn(
|
|
"fixed z-50 overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-[0_16px_40px_-12px_rgb(0_0_0/45%)] outline-none",
|
|
className,
|
|
)}
|
|
role="dialog"
|
|
style="top: {at.top}px; left: {at.left}px; opacity: {placed ? 1 : 0}"
|
|
tabindex="-1"
|
|
use:mount
|
|
>
|
|
{@render children()}
|
|
</div>
|
|
</Portal>
|