1customElements.define('custom-map-list', class extends HTMLElement { 2 constructor() { 3 super(); 4 this.list = this.querySelector('.list'); 5 this._listData = null; // internal storage for reactive list data 6 this.loaded = false; 7 } 8 9 set listData(val) { 10 this._listData = val; 11 this.render(); 12 } 13 14 set appendToSource({ source, newItems }) { 15 this.appendItemsToSource(source, newItems); 16 } 17 18 get listData() { 19 return this._listData; 20 } 21 22 async connectedCallback() { 23 if (this.loaded) return; 24 this.loaded = true; 25 this.groupCount = this.querySelectorAll('.results-group').length; 26 27 // click handling for items & group headers 28 this.list.addEventListener('click', (e) => { 29 const item = e.target.closest('.list-group-item'); 30 const header = e.target.closest('[data-action="toggle-group"]'); 31 32 if (item) { 33 const source = item.dataset.source; 34 const id = item.dataset.id; 35 this.dispatchEvent(new CustomEvent('itemClicked', { detail: { source, id } })); 36 this.activateItem(source, id); 37 } else if (header) { 38 const group = header.closest('.results-group'); 39 if (group.classList.contains('disabled')) { 40 this.dispatchEvent(new CustomEvent('updateSourceVisibility', { 41 detail: { 42 source: group.getAttribute('data-source'), 43 enabled: true, 44 origin: 'list' 45 } 46 })); 47 }; 48 this.toggleGroup(group); 49 } 50 }); 51 52 this.addEventListener('updateSourceVisibility', (e) => { 53 const { source, enabled } = e.detail; 54 55 const group = this.querySelector(`.results-group[data-source="${source}"]`); 56 if (group) { 57 if (enabled) { 58 group.classList.remove('disabled'); 59 } else { 60 group.classList.add('disabled'); 61 this.collapseGroup(group); 62 } 63 } 64 }); 65 66 this.addEventListener('resize', () => { 67 const group = this.querySelector('.results-group:not(.collapsed)'); 68 if (group) { 69 this.fixGroupHeight(group); 70 } 71 }); 72 73 this.initObservers(); 74 } 75 76 initObservers() { 77 const options = { root: null, rootMargin: '0px', threshold: 0.1 }; 78 this.observer = new IntersectionObserver(entries => { 79 entries.forEach(entry => { 80 if (entry.isIntersecting) { 81 const groupEl = entry.target.closest('.results-group'); 82 const source = groupEl?.dataset.source; 83 if (source) { 84 const sentinel = groupEl.querySelector('.load-more-sentinel'); 85 if (sentinel) { 86 sentinel.innerHTML = '<div class="text-center text-muted py-2">Loading...</div>'; 87 } 88 this.dispatchEvent(new CustomEvent('loadMore', { 89 detail: { source }, 90 bubbles: true 91 })); 92 } 93 } 94 }); 95 }, options); 96 97 requestAnimationFrame(() => { 98 this.list.querySelectorAll('.load-more-sentinel').forEach(sentinel => { 99 this.observer.observe(sentinel); 100 101 const groupEl = sentinel.closest('.results-group'); 102 if (groupEl && this.isInView(sentinel)) { 103 const source = groupEl.dataset.source; 104 if (source) { 105 this.dispatchEvent(new CustomEvent('loadMore', { detail: { source }, bubbles: true })); 106 } 107 } 108 }); 109 }); 110 } 111 112 isInView(el) { 113 const rect = el.getBoundingClientRect(); 114 return rect.bottom > 0 && rect.top < window.innerHeight; 115 } 116 117 118 async render() { 119 120 if (!this._listData) return; 121 122 this.list.innerHTML = ''; // clear list 123 124 // disconnect old observer to prevent memory leaks 125 if (this.observer) { 126 this.observer.disconnect(); 127 } 128 129 // reinit observer 130 const options = { root: null, rootMargin: '0px', threshold: 0.1 }; 131 this.observer = new IntersectionObserver(entries => {
132 entries.forEach(entry => { 133 if (entry.isIntersecting) { 134 const groupEl = entry.target.closest('.results-group'); 135 const source = groupEl?.dataset.source; 136 if (source) { 137 this.dispatchEvent(new CustomEvent('loadMore', { 138 detail: { source }, 139 bubbles: true 140 })); 141 } 142 } 143 }); 144 }, options); 145 146 for (const src of this._listData) { 147 // create group 148 const group = document.createElement('div'); 149 group.className = 'results-group'; 150 group.dataset.source = src.slug; 151 152 // header 153 const header = document.createElement('div'); 154 header.className = 'results-group-header d-flex align-items-center justify-content-between'; 155 header.setAttribute('role', 'button'); 156 header.dataset.action = 'toggle-group'; 157 header.innerHTML = `<div class="fw-semibold">${src.title} <span class="text-muted">(${src.totalItems || 0})</span></div><span class="group-chevron">â¾</span>`; 158 group.appendChild(header); 159 160 // items container 161 const itemsContainer = document.createElement('div'); 162 itemsContainer.className = 'results-group-items'; 163 164 const itemsWrapper = document.createElement('div'); 165 itemsWrapper.className = 'items'; 166 itemsContainer.appendChild(itemsWrapper); 167 if (src.items && src.items) { 168 for (const item of src.items) { 169 itemsWrapper.appendChild(this.buildItemHTML(item, src)); 170 } 171 } 172 173 group.appendChild(itemsContainer); 174 175 // sentinel 176 const sentinel = document.createElement('div'); 177 sentinel.className = 'load-more-sentinel'; 178 itemsContainer.appendChild(sentinel); 179 180 // **observe the new sentinel** 181 this.observer.observe(sentinel); 182 183 this.list.appendChild(group); 184 } 185 } 186 187 buildItemHTML(item, src) { 188 let html; 189 if (src.templateFn) { 190 item.source = src.slug; 191 html = src.templateFn(item); 192 } else { 193 html = `<pre>${JSON.stringify(item, null, 2)}</pre>`; 194 } 195 const div = document.createElement('div'); 196 div.innerHTML = html; 197 return div.firstElementChild; 198 } 199 200 appendItemsToSource(src, items) { 201 const groupEl = this.list.querySelector(`.results-group[data-source="${src.slug}"]`); 202 if (!groupEl) return; 203 const itemsContainer = groupEl.querySelector('.results-group-items .items'); 204 for (const item of items) { 205 itemsContainer.appendChild(this.buildItemHTML(item, src)); 206 } 207 208 if (items.length < 50) { 209 const sentinel = groupEl.querySelector('.load-more-sentinel'); 210 if (sentinel) { 211 this.observer.unobserve(sentinel); 212 sentinel.innerHTML = '<div class="text-center text-muted py-2">No more items</div>'; 213 } 214 } 215 } 216 217 toggleGroup(group) { 218 if (this.groupCount > 1) { 219 const chevron = group.querySelector('.group-chevron'); 220 group.classList.toggle('collapsed'); 221 chevron.textContent = group.classList.contains('collapsed') ? 'â¸' : 'â¾'; 222 223 if (!group.classList.contains('collapsed')) { 224 this.querySelectorAll('.results-group').forEach(g => { 225 if (g !== group) { 226 this.collapseGroup(g); 227 } 228 }); 229 } 230 } 231 } 232 233 collapseGroup(group) { 234 group.classList.add('collapsed'); 235 const c = group.querySelector('.group-chevron'); 236 if (c) c.textContent = 'â¸'; 237 } 238 239 fixGroupHeight(group) { 240 const content = group.querySelector('.results-group-items'); 241 if (content) { 242 let availableHeight = this.clientHeight; 243 const headers = Array.from(this.querySelectorAll('.results-group-header')); 244
245 headers.forEach(h => { 246 availableHeight -= h.offsetHeight; 247 }); 248 249 content.style.maxHeight = availableHeight + 'px'; 250 } 251 } 252 253 activateItem(source, id) { 254 const item = this.list.querySelector(`.list-group-item[data-source="${source}"][data-id="${id}"]`); 255 if (!item) return; 256 this.list.querySelectorAll('.list-group-item').forEach(i => i.classList.remove('active')); 257 item.classList.add('active'); 258 } 259});
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.