<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: "dropdown-menu fixed z-50 m-0 max-h-[calc(100dvh-1rem)] w-56 max-w-[calc(100vw-1rem)] divide-y divide-border-default overflow-auto rounded border border-border-default bg-background-default text-sm text-foreground-default shadow-menu"
		},
		variants: {
			align: {
				left: {},
				right: {}
			}
		},
		defaultVariants: {
			align: "left"
		}
	});

	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, { type ButtonVariants } from "./Button.svelte";

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

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

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

	const triggerElement = () =>
		variant === "button"
			? triggerWrapper?.querySelector<HTMLElement>("button, a")
			: triggerButtonEl;

	const focusTrigger = () => {
		triggerElement()?.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 = () => {
		if (open) menuElement?.hidePopover();
	};

	const onToggle = (event: Event) => {
		const { newState } = event as ToggleEvent;
		open = newState === "open";
		if (!open) return;

		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();
			triggerElement()?.click();
		}
	};

	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();
				menuElement?.hidePopover();
				focusTrigger();
				break;
			case "Tab":
				menuElement?.hidePopover();
				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(() => {
		return registerDropdown(group, close);
	});
</script>

<div class={classes.root()}>
	{#if variant === "button"}
		<span bind:this={triggerWrapper} class="contents">
			<Button
				variant={buttonVariant}
				class="px-3"
				popovertarget={menuId}
				popovertargetaction="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()}
			popovertarget={menuId}
			popovertargetaction="toggle"
			onkeydown={onTriggerKeydown}
			aria-haspopup="menu"
			aria-expanded={open}
			aria-controls={menuId}
			aria-label={label}
		>
			{@render trigger()}
		</button>
	{/if}

	<div
		bind:this={menuElement}
		id={menuId}
		data-align={align}
		popover="auto"
		ontoggle={onToggle}
		class={classes.menu({ class: menuClass })}
		role="menu"
		tabindex={-1}
		onkeydown={onMenuKeydown}
	>
		{@render children()}
	</div>
</div>

<style>
	@supports (position-area: bottom) and (position-try: flip-block) {
		.dropdown-menu {
			margin: 0.5rem;
			position-try: flip-block;
		}

		.dropdown-menu[data-align="left"] {
			position-area: block-end span-inline-end;
		}

		.dropdown-menu[data-align="right"] {
			position-area: block-end span-inline-start;
		}
	}
</style>
