<script module lang="ts">
import type { Snippet } from "svelte";
import { SvelteMap, SvelteSet } from "svelte/reactivity";
import { tv, type VariantProps } from "tailwind-variants";
import type { ButtonVariants } from "#lib/components/ui/Button.svelte";
import type { PopoverOrigin } from "#lib/components/ui/popover.svelte.ts";
export const dropdown = tv({
slots: {
root: "relative inline-flex",
plainTrigger:
"min-h-0 gap-2xs border-0 bg-transparent p-none text-inherit hover:bg-transparent aria-expanded:bg-transparent",
menu: "dropdown-menu fixed inset-auto z-50 m-none h-fit max-h-[calc(100dvh-1rem)] w-56 max-w-[calc(100vw-1rem)] touch-pan-y overflow-auto overscroll-contain rounded-md border-0 bg-background-default p-1 text-foreground-default shadow-lg edge-ring [-webkit-overflow-scrolling:touch] [&>hr]:-mx-1 [&>hr]:my-1 [&>hr]:w-auto"
},
variants: {
align: {
left: {},
right: {}
},
width: {
fixed: {},
fit: {
menu: "hidden w-fit flex-col open:flex [&>a]:w-auto [&>button]:w-auto"
}
}
},
defaultVariants: {
align: "left",
width: "fixed"
}
});
export type DropdownVariants = VariantProps<typeof dropdown>;
export interface DropdownProps {
id?: string;
group?: string;
variant?: "plain" | "button";
buttonVariant?: ButtonVariants["variant"];
size?: ButtonVariants["size"];
square?: boolean;
align?: DropdownVariants["align"];
side?: "bottom" | "top";
width?: DropdownVariants["width"];
label?: string;
disabled?: boolean;
menuClass?: string;
triggerClass?: string;
matchTriggerWidth?: boolean;
class?: string;
testid?: string;
trigger: Snippet;
children: Snippet;
xOffset?: number;
title?: string;
origin?: PopoverOrigin;
onOpenChange?: (open: boolean) => void;
}
// 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 { onMount, setContext, tick } from "svelte";
import { popoverOrigin } from "#lib/components/ui/popover.svelte.ts";
import { menuItems } from "#lib/components/ui/menuItems.ts";
import Button from "#lib/components/ui/Button.svelte";
let {
id,
group,
variant = "plain",
buttonVariant = "default",
square,
size,
align = "left",
side = "bottom",
width = "fixed",
label,
disabled = false,
menuClass,
triggerClass,
matchTriggerWidth = false,
class: className,
testid,
trigger,
children,
xOffset,
title,
origin,
onOpenChange
}: DropdownProps = $props();
let open = $state(false);
let closing = $state(false);
let fixedTrigger = $state(false);
const classes = $derived(dropdown({ align, width }));
let triggerElement = $state<HTMLAnchorElement | HTMLButtonElement>();
let menuElement = $state<HTMLElement>();
const fallbackId = $props.id();
const menuId = $derived(id ?? fallbackId);
const anchorName = $derived(`--dropdown-${menuId.replace(/[^a-zA-Z0-9_-]/g, "-")}`);
const focusTrigger = () => {
triggerElement?.focus();
};
const items = menuItems();
const reducedMotion = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
const closeListeners = new SvelteSet<() => void>();
const close = () => {
const menu = menuElement;
if (!menu || !open || closing) return;
closeListeners.forEach((listener) => listener());
if (reducedMotion()) {
menu.hidePopover();
return;
}
closing = true;
const done = (event?: TransitionEvent) => {
if (event && (event.target !== menu || event.propertyName !== "opacity")) return;
menu.removeEventListener("transitionend", done);
clearTimeout(fallback);
if (!closing) return;
closing = false;
const hadFocus = menu.contains(document.activeElement);
menu.hidePopover();
if (hadFocus) focusTrigger();
};
const fallback = setTimeout(done, 300);
menu.addEventListener("transitionend", done);
};
const show = () => {
closing = false;
if (!open) menuElement?.showPopover();
};
export function showMenu() {
show();
}
export function hideMenu() {
close();
}
const toggleMenu = () => {
if (open && !closing) close();
else show();
};
const onToggle = (event: Event) => {
const { newState } = event as ToggleEvent;
open = newState === "open";
onOpenChange?.(open);
if (!open) return;
closeGroupExcept(group, close);
};
$effect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (menuElement?.contains(target) || triggerElement?.contains(target)) return;
close();
};
const onKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("keydown", onKeydown);
return () => {
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("keydown", onKeydown);
};
});
const inFixedAncestor = (el: Element | undefined) => {
for (let node = el?.parentElement; node; node = node.parentElement) {
if (getComputedStyle(node).position === "fixed") return true;
}
return false;
};
const onTriggerKeydown = (event: KeyboardEvent) => {
if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
event.preventDefault();
show();
}
};
const onMenuKeydown = (event: KeyboardEvent) => {
if (items.navigate(event)) return;
if (event.key === "Tab") close();
};
$effect(() => {
if (open) {
tick().then(() => items.focus(0));
}
});
setContext("dropdown-close", close);
setContext("dropdown-register", items.register);
setContext("dropdown-onclose", (listener: () => void) => {
closeListeners.add(listener);
return () => closeListeners.delete(listener);
});
setContext("dropdown-align", () => align);
onMount(() => {
return registerDropdown(group, close);
});
</script>
<div class={classes.root({ class: className })} data-testid={testid}>
<Button
bind:ref={triggerElement}
variant={variant === "button" ? buttonVariant : "ghost"}
{size}
class={variant === "button" ? triggerClass : classes.plainTrigger({ class: triggerClass })}
onclick={toggleMenu}
style="anchor-name: {anchorName}"
onkeydown={onTriggerKeydown}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={menuId}
aria-label={label}
{title}
{square}
{disabled}
>
{@render trigger()}
</Button>
<div
bind:this={menuElement}
id={menuId}
data-align={align}
data-side={side}
data-match-trigger={matchTriggerWidth ? "" : undefined}
data-fixed={fixedTrigger ? "" : undefined}
data-closing={closing ? "" : undefined}
popover="manual"
onbeforetoggle={(event) => {
if (event.newState === "open") fixedTrigger = inFixedAncestor(triggerElement);
}}
ontoggle={onToggle}
class={classes.menu({ class: menuClass })}
role="menu"
tabindex={-1}
onkeydown={onMenuKeydown}
style="position-anchor: {anchorName}; transform: translateX({xOffset}px)"
{@attach popoverOrigin(
() => triggerElement,
() => origin
)}
>
{@render children()}
</div>
</div>
<style>
.dropdown-menu {
--menu-shift: -8px;
opacity: 0;
scale: 0.94;
translate: 0 var(--menu-shift);
transition:
opacity 120ms cubic-bezier(0.23, 1, 0.32, 1),
scale 260ms cubic-bezier(0.23, 1, 0.32, 1),
translate 260ms cubic-bezier(0.23, 1, 0.32, 1);
}
.dropdown-menu:global([data-popover-origin^="bottom"]) {
--menu-shift: 8px;
}
.dropdown-menu:global([data-popover-origin="top-left"]) {
transform-origin: top left;
}
.dropdown-menu:global([data-popover-origin="top-center"]) {
transform-origin: top center;
}
.dropdown-menu:global([data-popover-origin="top-right"]) {
transform-origin: top right;
}
.dropdown-menu:global([data-popover-origin="bottom-left"]) {
transform-origin: bottom left;
}
.dropdown-menu:global([data-popover-origin="bottom-center"]) {
transform-origin: bottom center;
}
.dropdown-menu:global([data-popover-origin="bottom-right"]) {
transform-origin: bottom right;
}
.dropdown-menu:popover-open {
opacity: 1;
scale: 1;
translate: 0;
}
@starting-style {
.dropdown-menu:popover-open {
opacity: 0;
scale: 0.94;
translate: 0 var(--menu-shift);
}
}
.dropdown-menu:popover-open[data-closing] {
opacity: 0;
scale: 0.97;
translate: 0 calc(var(--menu-shift) * 0.75);
transition-duration: 120ms;
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
}
@media (prefers-reduced-motion: reduce) {
.dropdown-menu {
transition: none;
}
}
@supports (position-area: bottom) and (position-try: flip-block) {
.dropdown-menu {
position: absolute;
margin-top: 0.5rem;
position-try: flip-block;
}
.dropdown-menu[data-fixed] {
position: fixed;
}
.dropdown-menu[data-align="left"] {
position-area: block-end span-inline-end;
}
.dropdown-menu[data-align="right"] {
position-area: block-end span-inline-start;
}
.dropdown-menu[data-side="top"] {
margin-top: 0;
margin-bottom: 0.5rem;
position-try: none;
}
.dropdown-menu[data-side="top"][data-align="left"] {
position-area: block-start span-inline-end;
}
.dropdown-menu[data-side="top"][data-align="right"] {
position-area: block-start span-inline-start;
}
.dropdown-menu[data-match-trigger] {
width: anchor-size(width);
}
}
</style>