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