forked from
pds.ls/pdsls
atmosphere explorer
1import { A } from "@solidjs/router";
2import {
3 Accessor,
4 createContext,
5 createSignal,
6 JSX,
7 onCleanup,
8 onMount,
9 Setter,
10 Show,
11 useContext,
12} from "solid-js";
13import { Portal } from "solid-js/web";
14import { addToClipboard } from "../utils/copy";
15
16const MenuContext = createContext<{
17 showMenu: Accessor<boolean>;
18 setShowMenu: Setter<boolean>;
19}>();
20
21export const MenuProvider = (props: { children?: JSX.Element }) => {
22 const [showMenu, setShowMenu] = createSignal(false);
23 const value = { showMenu, setShowMenu };
24
25 return <MenuContext.Provider value={value}>{props.children}</MenuContext.Provider>;
26};
27
28export const CopyMenu = (props: { content: string; label: string; icon?: string }) => {
29 const ctx = useContext(MenuContext);
30
31 return (
32 <button
33 onClick={() => {
34 addToClipboard(props.content);
35 ctx?.setShowMenu(false);
36 }}
37 class="flex items-center gap-2 rounded-md p-1.5 whitespace-nowrap hover:bg-neutral-200/50 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
38 >
39 <Show when={props.icon}>
40 <span class={"iconify shrink-0 " + props.icon}></span>
41 </Show>
42 <span class="whitespace-nowrap">{props.label}</span>
43 </button>
44 );
45};
46
47export const NavMenu = (props: {
48 href: string;
49 label: string;
50 icon?: string;
51 newTab?: boolean;
52 external?: boolean;
53 shortcut?: string;
54}) => {
55 const ctx = useContext(MenuContext);
56
57 return (
58 <A
59 href={props.href}
60 onClick={() => ctx?.setShowMenu(false)}
61 class="flex items-center gap-2 rounded-md p-1.5 hover:bg-neutral-200/50 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
62 classList={{ "justify-between": props.external || !!props.shortcut }}
63 target={props.newTab ? "_blank" : undefined}
64 >
65 <div class="flex items-center gap-2">
66 <Show when={props.icon}>
67 <span class={"iconify shrink-0 " + props.icon}></span>
68 </Show>
69 <span class="whitespace-nowrap">{props.label}</span>
70 </div>
71 <Show when={props.shortcut}>
72 <kbd class="rounded border border-neutral-300 bg-neutral-100 px-1.5 py-0.5 font-mono text-[10px] text-neutral-500 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-400">
73 {props.shortcut}
74 </kbd>
75 </Show>
76 <Show when={props.external}>
77 <span class="iconify lucide--external-link"></span>
78 </Show>
79 </A>
80 );
81};
82
83export const ActionMenu = (props: { label: string; icon: string; onClick: () => void }) => {
84 const ctx = useContext(MenuContext);
85
86 return (
87 <button
88 onClick={() => {
89 props.onClick();
90 ctx?.setShowMenu(false);
91 }}
92 class="flex items-center gap-2 rounded-md p-1.5 whitespace-nowrap hover:bg-neutral-200/50 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
93 >
94 <Show when={props.icon}>
95 <span class={"iconify shrink-0 " + props.icon}></span>
96 </Show>
97 <span class="whitespace-nowrap">{props.label}</span>
98 </button>
99 );
100};
101
102export const MenuSeparator = () => {
103 return <div class="my-1 h-[0.5px] bg-neutral-300 dark:bg-neutral-600" />;
104};
105
106export const DropdownMenu = (props: {
107 icon: string;
108 buttonClass?: string;
109 menuClass?: string;
110 children?: JSX.Element;
111}) => {
112 const ctx = useContext(MenuContext);
113 const [menu, setMenu] = createSignal<HTMLDivElement>();
114 const [menuButton, setMenuButton] = createSignal<HTMLButtonElement>();
115 const [buttonRect, setButtonRect] = createSignal<{ bottom: number; right: number }>();
116
117 const clickEvent = (event: MouseEvent) => {
118 const target = event.target as Node;
119 if (!menuButton()?.contains(target) && !menu()?.contains(target)) ctx?.setShowMenu(false);
120 };
121
122 const updatePosition = () => {
123 const rect = menuButton()?.getBoundingClientRect();
124 if (rect) {
125 const isTouchDevice = window.matchMedia("(hover: none)").matches;
126 const vv = isTouchDevice ? window.visualViewport : null;
127 setButtonRect({
128 bottom: rect.bottom + (vv?.offsetTop ?? 0),
129 right: rect.right + (vv?.offsetLeft ?? 0),
130 });
131 }
132 };
133
134 onMount(() => {
135 window.addEventListener("click", clickEvent);
136 window.addEventListener("scroll", updatePosition, true);
137 window.addEventListener("resize", updatePosition);
138 window.visualViewport?.addEventListener("resize", updatePosition);
139 window.visualViewport?.addEventListener("scroll", updatePosition);
140 });
141
142 onCleanup(() => {
143 window.removeEventListener("click", clickEvent);
144 window.removeEventListener("scroll", updatePosition, true);
145 window.removeEventListener("resize", updatePosition);
146 window.visualViewport?.removeEventListener("resize", updatePosition);
147 window.visualViewport?.removeEventListener("scroll", updatePosition);
148 });
149
150 return (
151 <div class="relative">
152 <button
153 class={
154 "flex items-center hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 " +
155 props.buttonClass
156 }
157 ref={setMenuButton}
158 onClick={() => {
159 updatePosition();
160 ctx?.setShowMenu(!ctx?.showMenu());
161 }}
162 >
163 <span class={"iconify " + props.icon}></span>
164 </button>
165 <Show when={ctx?.showMenu()}>
166 <Portal>
167 <div
168 ref={setMenu}
169 style={{
170 position: "fixed",
171 top: `${(buttonRect()?.bottom ?? 0) + 4}px`,
172 left: `${(buttonRect()?.right ?? 0) - 160}px`,
173 }}
174 class={
175 "dark:bg-dark-300 dark:shadow-dark-700 z-50 flex min-w-40 flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 text-sm shadow-md dark:border-neutral-700 " +
176 props.menuClass
177 }
178 >
179 {props.children}
180 </div>
181 </Portal>
182 </Show>
183 </div>
184 );
185};