Files
beaver-calendar/src/ui/anchored-panel.svelte
T

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>