Monorepo for Tangled tangled.org
2

Configure Feed

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

at icy/yovxsu 7.9 kB View raw
1(() => { 2 if (window._navSearchReady) return; 3 window._navSearchReady = true; 4 5 const $ = (id) => document.getElementById(id); 6 7 const submitFromInput = (input) => { 8 const query = input.value.trim(); 9 if (query) 10 window.location.href = `/search?q=${encodeURIComponent(query)}`; 11 }; 12 13 // mobile-related code 14 let savedScrollY = 0; 15 let touchMoveHandler = null; 16 17 const updateOverlayHeight = () => { 18 const overlay = $("mobile-search-overlay"); 19 if (!overlay) return; 20 21 const layoutHeight = window.innerHeight; 22 const visibleHeight = window.visualViewport?.height ?? layoutHeight; 23 24 overlay.style.height = `${layoutHeight}px`; 25 overlay.style.top = "0px"; 26 27 const spacer = $("mobile-search-spacer"); 28 if (spacer) 29 spacer.style.height = `${Math.max(0, layoutHeight - visibleHeight)}px`; 30 }; 31 32 const openMobile = () => { 33 const overlay = $("mobile-search-overlay"); 34 if (!overlay || overlay.classList.contains("opacity-100")) return; 35 36 overlay.classList.remove("opacity-0", "pointer-events-none"); 37 overlay.classList.add("opacity-100", "pointer-events-auto"); 38 overlay.setAttribute("aria-hidden", "false"); 39 40 savedScrollY = window.scrollY; 41 Object.assign(document.body.style, { 42 position: "fixed", 43 top: `-${savedScrollY}px`, 44 width: "100%", 45 }); 46 updateOverlayHeight(); 47 48 if (window.visualViewport) { 49 window.visualViewport.addEventListener( 50 "resize", 51 updateOverlayHeight, 52 ); 53 } 54 55 $("mobile-search-input")?.focus({ preventScroll: true }); 56 57 const results = $("mobile-search-results"); 58 if (results && !touchMoveHandler) { 59 touchMoveHandler = (e) => e.preventDefault(); 60 results.addEventListener("touchmove", touchMoveHandler, { 61 passive: false, 62 }); 63 } 64 }; 65 66 const closeMobile = () => { 67 const overlay = $("mobile-search-overlay"); 68 if (!overlay) return; 69 70 overlay.classList.remove("opacity-100", "pointer-events-auto"); 71 overlay.classList.add("opacity-0", "pointer-events-none"); 72 overlay.setAttribute("aria-hidden", "true"); 73 overlay.style.height = ""; 74 overlay.style.top = ""; 75 76 const spacer = $("mobile-search-spacer"); 77 if (spacer) { 78 spacer.style.height = ""; 79 spacer.classList.add("hidden"); 80 } 81 82 if (window.visualViewport) { 83 window.visualViewport.removeEventListener( 84 "resize", 85 updateOverlayHeight, 86 ); 87 } 88 89 Object.assign(document.body.style, { 90 position: "", 91 top: "", 92 width: "", 93 }); 94 window.scrollTo(0, savedScrollY); 95 96 const input = $("mobile-search-input"); 97 if (input) { 98 input.value = ""; 99 input.blur(); 100 } 101 102 $("mobile-search-results")?.replaceChildren(); 103 }; 104 105 // desktop-related things 106 const clearDesktop = () => { 107 $("topbar-search-results")?.replaceChildren(); 108 109 const box = $("topbar-search-box"); 110 box?.classList.add("rounded"); 111 box?.classList.remove("rounded-t"); 112 }; 113 114 // events 115 document.addEventListener("click", ({ target }) => { 116 // mobile: open/close overlay via data-action buttons 117 const action = target 118 .closest("[data-action]") 119 ?.getAttribute("data-action"); 120 if (action === "open-mobile-search") { 121 openMobile(); 122 return; 123 } 124 if (action === "close-mobile-search") { 125 closeMobile(); 126 return; 127 } 128 129 // desktop: clicking outside the search container clears results 130 const container = $("topbar-search-container"); 131 if (container && !container.contains(target)) clearDesktop(); 132 }); 133 134 // desktop: defer so a click on a result fires before results are cleared 135 document.addEventListener("focusout", ({ target, relatedTarget }) => { 136 const container = $("topbar-search-container"); 137 if (container?.contains(target) && !container.contains(relatedTarget)) { 138 setTimeout(clearDesktop, 0); 139 } 140 }); 141 142 document.addEventListener("htmx:afterSwap", ({ detail: { target } }) => { 143 if (!target) return; 144 145 // desktop: toggle rounded corners based on whether results are open 146 if (target.id === "topbar-search-results") { 147 const box = $("topbar-search-box"); 148 const open = target.children.length > 0; 149 box?.classList.toggle("rounded", !open); 150 box?.classList.toggle("rounded-t", open); 151 return; 152 } 153 154 // mobile: restore touch listener and show spacer when results arrive 155 if (target.id === "mobile-search-results") { 156 if (touchMoveHandler) { 157 target.removeEventListener("touchmove", touchMoveHandler); 158 touchMoveHandler = null; 159 } 160 161 const hasResults = !!target.querySelector("[data-results-footer]"); 162 $("mobile-search-spacer")?.classList.toggle("hidden", !hasResults); 163 } 164 }); 165 166 document.addEventListener("keydown", (e) => { 167 const { key, metaKey, ctrlKey } = e; 168 const input = $("topbar-search-input"); 169 const results = $("topbar-search-results"); 170 const mobileOverlay = $("mobile-search-overlay"); 171 const mobileInput = $("mobile-search-input"); 172 const active = document.activeElement; 173 174 // desktop: ⌘K / Ctrl+K focuses the search input 175 if ((metaKey || ctrlKey) && key === "k") { 176 e.preventDefault(); 177 input?.focus(); 178 input?.select(); 179 return; 180 } 181 182 if (key === "Enter") { 183 if (active === input) { 184 e.preventDefault(); 185 submitFromInput(input); 186 return; 187 } // desktop 188 if (active === mobileInput) { 189 e.preventDefault(); 190 submitFromInput(mobileInput); 191 return; 192 } // mobile 193 } 194 195 if (key === "Escape") { 196 // mobile: close the overlay 197 if ( 198 mobileOverlay && 199 !mobileOverlay.classList.contains("opacity-0") 200 ) { 201 e.preventDefault(); 202 closeMobile(); 203 return; 204 } 205 // desktop: clear results and blur 206 if (input) { 207 const links = results 208 ? [...results.querySelectorAll("[data-nav-result]")] 209 : []; 210 if (active === input || links.includes(active)) { 211 e.preventDefault(); 212 clearDesktop(); 213 input.blur(); 214 } 215 } 216 } 217 218 // desktop: arrow key navigation through results 219 if (!input || !results) return; 220 221 const links = [...results.querySelectorAll("[data-nav-result]")]; 222 const inputFocused = active === input; 223 const focusedIndex = links.indexOf(active); 224 225 if (key === "ArrowDown") { 226 if (inputFocused && links.length) { 227 e.preventDefault(); 228 links[0].focus(); 229 } else if (focusedIndex >= 0 && focusedIndex < links.length - 1) { 230 e.preventDefault(); 231 links[focusedIndex + 1].focus(); 232 } 233 } 234 235 if (key === "ArrowUp") { 236 if (focusedIndex === 0) { 237 e.preventDefault(); 238 input.focus(); 239 } else if (focusedIndex > 0) { 240 e.preventDefault(); 241 links[focusedIndex - 1].focus(); 242 } 243 } 244 }); 245})();