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
3.1 kB 133 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 bg-surface 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 }; 43 } 44 45 function closeGroupExcept(group: string | undefined, keep: () => void) { 46 if (!group) return; 47 groups.get(group)?.forEach((close) => { 48 if (close !== keep) close(); 49 }); 50 } 51</script> 52 53<script lang="ts"> 54 import type { Snippet } from 'svelte'; 55 import { onMount, setContext } from 'svelte'; 56 import Button from './Button.svelte'; 57 58 interface Props { 59 id?: string; 60 group?: string; 61 variant?: 'plain' | 'button'; 62 label?: string; 63 trigger: Snippet; 64 children: Snippet; 65 } 66 67 let { id, group, variant = 'plain', label, trigger, children }: Props = $props(); 68 69 let open = $state(false); 70 const classes = $derived(dropdown({ open })); 71 let root = $state<HTMLElement>(); 72 73 const close = () => { 74 open = false; 75 }; 76 77 const toggle = () => { 78 open = !open; 79 if (open) closeGroupExcept(group, close); 80 }; 81 82 setContext('dropdown-close', close); 83 84 onMount(() => { 85 const unregister = registerDropdown(group, close); 86 87 const onDocumentClick = (event: MouseEvent) => { 88 if (open && event.target instanceof Node && root && !root.contains(event.target)) close(); 89 }; 90 const onKeydown = (event: KeyboardEvent) => { 91 if (event.key === 'Escape') close(); 92 }; 93 94 document.addEventListener('click', onDocumentClick); 95 document.addEventListener('keydown', onKeydown); 96 97 return () => { 98 unregister(); 99 document.removeEventListener('click', onDocumentClick); 100 document.removeEventListener('keydown', onKeydown); 101 }; 102 }); 103</script> 104 105<div bind:this={root} class={classes.root()}> 106 {#if variant === 'button'} 107 <Button 108 size="sm" 109 class="gap-2 !py-0 text-sm" 110 onclick={toggle} 111 aria-haspopup="menu" 112 aria-expanded={open} 113 aria-label={label} 114 > 115 {@render trigger()} 116 </Button> 117 {:else} 118 <button 119 type="button" 120 class={classes.plainTrigger()} 121 onclick={toggle} 122 aria-haspopup="menu" 123 aria-expanded={open} 124 aria-label={label} 125 > 126 {@render trigger()} 127 </button> 128 {/if} 129 130 <div {id} class={classes.menu()} role="menu"> 131 {@render children()} 132 </div> 133</div>