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