Monorepo for Tangled
tangled.org
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>