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 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})();