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