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-default bg-background-default 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 let idCounter = 0;
33 function nextId() {
34 idCounter += 1;
35 return `dropdown-menu-${idCounter}`;
36 }
37
38 function registerDropdown(group: string | undefined, close: () => void) {
39 if (!group) return () => {};
40 let set = groups.get(group);
41 if (!set) {
42 set = new SvelteSet();
43 groups.set(group, set);
44 }
45 set.add(close);
46 return () => {
47 set.delete(close);
48 if (set && set.size === 0) groups.delete(group);
49 };
50 }
51
52 function closeGroupExcept(group: string | undefined, keep: () => void) {
53 if (!group) return;
54 groups.get(group)?.forEach((close) => {
55 if (close !== keep) close();
56 });
57 }
58</script>
59
60<script lang="ts">
61 import type { Snippet } from "svelte";
62 import { onMount, setContext, tick } from "svelte";
63 import Button from "./Button.svelte";
64
65 interface Props {
66 id?: string;
67 group?: string;
68 variant?: "plain" | "button";
69 label?: string;
70 trigger: Snippet;
71 children: Snippet;
72 }
73
74 let { id, group, variant = "plain", label, trigger, children }: Props = $props();
75
76 let open = $state(false);
77 const classes = $derived(dropdown({ open }));
78 let root = $state<HTMLElement>();
79 let triggerWrapper = $state<HTMLElement>();
80 let triggerButtonEl = $state<HTMLElement>();
81 const fallbackId = nextId();
82 const menuId = $derived(id ?? fallbackId);
83
84 const focusTrigger = () => {
85 if (variant === "button") {
86 triggerWrapper?.querySelector<HTMLElement>("button, a")?.focus();
87 } else {
88 triggerButtonEl?.focus();
89 }
90 };
91 let items: HTMLElement[] = $state([]);
92
93 const close = () => {
94 open = false;
95 };
96
97 const toggle = () => {
98 open = !open;
99 if (open) closeGroupExcept(group, close);
100 };
101
102 const focusItem = (index: number) => {
103 if (items.length === 0) return;
104 const wrapped = ((index % items.length) + items.length) % items.length;
105 items[wrapped]?.focus();
106 };
107
108 const openAndFocusFirst = () => {
109 open = true;
110 closeGroupExcept(group, close);
111 };
112
113 const onTriggerKeydown = (event: KeyboardEvent) => {
114 if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
115 event.preventDefault();
116 openAndFocusFirst();
117 }
118 };
119
120 const onMenuKeydown = (event: KeyboardEvent) => {
121 const currentIndex = items.indexOf(event.target as HTMLElement);
122 switch (event.key) {
123 case "ArrowDown":
124 event.preventDefault();
125 focusItem(currentIndex + 1);
126 break;
127 case "ArrowUp":
128 event.preventDefault();
129 focusItem(currentIndex - 1);
130 break;
131 case "Home":
132 event.preventDefault();
133 focusItem(0);
134 break;
135 case "End":
136 event.preventDefault();
137 focusItem(items.length - 1);
138 break;
139 case "Escape":
140 event.preventDefault();
141 close();
142 focusTrigger();
143 break;
144 }
145 };
146
147 $effect(() => {
148 if (open) {
149 tick().then(() => items[0]?.focus());
150 }
151 });
152
153 setContext("dropdown-close", close);
154 setContext("dropdown-register", (el: HTMLElement) => {
155 items.push(el);
156 return () => {
157 items = items.filter((item) => item !== el);
158 };
159 });
160
161 onMount(() => {
162 const unregister = registerDropdown(group, close);
163
164 const onDocumentClick = (event: MouseEvent) => {
165 if (open && event.target instanceof Node && root && !root.contains(event.target)) close();
166 };
167 const onKeydown = (event: KeyboardEvent) => {
168 if (!open) return;
169 if (event.key === "Escape") {
170 close();
171 focusTrigger();
172 }
173 };
174
175 document.addEventListener("click", onDocumentClick);
176 document.addEventListener("keydown", onKeydown);
177
178 return () => {
179 unregister();
180 document.removeEventListener("click", onDocumentClick);
181 document.removeEventListener("keydown", onKeydown);
182 };
183 });
184</script>
185
186<div bind:this={root} class={classes.root()}>
187 {#if variant === "button"}
188 <span bind:this={triggerWrapper} class="contents">
189 <Button
190 size="sm"
191 class="px-3"
192 onclick={toggle}
193 onkeydown={onTriggerKeydown}
194 aria-haspopup="menu"
195 aria-expanded={open}
196 aria-controls={menuId}
197 aria-label={label}
198 >
199 {@render trigger()}
200 </Button>
201 </span>
202 {:else}
203 <button
204 bind:this={triggerButtonEl}
205 type="button"
206 class={classes.plainTrigger()}
207 onclick={toggle}
208 onkeydown={onTriggerKeydown}
209 aria-haspopup="menu"
210 aria-expanded={open}
211 aria-controls={menuId}
212 aria-label={label}
213 >
214 {@render trigger()}
215 </button>
216 {/if}
217
218 <div id={menuId} class={classes.menu()} role="menu" tabindex={-1} onkeydown={onMenuKeydown}>
219 {@render children()}
220 </div>
221</div>