Monorepo for Tangled tangled.org
3

Configure Feed

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

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