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 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"
11 },
12 variants: {
13 align: {
14 left: {
15 menu: "left-0"
16 },
17 right: {
18 menu: "right-0"
19 }
20 },
21 open: {
22 true: {
23 menu: "block"
24 },
25 false: {
26 menu: "hidden"
27 }
28 }
29 },
30 defaultVariants: {
31 align: "left",
32 open: false
33 }
34 });
35
36 export type DropdownVariants = VariantProps<typeof dropdown>;
37
38 // opening one dropdown in a group closes its siblings.
39 const groups = new SvelteMap<string, SvelteSet<() => void>>();
40
41 function registerDropdown(group: string | undefined, close: () => void) {
42 if (!group) return () => {};
43 let set = groups.get(group);
44 if (!set) {
45 set = new SvelteSet();
46 groups.set(group, set);
47 }
48 set.add(close);
49 return () => {
50 set.delete(close);
51 if (set.size === 0) groups.delete(group);
52 };
53 }
54
55 function closeGroupExcept(group: string | undefined, keep: () => void) {
56 if (!group) return;
57 groups.get(group)?.forEach((close) => {
58 if (close !== keep) close();
59 });
60 }
61</script>
62
63<script lang="ts">
64 import type { Snippet } from "svelte";
65 import { onMount, setContext, tick } from "svelte";
66 import Button, { type ButtonVariants } from "./Button.svelte";
67
68 interface Props {
69 id?: string;
70 group?: string;
71 variant?: "plain" | "button";
72 buttonVariant?: ButtonVariants["variant"];
73 align?: DropdownVariants["align"];
74 label?: string;
75 menuClass?: string;
76 trigger: Snippet;
77 children: Snippet;
78 }
79
80 let {
81 id,
82 group,
83 variant = "plain",
84 buttonVariant = "default",
85 align = "left",
86 label,
87 menuClass,
88 trigger,
89 children
90 }: Props = $props();
91
92 let open = $state(false);
93 const classes = $derived(dropdown({ open, align }));
94 let root = $state<HTMLElement>();
95 let triggerWrapper = $state<HTMLElement>();
96 let triggerButtonEl = $state<HTMLElement>();
97 const fallbackId = $props.id();
98 const menuId = $derived(id ?? fallbackId);
99
100 const focusTrigger = () => {
101 if (variant === "button") {
102 triggerWrapper?.querySelector<HTMLElement>("button, a")?.focus();
103 } else {
104 triggerButtonEl?.focus();
105 }
106 };
107 // intentionally not $state: only used for imperative focus, and reactive
108 // reads inside DropdownItem's register $effect would loop it forever
109 let items: HTMLElement[] = [];
110
111 const close = () => {
112 open = false;
113 };
114
115 const toggle = () => {
116 open = !open;
117 if (open) closeGroupExcept(group, close);
118 };
119
120 const focusItem = (index: number) => {
121 if (items.length === 0) return;
122 const wrapped = ((index % items.length) + items.length) % items.length;
123 items[wrapped]?.focus();
124 };
125
126 const onTriggerKeydown = (event: KeyboardEvent) => {
127 if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
128 event.preventDefault();
129 open = true;
130 closeGroupExcept(group, close);
131 }
132 };
133
134 const onMenuKeydown = (event: KeyboardEvent) => {
135 const currentIndex = items.indexOf(event.target as HTMLElement);
136 switch (event.key) {
137 case "ArrowDown":
138 event.preventDefault();
139 focusItem(currentIndex + 1);
140 break;
141 case "ArrowUp":
142 event.preventDefault();
143 focusItem(currentIndex - 1);
144 break;
145 case "Home":
146 event.preventDefault();
147 focusItem(0);
148 break;
149 case "End":
150 event.preventDefault();
151 focusItem(items.length - 1);
152 break;
153 case "Escape":
154 event.preventDefault();
155 close();
156 focusTrigger();
157 break;
158 case "Tab":
159 close();
160 break;
161 }
162 };
163
164 $effect(() => {
165 if (open) {
166 tick().then(() => items[0]?.focus());
167 }
168 });
169
170 setContext("dropdown-close", close);
171 setContext("dropdown-register", (el: HTMLElement) => {
172 // insert in DOM order, not mount order, since conditionally rendered
173 // items can register after already-registered items that appear later
174 // in the DOM
175 const index = items.findIndex(
176 (item) => item.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING
177 );
178 if (index === -1) {
179 items.push(el);
180 } else {
181 items.splice(index, 0, el);
182 }
183 return () => {
184 items = items.filter((item) => item !== el);
185 };
186 });
187
188 onMount(() => {
189 const unregister = registerDropdown(group, close);
190
191 const onDocumentClick = (event: PointerEvent) => {
192 if (open && event.target instanceof Node && root && !root.contains(event.target)) close();
193 };
194 const onKeydown = (event: KeyboardEvent) => {
195 if (!open) return;
196 if (event.key === "Escape") {
197 close();
198 focusTrigger();
199 }
200 };
201
202 document.addEventListener("pointerdown", onDocumentClick);
203 document.addEventListener("keydown", onKeydown);
204
205 return () => {
206 unregister();
207 document.removeEventListener("pointerdown", onDocumentClick);
208 document.removeEventListener("keydown", onKeydown);
209 };
210 });
211</script>
212
213<div bind:this={root} class={classes.root()}>
214 {#if variant === "button"}
215 <span bind:this={triggerWrapper} class="contents">
216 <Button
217 variant={buttonVariant}
218 class="px-3"
219 onclick={toggle}
220 onkeydown={onTriggerKeydown}
221 aria-haspopup="menu"
222 aria-expanded={open}
223 aria-controls={menuId}
224 aria-label={label}
225 >
226 {@render trigger()}
227 </Button>
228 </span>
229 {:else}
230 <button
231 bind:this={triggerButtonEl}
232 type="button"
233 class={classes.plainTrigger()}
234 onclick={toggle}
235 onkeydown={onTriggerKeydown}
236 aria-haspopup="menu"
237 aria-expanded={open}
238 aria-controls={menuId}
239 aria-label={label}
240 >
241 {@render trigger()}
242 </button>
243 {/if}
244
245 <div
246 id={menuId}
247 class={classes.menu({ class: menuClass })}
248 role="menu"
249 tabindex={-1}
250 onkeydown={onMenuKeydown}
251 >
252 {@render children()}
253 </div>
254</div>