atmosphere explorer
at main 185 lines 5.9 kB view raw
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};