1class SearchBox extends HTMLElement { 2 static get observedAttributes() { 3 return ['api-key', 'api-host', 'search-hub', 'max-results', 'redirect-url', 'class']; 4 } 5 6 constructor() { 7 super(); 8 this.attachShadow({ mode: 'open' }); 9 this.timer = null; 10 this.highlightedIndex = -1; 11 this.suggestions = []; 12 13 // Defaults 14 this.apiKey = ''; 15 this.apiHost = 'https://platform.cloud.coveo.com'; 16 this.searchHub = 'default'; 17 this.maxResults = 5; 18 this.redirectUrl = '/'; 19 20 this.shadowRoot.innerHTML = ` 21 <style> 22 .search-container { 23 position: relative; 24 display: flex; 25 flex-direction: row; 26 flex-wrap: nowrap; 27 width: 100%; 28 max-width: 520px; 29 border-radius: 0.5rem; 30 border: 1px solid #e5e8e8; 31 background-color: #fff; 32 33 &:focus-within { 34 box-shadow: 0 0 0 3px rgba(220, 36, 29, 0.3); 35 border-color: #dc241d; 36 } 37 } 38 input { 39 outline: transparent; 40 width: 100%; 41 max-width: 842px; 42 height: 100%; 43 flex-grow: 1; 44 border: 0px; 45 border-radius: .5rem 0 0 .5rem; 46 padding: 0.875rem 1rem; 47 background-color: transparent; 48 font-size: 1rem; 49 color: #626971; 50 51 @media (max-width: 991px) { 52 padding-top: 9px; 53 padding-bottom: 9px; 54 } 55 &:focus + .suggestions { 56 transition: .15s background ease-in-out; 57 background-color: white; 58 border: 1px solid #ccc; 59 border-radius: 0.5rem; 60 61 li { 62 opacity: 1; 63 transition: .2s opacity ease-in-out; 64 } 65 } 66 } 67 button { 68 cursor: pointer; 69 display: flex; 70 justify-content: center; 71 margin: auto 0; 72 border: 0; 73 padding: 1rem; 74 background: transparent; 75 76 svg { 77 margin: auto; 78 width: 1rem; 79 height: 1rem; 80 } 81 &:focus-visible { 82 outline: #ec1211 auto 1px; 83 } 84 &.hidden { 85 display:none; 86 } 87 } 88 .btn-search { 89 margin: -1px; 90 border-radius: 0 .5rem .5rem 0; 91 background-color: #ec1211; 92 color:#fff; 93 } 94 .btn-reset { 95 margin-right: .375rem; 96 padding: 0.75rem; 97 color: #626971; 98 99 svg { 100 width:0.75rem; 101 height:0.75rem; 102 } 103 &:hover { 104 color: #ec1211; 105 } 106 } 107 .startpage-search { 108 &.search-container { 109 max-width: unset; 110 border-color: transparent; 111 } 112 .btn-search { 113 color: #000; 114 background-color: #7aea97; 115 } 116 } 117 ul.suggestions { 118 position: absolute; 119 top: 100%; 120 left: 0; 121 right: 0; 122 max-height: 150px; 123 overflow-y: auto; 124 list-style: none; 125 margin: 0; 126 padding: 0; 127 z-index: 10000; 128 transition: .2s background ease-in-out; 129 } 130 ul.suggestions li { 131 cursor: pointer; 132 min-height: 40px; 133 display: flex; 134 flex-direction: row; 135 flex-wrap: nowrap; 136 gap: 0.5rem; 137 transition: .2s opacity ease-in-out; 138 opacity: 0; 139 padding: 0 1rem; 140 color: #626971; 141 142 &.highlighted { 143 background-color: #ddd; 144 } 145 &:hover { 146 background-color: #eee; 147 } 148 svg { 149 margin: auto 0; 150 aspect-ratio: 1/1; 151 width:1rem; 152 min-width: 1rem; 153 height:1rem; 154 } 155 span { 156 margin: auto 0; 157 overflow: hidden; 158 display: -webkit-box; 159 -webkit-box-orient: vertical; 160 -webkit-line-clamp: 2; 161 white-space: normal; 162 } 163 } 164 </style> 165 <div class="search-container"> 166 <input name="global-search" type="text" placeholder="What are you looking for?" /> 167 <ul class="suggestions"></ul> 168 <button class="btn-reset hidden" aria-label="Clear"> 169 <svg fill="currentColor" viewBox="0 0 18 18"> 170 <path d="m18 2-1.8-2-7.1 7.1-7.1-7.1-2 2 7.1 7.1-7.1 7.1 2 1.8 7.1-6.9 7.1 6.9 1.8-1.8-6.9-7.1z"></path> 171 </svg> 172 </button> 173 <button class="btn-search" aria-label="Search"> 174 <svg fill="currentColor" viewBox="0 0 16 16"> 175 <path d="m6.4 0c3.5 0 6.4 2.9 6.4 6.4 0 1.4-.4 2.7-1.2 3.7l4 4c.4.4.4 1 .1 1.5l-.1.1c-.2.2-.5.3-.8.3s-.6-.1-.8-.3l-4-4c-1 .7-2.3 1.2-3.7 1.2-3.4-.1-6.3-3-6.3-6.5s2.9-6.4 6.4-6.4zm
1750 2.1c-2.3 0-4.3 1.9-4.3 4.3s1.9 4.3 4.3 4.3 4.3-1.9 4.3-4.3-1.9-4.3-4.3-4.3z"></path> 176 </svg> 177 </button> 178 </div> 179 `; 180 } 181 182 connectedCallback() { 183 this.input = this.shadowRoot.querySelector('input'); 184 this.suggestionList = this.shadowRoot.querySelector('.suggestions'); 185 this.buttonReset = this.shadowRoot.querySelector('button.btn-reset'); 186 this.buttonSearch = this.shadowRoot.querySelector('button.btn-search'); 187 188 this.input.addEventListener('input', this.handleInput.bind(this)); 189 this.input.addEventListener('keydown', this.handleKeyDown.bind(this)); 190 this.input.addEventListener('focus', this.handleFocus.bind(this)); 191 this.buttonReset.addEventListener('click', () => { 192 this.buttonReset.classList.add('hidden') 193 this.input.value = ''; 194 this.input.focus(); 195 }); 196 this.buttonSearch.addEventListener('click', () => this.emitSearch(this.input.value)); 197 this.input.addEventListener('blur', this.handleBlur.bind(this)); 198 this.fetchSuggestions(); 199 } 200 201 attributeChangedCallback(name, oldVal, newVal) { 202 switch (name) { 203 case 'api-key': 204 this.apiKey = newVal; 205 break; 206 case 'api-host': 207 this.apiHost = newVal; 208 break; 209 case 'search-hub': 210 this.searchHub = newVal; 211 break; 212 case 'max-results': 213 this.maxResults = parseInt(newVal, 10) || 5; 214 break; 215 case 'redirect-url': 216 this.redirectUrl = newVal; 217 break; 218 case 'class': 219 this.shadowRoot.querySelector('.search-container').classList.add(newVal) 220 break; 221 } 222 } 223 224 async handleFocus() { 225 this.highlightedIndex = -1; 226 this.prevQuery = ''; 227 this.suggestions = await this.fetchSuggestions(''); 228 this.renderSuggestions(this.suggestions); 229 } 230 231 async handleInput(event) { 232 const query = event.target.value.trim(); 233 if (query.length > 0) this.buttonReset.classList.remove('hidden') 234 else this.buttonReset.classList.add('hidden') 235 this.prevQuery = query; 236 this.highlightedIndex = -1; 237 238 if (this.timer) clearTimeout(this.timer); 239 240 this.timer = setTimeout(async () => { 241 if (query.length === 0) { 242 this.renderSuggestions([]); 243 return; 244 } 245 246 this.suggestions = await this.fetchSuggestions(query); 247 this.renderSuggestions(this.suggestions); 248 }, 300); 249 } 250 251 handleBlur() { 252 setTimeout(() => { 253 this.renderSuggestions([]); 254 }, 100); // Enough time to allow clicks 255 } 256 257 handleKeyDown(event) { 258 const maxIndex = this.suggestions.length - 1; 259 260 switch (event.key) { 261 case 'ArrowDown': 262 event.preventDefault(); 263 this.highlightedIndex = (this.highlightedIndex + 1) > maxIndex ? -1 : this.highlightedIndex + 1; 264 this.input.value = this.suggestions[this.highlightedIndex] || this.prevQuery; 265 this.renderSuggestions(this.suggestions); 266 break; 267 268 case 'ArrowUp': 269 event.preventDefault(); 270 this.highlightedIndex = (this.highlightedIndex - 1) < -1 ? maxIndex : (this.highlightedIndex - 1) < 0 ? -1 : this.highlightedIndex - 1; 271 this.input.value = this.suggestions[this.highlightedIndex] || this.prevQuery; 272 this.renderSuggestions(this.suggestions); 273 break; 274 275 case 'Enter': 276 event.preventDefault(); 277 if (this.highlightedIndex >= 0 && this.suggestions[this.highlightedIndex]) { 278 this.selectSuggestion(this.suggestions[this.highlightedIndex]); 279 } else { 280 this.emitSearch(this.input.value); 281 } 282 break; 283 } 284 } 285 286 async fetchSuggestions(query) { 287 if (!this.apiKey || !this.apiHost) { 288 console.warn('Missing api-key or api-host'); 289 return []; 290 } 291 292 try { 293 const response = await fetch(`${this.apiHost}/rest/search/v2/querySuggest`, { 294 method: 'POST', 295 headers: { 296 'Content-Type': 'application/json', 297 'Authorization': `Bearer ${this.apiKey}` 298 }, 299 body: JSON.stringify({ 300 q: query, 301 searchHub: this.searchHub, 302 count: this.maxResults 303 }) 304 }); 305 306 const data = await response.json(); 307 return data.completions?.map(item => item.expression) || []; 308 309 } catch (err) { 310 console.error('Coveo API error:', err); 311 return []; 312 } 313 } 314 315 async fetchRedirects(query) { 316 if (!this.apiKey || !this.apiHost) { 317 console.warn('Missing api-key or api-host'); 318 return []; 319 } 320 321 try { 322 const response = await fetch(`${this.apiHost}/rest/search/v2/plan`, { 323 method: 'POST', 324 headers: { 325 'Content-Type': 'application/json', 326 'Authorization': `Bearer ${this.apiKey}` 327 }, 328 body: JSON.stringify({ 329 q: query, 330 searchHub: this.searchHub, 331 count: this.maxResults 332 }) 333 }); 334 335 const data = await response.json(); 336 return data?.preprocessingOutput?.triggers || []; 337 338 } catch (err) { 339 console.error('Coveo API error:', err); 340 return []; 341 } 342 343 } 344 345 renderSuggestions(suggestions) { 346 this.suggestionList.innerHTML = ''; 347 suggestions.forEach((suggestion, index) => { 348 const iconPath = document.createElementNS('http://www.w3.org/2000/svg', 'path') 349 iconPath.setAttribute('fill', 'currentColor'); 350 iconPath.setAttribute('d', 'm6.4 0c3.5 0 6.4 2.9 6.4 6.4 0 1.4-.4 2.7-1.2 3.7l4 4c.4.4.4 1 .1 1.5l-.1.1c-.2.2-.5.3-.8.3s-.6-.1-.8-.3l-4-4c-1 .7-2.3 1.2-3.7 1.2-3.4-.1-6.3-3-6.3-6.5s2.9-6.4 6.4-6.4zm
3500 2.1c-2.3 0-4.3 1.9-4.3 4.3s1.9 4.3 4.3 4.3 4.3-1.9 4.3-4.3-1.9-4.3-4.3-4.3z'); 351 const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 352 icon.appendChild(iconPath) 353 const contentSpan = document.createElement('span'); 354 contentSpan.textContent = suggestion; 355 const li = document.createElement('li'); 356 li.appendChild(icon) 357 li.appendChild(contentSpan) 358 if (index === this.highlightedIndex) { 359 li.classList.add('highlighted'); 360 } 361 li.addEventListener('mousedown', this.selectSuggestion.bind(this)); 362 this.suggestionList.appendChild(li); 363 }); 364 } 365 366 selectSuggestion(suggestion) { 367 this.input.value = typeof suggestion == "string" ? suggestion : suggestion.target.innerText; 368 this.renderSuggestions([]); 369 this.suggestions = []; 370 this.highlightedIndex = -1; 371 this.emitSearch(typeof suggestion == "string" ? suggestion : suggestion.target.innerText); 372 } 373 374 async emitSearch(query) { 375 this.input.value = ''; 376 let redirects = await this.fetchRedirects(query); 377 if (redirects.length > 0) { 378 redirects = redirects.filter(r => r.type == 'redirect') 379 window.location.href = redirects[0].content; 380 } 381 else { 382 window.location.href = `${this.redirectUrl}#q=${encodeURIComponent(query)}`; 383 } 384 } 385} 386 387customElements.define('search-box', SearchBox);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.