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