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