<script module lang="ts">
	import { SvelteMap, SvelteSet } from "svelte/reactivity";
	import { tv, type VariantProps } from "tailwind-variants";

	export const dropdown = tv({
		slots: {
			root: "relative inline-block",
			plainTrigger:
				"flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-inherit",
			menu: "absolute z-50 mt-2 w-56 divide-y divide-border-default overflow-hidden rounded border border-border-default bg-background-default text-sm shadow-menu"
		},
		variants: {
			align: {
				left: {
					menu: "left-0"
				},
				right: {
					menu: "right-0"
				}
			},
			open: {
				true: {
					menu: "block"
				},
				false: {
					menu: "hidden"
				}
			}
		},
		defaultVariants: {
			align: "left",
			open: false
		}
	});

	export type DropdownVariants = VariantProps<typeof dropdown>;

	// opening one dropdown in a group closes its siblings.
	const groups = new SvelteMap<string, SvelteSet<() => void>>();

	function registerDropdown(group: string | undefined, close: () => void) {
		if (!group) return () => {};
		let set = groups.get(group);
		if (!set) {
			set = new SvelteSet();
			groups.set(group, set);
		}
		set.add(close);
		return () => {
			set.delete(close);
			if (set.size === 0) groups.delete(group);
		};
	}

	function closeGroupExcept(group: string | undefined, keep: () => void) {
		if (!group) return;
		groups.get(group)?.forEach((close) => {
			if (close !== keep) close();
		});
	}
</script>

<script lang="ts">
	import type { Snippet } from "svelte";
	import { onMount, setContext, tick } from "svelte";
	import Button from "./Button.svelte";

	interface Props {
		id?: string;
		group?: string;
		variant?: "plain" | "button";
		align?: DropdownVariants["align"];
		label?: string;
		trigger: Snippet;
		children: Snippet;
	}

	let { id, group, variant = "plain", align = "left", label, trigger, children }: Props = $props();

	let open = $state(false);
	const classes = $derived(dropdown({ open, align }));
	let root = $state<HTMLElement>();
	let triggerWrapper = $state<HTMLElement>();
	let triggerButtonEl = $state<HTMLElement>();
	const fallbackId = $props.id();
	const menuId = $derived(id ?? fallbackId);

	const focusTrigger = () => {
		if (variant === "button") {
			triggerWrapper?.querySelector<HTMLElement>("button, a")?.focus();
		} else {
			triggerButtonEl?.focus();
		}
	};
	// intentionally not $state: only used for imperative focus, and reactive
	// reads inside DropdownItem's register $effect would loop it forever
	let items: HTMLElement[] = [];

	const close = () => {
		open = false;
	};

	const toggle = () => {
		open = !open;
		if (open) closeGroupExcept(group, close);
	};

	const focusItem = (index: number) => {
		if (items.length === 0) return;
		const wrapped = ((index % items.length) + items.length) % items.length;
		items[wrapped]?.focus();
	};

	const onTriggerKeydown = (event: KeyboardEvent) => {
		if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
			event.preventDefault();
			open = true;
			closeGroupExcept(group, close);
		}
	};

	const onMenuKeydown = (event: KeyboardEvent) => {
		const currentIndex = items.indexOf(event.target as HTMLElement);
		switch (event.key) {
			case "ArrowDown":
				event.preventDefault();
				focusItem(currentIndex + 1);
				break;
			case "ArrowUp":
				event.preventDefault();
				focusItem(currentIndex - 1);
				break;
			case "Home":
				event.preventDefault();
				focusItem(0);
				break;
			case "End":
				event.preventDefault();
				focusItem(items.length - 1);
				break;
			case "Escape":
				event.preventDefault();
				close();
				focusTrigger();
				break;
			case "Tab":
				close();
				break;
		}
	};

	$effect(() => {
		if (open) {
			tick().then(() => items[0]?.focus());
		}
	});

	setContext("dropdown-close", close);
	setContext("dropdown-register", (el: HTMLElement) => {
		// insert in DOM order, not mount order, since conditionally rendered
		// items can register after already-registered items that appear later
		// in the DOM
		const index = items.findIndex(
			(item) => item.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING
		);
		if (index === -1) {
			items.push(el);
		} else {
			items.splice(index, 0, el);
		}
		return () => {
			items = items.filter((item) => item !== el);
		};
	});

	onMount(() => {
		const unregister = registerDropdown(group, close);

		const onDocumentClick = (event: PointerEvent) => {
			if (open && event.target instanceof Node && root && !root.contains(event.target)) close();
		};
		const onKeydown = (event: KeyboardEvent) => {
			if (!open) return;
			if (event.key === "Escape") {
				close();
				focusTrigger();
			}
		};

		document.addEventListener("pointerdown", onDocumentClick);
		document.addEventListener("keydown", onKeydown);

		return () => {
			unregister();
			document.removeEventListener("pointerdown", onDocumentClick);
			document.removeEventListener("keydown", onKeydown);
		};
	});
</script>

<div bind:this={root} class={classes.root()}>
	{#if variant === "button"}
		<span bind:this={triggerWrapper} class="contents">
			<Button
				class="px-3"
				onclick={toggle}
				onkeydown={onTriggerKeydown}
				aria-haspopup="menu"
				aria-expanded={open}
				aria-controls={menuId}
				aria-label={label}
			>
				{@render trigger()}
			</Button>
		</span>
	{:else}
		<button
			bind:this={triggerButtonEl}
			type="button"
			class={classes.plainTrigger()}
			onclick={toggle}
			onkeydown={onTriggerKeydown}
			aria-haspopup="menu"
			aria-expanded={open}
			aria-controls={menuId}
			aria-label={label}
		>
			{@render trigger()}
		</button>
	{/if}

	<div id={menuId} class={classes.menu()} role="menu" tabindex={-1} onkeydown={onMenuKeydown}>
		{@render children()}
	</div>
</div>
