This repository has no description
1<script module lang="ts">
2 import { SvelteMap, SvelteSet } from 'svelte/reactivity';
3 import { tv, type VariantProps } from 'tailwind-variants';
4
5 export const dropdown = tv({
6 slots: {
7 root: 'relative inline-block',
8 plainTrigger:
9 'flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-inherit',
10 menu: 'absolute right-0 z-50 mt-2 w-56 overflow-hidden rounded border border-border bg-surface text-sm shadow-menu'
11 },
12 variants: {
13 open: {
14 true: {
15 menu: 'block'
16 },
17 false: {
18 menu: 'hidden'
19 }
20 }
21 },
22 defaultVariants: {
23 open: false
24 }
25 });
26
27 export type DropdownVariants = VariantProps<typeof dropdown>;
28
29 // opening one dropdown in a group closes its siblings.
30 const groups = new SvelteMap<string, SvelteSet<() => void>>();
31
32 function registerDropdown(group: string | undefined, close: () => void) {
33 if (!group) return () => {};
34 let set = groups.get(group);
35 if (!set) {
36 set = new SvelteSet();
37 groups.set(group, set);
38 }
39 set.add(close);
40 return () => {
41 set.delete(close);
42 };
43 }
44
45 function closeGroupExcept(group: string | undefined, keep: () => void) {
46 if (!group) return;
47 groups.get(group)?.forEach((close) => {
48 if (close !== keep) close();
49 });
50 }
51</script>
52
53<script lang="ts">
54 import type { Snippet } from 'svelte';
55 import { onMount, setContext } from 'svelte';
56 import Button from './Button.svelte';
57
58 interface Props {
59 id?: string;
60 group?: string;
61 variant?: 'plain' | 'button';
62 label?: string;
63 trigger: Snippet;
64 children: Snippet;
65 }
66
67 let { id, group, variant = 'plain', label, trigger, children }: Props = $props();
68
69 let open = $state(false);
70 const classes = $derived(dropdown({ open }));
71 let root = $state<HTMLElement>();
72
73 const close = () => {
74 open = false;
75 };
76
77 const toggle = () => {
78 open = !open;
79 if (open) closeGroupExcept(group, close);
80 };
81
82 setContext('dropdown-close', close);
83
84 onMount(() => {
85 const unregister = registerDropdown(group, close);
86
87 const onDocumentClick = (event: MouseEvent) => {
88 if (open && event.target instanceof Node && root && !root.contains(event.target)) close();
89 };
90 const onKeydown = (event: KeyboardEvent) => {
91 if (event.key === 'Escape') close();
92 };
93
94 document.addEventListener('click', onDocumentClick);
95 document.addEventListener('keydown', onKeydown);
96
97 return () => {
98 unregister();
99 document.removeEventListener('click', onDocumentClick);
100 document.removeEventListener('keydown', onKeydown);
101 };
102 });
103</script>
104
105<div bind:this={root} class={classes.root()}>
106 {#if variant === 'button'}
107 <Button
108 size="sm"
109 class="gap-2 !py-0 text-sm"
110 onclick={toggle}
111 aria-haspopup="menu"
112 aria-expanded={open}
113 aria-label={label}
114 >
115 {@render trigger()}
116 </Button>
117 {:else}
118 <button
119 type="button"
120 class={classes.plainTrigger()}
121 onclick={toggle}
122 aria-haspopup="menu"
123 aria-expanded={open}
124 aria-label={label}
125 >
126 {@render trigger()}
127 </button>
128 {/if}
129
130 <div {id} class={classes.menu()} role="menu">
131 {@render children()}
132 </div>
133</div>