Monorepo for Tangled
tangled.org
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})();