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 from "./Button.svelte";
67
68 interface Props {
69 id?: string;
70 group?: string;
71 variant?: "plain" | "button";
72 align?: DropdownVariants["align"];
73 label?: string;
74 trigger: Snippet;
75 children: Snippet;
76 }
77
78 let { id, group, variant = "plain", align = "left", label, trigger, children }: Props = $props();
79
80 let open = $state(false);
81 const classes = $derived(dropdown({ open, align }));
82 let root = $state<HTMLElement>();
83 let triggerWrapper = $state<HTMLElement>();
84 let triggerButtonEl = $state<HTMLElement>();
85 const fallbackId = $props.id();
86 const menuId = $derived(id ?? fallbackId);
87
88 const focusTrigger = () => {
89 if (variant === "button") {
90 triggerWrapper?.querySelector<HTMLElement>("button, a")?.focus();
91 } else {
92 triggerButtonEl?.focus();
93 }
94 };
95 // intentionally not $state: only used for imperative focus, and reactive
96 // reads inside DropdownItem's register $effect would loop it forever
97 let items: HTMLElement[] = [];
98
99 const close = () => {
100 open = false;
101 };
102
103 const toggle = () => {
104 open = !open;
105 if (open) closeGroupExcept(group, close);
106 };
107
108 const focusItem = (index: number) => {
109 if (items.length === 0) return;
110 const wrapped = ((index % items.length) + items.length) % items.length;
111 items[wrapped]?.focus();
112 };
113
114 const onTriggerKeydown = (event: KeyboardEvent) => {
115 if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
116 event.preventDefault();
117 open = true;
118 closeGroupExcept(group, close);
119 }
120 };
121
122 const onMenuKeydown = (event: KeyboardEvent) => {
123 const currentIndex = items.indexOf(event.target as HTMLElement);
124 switch (event.key) {
125 case "ArrowDown":
126 event.preventDefault();
127 focusItem(currentIndex + 1);
128 break;
129 case "ArrowUp":
130 event.preventDefault();
131 focusItem(currentIndex - 1);
132 break;
133 case "Home":
134 event.preventDefault();
135 focusItem(0);
136 break;
137 case "End":
138 event.preventDefault();
139 focusItem(items.length - 1);
140 break;
141 case "Escape":
142 event.preventDefault();
143 close();
144 focusTrigger();
145 break;
146 case "Tab":
147 close();
148 break;
149 }
150 };
151
152 $effect(() => {
153 if (open) {
154 tick().then(() => items[0]?.focus());
155 }
156 });
157
158 setContext("dropdown-close", close);
159 setContext("dropdown-register", (el: HTMLElement) => {
160 // insert in DOM order, not mount order, since conditionally rendered
161 // items can register after already-registered items that appear later
162 // in the DOM
163 const index = items.findIndex(
164 (item) => item.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING
165 );
166 if (index === -1) {
167 items.push(el);
168 } else {
169 items.splice(index, 0, el);
170 }
171 return () => {
172 items = items.filter((item) => item !== el);
173 };
174 });
175
176 onMount(() => {
177 const unregister = registerDropdown(group, close);
178
179 const onDocumentClick = (event: PointerEvent) => {
180 if (open && event.target instanceof Node && root && !root.contains(event.target)) close();
181 };
182 const onKeydown = (event: KeyboardEvent) => {
183 if (!open) return;
184 if (event.key === "Escape") {
185 close();
186 focusTrigger();
187 }
188 };
189
190 document.addEventListener("pointerdown", onDocumentClick);
191 document.addEventListener("keydown", onKeydown);
192
193 return () => {
194 unregister();
195 document.removeEventListener("pointerdown", onDocumentClick);
196 document.removeEventListener("keydown", onKeydown);
197 };
198 });
199</script>
200
201<div bind:this={root} class={classes.root()}>
202 {#if variant === "button"}
203 <span bind:this={triggerWrapper} class="contents">
204 <Button
205 class="px-3"
206 onclick={toggle}
207 onkeydown={onTriggerKeydown}
208 aria-haspopup="menu"
209 aria-expanded={open}
210 aria-controls={menuId}
211 aria-label={label}
212 >
213 {@render trigger()}
214 </Button>
215 </span>
216 {:else}
217 <button
218 bind:this={triggerButtonEl}
219 type="button"
220 class={classes.plainTrigger()}
221 onclick={toggle}
222 onkeydown={onTriggerKeydown}
223 aria-haspopup="menu"
224 aria-expanded={open}
225 aria-controls={menuId}
226 aria-label={label}
227 >
228 {@render trigger()}
229 </button>
230 {/if}
231
232 <div id={menuId} class={classes.menu()} role="menu" tabindex={-1} onkeydown={onMenuKeydown}>
233 {@render children()}
234 </div>
235</div>