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
5.8 kB 235 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 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>