This repository has no description
0

Configure Feed

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

core / appview / pages / static / topbar-search.js
8.6 kB 260 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: keep the input focused when a result is clicked. 141 // Safari does not move focus to links/buttons on click, so mousedown would 142 // blur the input, fire focusout with a null relatedTarget, and clear the 143 // results before the click lands. Preventing the mousedown default keeps 144 // focus on the input while the click still navigates via the href. 145 document.addEventListener("mousedown", (e) => { 146 if ($("topbar-search-results")?.contains(e.target)) e.preventDefault(); 147 }); 148 149 // desktop: defer so a click on a result fires before results are cleared 150 document.addEventListener("focusout", ({ target, relatedTarget }) => { 151 const container = $("topbar-search-container"); 152 if (container?.contains(target) && !container.contains(relatedTarget)) { 153 setTimeout(clearDesktop, 0); 154 } 155 }); 156 157 document.addEventListener("htmx:afterSwap", ({ detail: { target } }) => { 158 if (!target) return; 159 160 // desktop: toggle rounded corners based on whether results are open 161 if (target.id === "topbar-search-results") { 162 const box = $("topbar-search-box"); 163 const open = target.children.length > 0; 164 box?.classList.toggle("rounded", !open); 165 box?.classList.toggle("rounded-t", open); 166 return; 167 } 168 169 // mobile: restore touch listener and show spacer when results arrive 170 if (target.id === "mobile-search-results") { 171 if (touchMoveHandler) { 172 target.removeEventListener("touchmove", touchMoveHandler); 173 touchMoveHandler = null; 174 } 175 176 const hasResults = !!target.querySelector("[data-results-footer]"); 177 $("mobile-search-spacer")?.classList.toggle("hidden", !hasResults); 178 } 179 }); 180 181 document.addEventListener("keydown", (e) => { 182 const { key, metaKey, ctrlKey } = e; 183 const input = $("topbar-search-input"); 184 const results = $("topbar-search-results"); 185 const mobileOverlay = $("mobile-search-overlay"); 186 const mobileInput = $("mobile-search-input"); 187 const active = document.activeElement; 188 189 // desktop: ⌘K / Ctrl+K focuses the search input 190 if ((metaKey || ctrlKey) && key === "k") { 191 e.preventDefault(); 192 input?.focus(); 193 input?.select(); 194 return; 195 } 196 197 if (key === "Enter") { 198 if (active === input) { 199 e.preventDefault(); 200 submitFromInput(input); 201 return; 202 } // desktop 203 if (active === mobileInput) { 204 e.preventDefault(); 205 submitFromInput(mobileInput); 206 return; 207 } // mobile 208 } 209 210 if (key === "Escape") { 211 // mobile: close the overlay 212 if ( 213 mobileOverlay && 214 !mobileOverlay.classList.contains("opacity-0") 215 ) { 216 e.preventDefault(); 217 closeMobile(); 218 return; 219 } 220 // desktop: clear results and blur 221 if (input) { 222 const links = results 223 ? [...results.querySelectorAll("[data-nav-result]")] 224 : []; 225 if (active === input || links.includes(active)) { 226 e.preventDefault(); 227 clearDesktop(); 228 input.blur(); 229 } 230 } 231 } 232 233 // desktop: arrow key navigation through results 234 if (!input || !results) return; 235 236 const links = [...results.querySelectorAll("[data-nav-result]")]; 237 const inputFocused = active === input; 238 const focusedIndex = links.indexOf(active); 239 240 if (key === "ArrowDown") { 241 if (inputFocused && links.length) { 242 e.preventDefault(); 243 links[0].focus(); 244 } else if (focusedIndex >= 0 && focusedIndex < links.length - 1) { 245 e.preventDefault(); 246 links[focusedIndex + 1].focus(); 247 } 248 } 249 250 if (key === "ArrowUp") { 251 if (focusedIndex === 0) { 252 e.preventDefault(); 253 input.focus(); 254 } else if (focusedIndex > 0) { 255 e.preventDefault(); 256 links[focusedIndex - 1].focus(); 257 } 258 } 259 }); 260})();