This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / web / src / lib / components / ui / Dropdown.svelte
6.0 kB 254 lines
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>