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