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