Monorepo for Tangled tangled.org
1

Configure Feed

Select the types of activity you want to include in your feed.

core / web / src / lib / components / ui / Dropdown.svelte
3.0 kB 143 lines
1<script module lang="ts"> 2 import { SvelteMap, SvelteSet } from 'svelte/reactivity'; 3 4 // opening one dropdown in a group closes its siblings. 5 const groups = new SvelteMap<string, SvelteSet<() => void>>(); 6 7 function registerDropdown(group: string | undefined, close: () => void) { 8 if (!group) return () => {}; 9 let set = groups.get(group); 10 if (!set) { 11 set = new SvelteSet(); 12 groups.set(group, set); 13 } 14 set.add(close); 15 return () => { 16 set.delete(close); 17 }; 18 } 19 20 function closeGroupExcept(group: string | undefined, keep: () => void) { 21 if (!group) return; 22 groups.get(group)?.forEach((close) => { 23 if (close !== keep) close(); 24 }); 25 } 26</script> 27 28<script lang="ts"> 29 import type { Snippet } from 'svelte'; 30 import { onMount, setContext } from 'svelte'; 31 import Button from './Button.svelte'; 32 33 interface Props { 34 id?: string; 35 group?: string; 36 variant?: 'plain' | 'button'; 37 label?: string; 38 trigger: Snippet; 39 children: Snippet; 40 } 41 42 let { id, group, variant = 'plain', label, trigger, children }: Props = $props(); 43 44 let open = $state(false); 45 let root = $state<HTMLElement>(); 46 47 const close = () => { 48 open = false; 49 }; 50 51 const toggle = () => { 52 open = !open; 53 if (open) closeGroupExcept(group, close); 54 }; 55 56 setContext('dropdown-close', close); 57 58 onMount(() => { 59 const unregister = registerDropdown(group, close); 60 61 const onDocumentClick = (event: MouseEvent) => { 62 if (open && event.target instanceof Node && root && !root.contains(event.target)) close(); 63 }; 64 const onKeydown = (event: KeyboardEvent) => { 65 if (event.key === 'Escape') close(); 66 }; 67 68 document.addEventListener('click', onDocumentClick); 69 document.addEventListener('keydown', onKeydown); 70 71 return () => { 72 unregister(); 73 document.removeEventListener('click', onDocumentClick); 74 document.removeEventListener('keydown', onKeydown); 75 }; 76 }); 77</script> 78 79<div bind:this={root} class="dropdown"> 80 {#if variant === 'button'} 81 <Button 82 size="sm" 83 class="gap-2 !py-0 text-sm" 84 onclick={toggle} 85 aria-haspopup="menu" 86 aria-expanded={open} 87 aria-label={label} 88 > 89 {@render trigger()} 90 </Button> 91 {:else} 92 <button 93 type="button" 94 class="plain" 95 onclick={toggle} 96 aria-haspopup="menu" 97 aria-expanded={open} 98 aria-label={label} 99 > 100 {@render trigger()} 101 </button> 102 {/if} 103 104 <div {id} class="menu" class:open role="menu"> 105 {@render children()} 106 </div> 107</div> 108 109<style> 110 .dropdown { 111 position: relative; 112 display: inline-block; 113 } 114 115 .plain { 116 display: flex; 117 align-items: center; 118 gap: 0.25rem; 119 border: 0; 120 background: transparent; 121 padding: 0; 122 color: inherit; 123 cursor: pointer; 124 } 125 126 .menu { 127 position: absolute; 128 right: 0; 129 z-index: 50; 130 margin-top: 0.5rem; 131 width: 14rem; 132 overflow: hidden; 133 border: 1px solid var(--color-border); 134 border-radius: 0.25rem; 135 background-color: var(--color-surface); 136 box-shadow: var(--shadow-menu); 137 font-size: 0.875rem; 138 } 139 140 .menu:not(.open) { 141 display: none; 142 } 143</style>