My agentic slop goes here. Not intended for anyone else!
1/**
2 * Arod Search Modal Component
3 * An expandable search interface with modal overlay
4 *
5 * Usage:
6 * <arod-search placeholder="Search docs..."></arod-search>
7 *
8 * Events:
9 * - search: Fired when user performs a search
10 * - clear: Fired when search is cleared
11 */
12
13// ============================================
14// AROD-SEARCH - Search Modal Component
15// ============================================
16class ArodSearch extends HTMLElement {
17 constructor() {
18 super();
19 this.attachShadow({ mode: 'open' });
20 this.isOpen = false;
21 this.searchDebounce = null;
22 }
23
24 connectedCallback() {
25 this.render();
26 this.setupEventListeners();
27 this.setupKeyboardShortcuts();
28 }
29
30 disconnectedCallback() {
31 this.removeKeyboardShortcuts();
32 }
33
34 render() {
35 const placeholder = this.getAttribute('placeholder') || 'Search...';
36
37 this.shadowRoot.innerHTML = `
38 <style>
39 :host {
40 --search-bg: #fffffc;
41 --search-border: #e0e0e0;
42 --search-text: #1a1a1a;
43 --search-muted: #666;
44 --search-accent: #090c8d;
45 --search-overlay: rgba(0, 0, 0, 0.5);
46 }
47
48 @media (prefers-color-scheme: dark) {
49 :host {
50 --search-bg: #1a1a1a;
51 --search-border: #444;
52 --search-text: #e0e0e0;
53 --search-muted: #999;
54 --search-accent: #4db8ff;
55 --search-overlay: rgba(0, 0, 0, 0.8);
56 }
57 }
58
59 /* Explicit light theme */
60 :host-context([data-theme="light"]) {
61 --search-bg: #fffffc;
62 --search-border: #e0e0e0;
63 --search-text: #1a1a1a;
64 --search-muted: #666;
65 --search-accent: #090c8d;
66 --search-overlay: rgba(0, 0, 0, 0.5);
67 }
68
69 /* Explicit dark theme */
70 :host-context([data-theme="dark"]) {
71 --search-bg: #1a1a1a;
72 --search-border: #444;
73 --search-text: #e0e0e0;
74 --search-muted: #999;
75 --search-accent: #4db8ff;
76 --search-overlay: rgba(0, 0, 0, 0.8);
77 }
78
79 .search-trigger {
80 display: flex;
81 align-items: center;
82 gap: 0.5rem;
83 padding: 0.5rem 0.75rem;
84 background: transparent;
85 border: 1px solid var(--search-border);
86 border-radius: 4px;
87 color: var(--search-text);
88 cursor: pointer;
89 transition: all 0.2s ease;
90 }
91
92 .search-trigger:hover {
93 border-color: var(--search-accent);
94 background: rgba(9, 12, 141, 0.05);
95 }
96
97 .search-icon {
98 width: 18px;
99 height: 18px;
100 }
101
102 .search-shortcut {
103 font-size: 0.75rem;
104 padding: 0.125rem 0.375rem;
105 background: rgba(0, 0, 0, 0.08);
106 border-radius: 3px;
107 color: var(--search-muted);
108 font-family: monospace;
109 }
110
111 .modal-overlay {
112 display: none;
113 position: fixed;
114 top: 0;
115 left: 0;
116 right: 0;
117 bottom: 0;
118 background: var(--search-overlay);
119 z-index: 9999;
120 animation: fadeIn 0.2s ease;
121 }
122
123 .modal-overlay.open {
124 display: block;
125 }
126
127 .modal-container {
128 position: fixed;
129 top: 10vh;
130 left: 50%;
131 transform: translateX(-50%);
132 width: 90%;
133 max-width: 600px;
134 background: var(--search-bg);
135 border-radius: 8px;
136 box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
137 animation: slideDown 0.3s ease;
138 }
139
140 .search-header {
141 position: relative;
142 padding: 1rem;
143 border-bottom: 1px solid var(--search-border);
144 }
145
146 .search-input-wrapper {
147 display: flex;
148 align-items: center;
149 gap: 0.75rem;
150 }
151
152 .search-input {
153 flex: 1;
154 padding: 0.75rem;
155 font-size: 1.125rem;
156 background: transparent;
157 border: none;
158 color: var(--search-text);
159 outline: none;
160 }
161
162 .search-input::placeholder {
163 color: var(--search-muted);
164 }
165
166 .search-close {
167 padding: 0.5rem;
168 background: transparent;
169 border: none;
170 color: var(--search-muted);
171 cursor: pointer;
172 border-radius: 4px;
173 transition: all 0.2s ease;
174 }
175
176 .search-close:hover {
177 background: rgba(0, 0, 0, 0.08);
178 }
179
180 .search-results {
181 max-height: 60vh;
182 overflow-y: auto;
183 padding: 0.5rem;
184 }
185
186 .search-results:empty::after {
187 content: "Type to search...";
188 display: block;
189 padding: 2rem;
190 text-align: center;
191 color: var(--search-muted);
192 }
193
194 .search-result-item {
195 padding: 0.75rem 1rem;
196 border-radius: 4px;
197 cursor: pointer;
198 transition: all 0.2s ease;
199 }
200
201 .search-result-item:hover {
202 background: rgba(9, 12, 141, 0.1);
203 }
204
205 .result-title {
206 font-weight: 500;
207 color: var(--search-text);
208 margin-bottom: 0.25rem;
209 }
210
211 .result-snippet {
212 font-size: 0.875rem;
213 color: var(--search-muted);
214 line-height: 1.4;
215 }
216
217 .result-snippet mark {
218 background: rgba(255, 200, 0, 0.3);
219 color: inherit;
220 padding: 0.1em 0.2em;
221 border-radius: 2px;
222 }
223
224 .search-footer {
225 padding: 0.75rem 1rem;
226 border-top: 1px solid var(--search-border);
227 display: flex;
228 justify-content: space-between;
229 align-items: center;
230 font-size: 0.875rem;
231 color: var(--search-muted);
232 }
233
234 .search-hints {
235 display: flex;
236 gap: 1rem;
237 }
238
239 .search-hint {
240 display: flex;
241 align-items: center;
242 gap: 0.25rem;
243 }
244
245 .key {
246 padding: 0.125rem 0.375rem;
247 background: rgba(0, 0, 0, 0.08);
248 border-radius: 3px;
249 font-family: monospace;
250 }
251
252 /* Selected result highlighting */
253 .search-result-item.selected {
254 background: rgba(9, 12, 141, 0.15);
255 }
256
257 /* Dark mode adjustments for backgrounds */
258 @media (prefers-color-scheme: dark) {
259 .search-shortcut,
260 .key {
261 background: rgba(255, 255, 255, 0.1);
262 }
263
264 .search-close:hover {
265 background: rgba(255, 255, 255, 0.1);
266 }
267
268 .search-result-item:hover {
269 background: rgba(77, 184, 255, 0.1);
270 }
271
272 .search-result-item.selected {
273 background: rgba(77, 184, 255, 0.15);
274 }
275 }
276
277 @keyframes fadeIn {
278 from { opacity: 0; }
279 to { opacity: 1; }
280 }
281
282 @keyframes slideDown {
283 from {
284 opacity: 0;
285 transform: translateX(-50%) translateY(-20px);
286 }
287 to {
288 opacity: 1;
289 transform: translateX(-50%) translateY(0);
290 }
291 }
292 </style>
293
294 <button class="search-trigger" aria-label="Search">
295 <svg class="search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
296 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
297 </svg>
298 <span class="search-shortcut">⌘K</span>
299 </button>
300
301 <div class="modal-overlay">
302 <div class="modal-container">
303 <div class="search-header">
304 <div class="search-input-wrapper">
305 <svg class="search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
306 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
307 </svg>
308 <input type="text" class="search-input" placeholder="${placeholder}" autocomplete="off">
309 <button class="search-close" aria-label="Close">
310 <svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
311 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
312 </svg>
313 </button>
314 </div>
315 </div>
316
317 <div class="search-results"></div>
318
319 <div class="search-footer">
320 <div class="search-hints">
321 <span class="search-hint">
322 <span class="key">↑↓</span> navigate
323 </span>
324 <span class="search-hint">
325 <span class="key">↵</span> select
326 </span>
327 <span class="search-hint">
328 <span class="key">esc</span> close
329 </span>
330 </div>
331 <div class="search-powered">
332 <slot name="powered-by"></slot>
333 </div>
334 </div>
335 </div>
336 </div>
337 `;
338 }
339
340 setupEventListeners() {
341 const trigger = this.shadowRoot.querySelector('.search-trigger');
342 const overlay = this.shadowRoot.querySelector('.modal-overlay');
343 const close = this.shadowRoot.querySelector('.search-close');
344 const input = this.shadowRoot.querySelector('.search-input');
345
346 trigger.addEventListener('click', () => this.open());
347 close.addEventListener('click', () => this.close());
348
349 overlay.addEventListener('click', (e) => {
350 if (e.target === overlay) this.close();
351 });
352
353 input.addEventListener('input', (e) => {
354 clearTimeout(this.searchDebounce);
355 this.searchDebounce = setTimeout(() => {
356 this.handleSearch(e.target.value);
357 }, 200);
358 });
359
360 input.addEventListener('keydown', (e) => {
361 if (e.key === 'Escape') this.close();
362 else if (e.key === 'ArrowDown') this.navigateResults(1);
363 else if (e.key === 'ArrowUp') this.navigateResults(-1);
364 else if (e.key === 'Enter') this.selectResult();
365 });
366 }
367
368 setupKeyboardShortcuts() {
369 this.keyHandler = (e) => {
370 // Cmd+K or Ctrl+K to open search
371 if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
372 e.preventDefault();
373 this.open();
374 }
375 };
376 document.addEventListener('keydown', this.keyHandler);
377 }
378
379 removeKeyboardShortcuts() {
380 if (this.keyHandler) {
381 document.removeEventListener('keydown', this.keyHandler);
382 }
383 }
384
385 open() {
386 this.isOpen = true;
387 const overlay = this.shadowRoot.querySelector('.modal-overlay');
388 const input = this.shadowRoot.querySelector('.search-input');
389
390 overlay.classList.add('open');
391 setTimeout(() => input.focus(), 100);
392 }
393
394 close() {
395 this.isOpen = false;
396 const overlay = this.shadowRoot.querySelector('.modal-overlay');
397 const input = this.shadowRoot.querySelector('.search-input');
398 const results = this.shadowRoot.querySelector('.search-results');
399
400 overlay.classList.remove('open');
401 input.value = '';
402 results.innerHTML = '';
403 }
404
405 handleSearch(query) {
406 if (!query) {
407 this.clearResults();
408 return;
409 }
410
411 // Dispatch search event for external handling
412 this.dispatchEvent(new CustomEvent('search', {
413 detail: { query },
414 bubbles: true
415 }));
416 }
417
418 // Public method to display search results
419 displayResults(results) {
420 const container = this.shadowRoot.querySelector('.search-results');
421 container.innerHTML = '';
422
423 if (!results || results.length === 0) {
424 container.innerHTML = '<div style="padding: 2rem; text-align: center; color: var(--search-muted);">No results found</div>';
425 return;
426 }
427
428 results.forEach((result, index) => {
429 const item = document.createElement('div');
430 item.className = 'search-result-item';
431 item.dataset.index = index;
432 item.innerHTML = `
433 <div class="result-title">${result.title}</div>
434 <div class="result-snippet">${result.snippet || ''}</div>
435 `;
436 item.addEventListener('click', () => {
437 if (result.url) window.location.href = result.url;
438 this.close();
439 });
440 container.appendChild(item);
441 });
442 }
443
444 clearResults() {
445 const container = this.shadowRoot.querySelector('.search-results');
446 container.innerHTML = '';
447
448 this.dispatchEvent(new CustomEvent('clear', { bubbles: true }));
449 }
450
451 navigateResults(direction) {
452 const results = this.shadowRoot.querySelectorAll('.search-result-item');
453 if (!results.length) return;
454
455 const current = this.shadowRoot.querySelector('.search-result-item.selected');
456 let index = current ? parseInt(current.dataset.index) : -1;
457
458 index += direction;
459 if (index < 0) index = results.length - 1;
460 if (index >= results.length) index = 0;
461
462 results.forEach(r => r.classList.remove('selected'));
463 results[index].classList.add('selected');
464 results[index].scrollIntoView({ block: 'nearest' });
465 }
466
467 selectResult() {
468 const selected = this.shadowRoot.querySelector('.search-result-item.selected');
469 if (selected) selected.click();
470 }
471}
472
473// Register component
474customElements.define('arod-search', ArodSearch);