1/* import { FilterSelector } from '/js/n/modules/service/FilterSelector.js'; */ 2 3class FilterSelector { 4 5 constructor(container, order, config) { 6 this._root = typeof container === 'string' 7 ? document.querySelector(container) 8 : container; 9 10 if (!(this._root instanceof HTMLElement)) { 11 throw new Error(`FilterSelector: конÑÐµÐ¹Ð½ÐµÑ Ð½Ðµ найден â «${container}»`); 12 } 13 14 this._order = order; 15 this._config = config; 16 this._state = {}; 17 this._badgeMap = new Map(); 18 this._listeners = {}; 19 20 this._render(); 21 } 22 23 /* âââ Public API âââââââââââââââââââââââââââââââââââââââââ */ 24 25 setState(state = {}) { 26 // СбÑаÑÑваем вÑÑ Ð²Ð¸Ð·ÑалÑно 27 this._badgeMap.forEach(btn => this._deselect(btn)); 28 this._state = {}; 29 30 for (const [secId, raw] of Object.entries(state)) { 31 const ids = raw instanceof Set 32 ? [...raw] 33 : Array.isArray(raw) ? raw : []; 34 35 if (!ids.length) continue; 36 37 const section = this._config[secId]; 38 if (!section) continue; 39 40 this._state[secId] = new Set(); 41 42 ids.forEach(optId => { 43 const key = this._key(secId, optId); 44 if (!this._badgeMap.has(key)) return; 45 46 if (!this._isMultiple(section)) this._clearSection(secId); 47 48 this._state[secId].add(optId); 49 this._select(this._badgeMap.get(key)); 50 }); 51 } 52 53 // ÐоÑле воÑÑÑÐ°Ð½Ð¾Ð²Ð»ÐµÐ½Ð¸Ñ ÑоÑÑоÑÐ½Ð¸Ñ â обновлÑем default-бейджи 54 this._order.forEach(secId => this._refreshDefaultBadge(secId)); 55 56 this._emit('change', this._payload()); 57 return this; 58 } 59 60 getState() { 61 const result = {}; 62 for (const [k, v] of Object.entries(this._state)) { 63 if (v.size > 0) result[k] = new Set(v); 64 } 65 return result; 66 } 67 68 getCount() { 69 let n = 0;
70 for (const [secId, set] of Object.entries(this._state)) { 71 for (const optId of set) { 72 // default-опÑии не ÑÑиÑаем 73 if (!this._isDefault(secId, optId)) n++; 74 } 75 } 76 return n; 77 } 78 79 getPayload() { 80 const result = {}; 81 for (const [secId, set] of Object.entries(this._state)) { 82 // ÐÑклÑÑаем default-опÑии из payload 83 const ids = [...set].filter(optId => !this._isDefault(secId, optId)); 84 if (ids.length > 0) result[secId] = ids; 85 } 86 return result; 87 } 88 89 reset() { 90 this._badgeMap.forEach(btn => this._deselect(btn)); 91 this._state = {}; 92 // ÐоÑле ÑбÑоÑа â вÑе default-бейджи ÑÑановÑÑÑÑ Ð°ÐºÑивнÑми 93 this._order.forEach(secId => this._refreshDefaultBadge(secId)); 94 this._emit('change', this._payload()); 95 return this; 96 } 97 98 toggle(secId, optId) { 99 const section = this._config[secId]; 100 if (!section) return this; 101 if (!this._badgeMap.has(this._key(secId, optId))) return this; 102 this._handleToggle(secId, optId, section); 103 return this; 104 } 105 106 on(event, fn) { 107 if (!this._listeners[event]) this._listeners[event] = []; 108 this._listeners[event].push(fn); 109 return this; 110 } 111 112 off(event, fn) { 113 if (!this._listeners[event]) return this; 114 this._listeners[event] = this._listeners[event].filter(f => f !== fn); 115 return this; 116 } 117 118 destroy() { 119 this._root.innerHTML = ''; 120 this._badgeMap.clear(); 121 this._listeners = {}; 122 this._state = {}; 123 } 124 125 /* âââ Ð ÐµÐ½Ð´ÐµÑ DOM âââââââââââââââââââââââââââââââââââââââââ */ 126 127 _render() { 128 this._root.innerHTML = ''; 129 this._badgeMap.clear(); 130 131 const fragment = document.createDocumentFragment(); 132 133 this._order.forEach((secId, idx) => { 134 const section = this._config[secId]; 135 if (!section) return; 136 137 if (idx > 0) { 138 const hr = document.createElement('hr'); 139 hr.className = 'filter-divider'; 140 fragment.appendChild(hr); 141 } 142 143 fragment.appendChild(this._renderSection(section)); 144 }); 145 146 this._root.appendChild(fragment); 147 } 148 149 _renderSection(section) { 150 const wrap = document.createElement('div'); 151 wrap.className = 'filter-section'; 152 wrap.dataset.sectionId = section.id; 153 154 const head = document.createElement('div'); 155 head.className = 'filter-section__head'; 156 head.innerHTML = ` 157 ${section.icon 158 ? `<span class="filter-section__icon" aria-hidden="true">${section.icon}</span>` 159 : ''} 160 <span class="filter-section__title">${this._esc(section.label)}</span> 161 ${section.hint 162 ? `<span class="filter-section__subtitle">${this._esc(section.hint)}</span>` 163 : ''}`; 164 165 const grid = document.createElement('div'); 166 grid.className = 'badges-wrap'; 167 grid.setAttribute('role', section.multiple === false ? 'radiogroup' : 'group'); 168 grid.setAttribute('aria-label', section.label); 169 170 section.options.forEach(opt => { 171 const btn = this._renderBadge(section, opt); 172 this._badgeMap.set(this._key(section.id, opt.id), btn); 173 grid.appendChild(btn); 174 }); 175 176 wrap.appendChild(head); 177 wrap.appendChild(grid); 178 return wrap; 179 } 180 181 _renderBadge(section, opt) { 182 const btn = document.createElement('button'); 183 btn.type = 'button'; 184 btn.className = 'badge'; 185 btn.dataset.section = section.id; 186 btn.dataset.option = String(opt.id); 187 188 // ÐаÑкиÑÑем default-опÑÐ¸Ñ 189 if (opt.default) { 190 btn.dataset.default = 'true'; 191 btn.classList.add('badge--default'); 192 } 193 194 const isSingle = !this._isMultiple(section); 195 btn.setAttribute('role', isSingle ? 'radio' : 'checkbox'); 196 btn.setAttribute('aria-checked', 'false'); 197 btn.setAttribute('aria-label', opt.label); 198 199 if (opt.stars) { 200 btn.classList.add('badge--stars'); 201 btn.innerHTML = ` 202 <span class="badge__stars" aria-hidden="true"> 203 ${'â '.repeat(opt.stars)}${'â'.repeat(5 - opt.stars)} 204 </span>
205 <span class="badge__label">${this._esc(opt.label)}</span>`; 206 } else { 207 btn.innerHTML = ` 208 ${opt.icon 209 ? `<span class="badge__icon" aria-hidden="true">${opt.icon}</span>` 210 : ''} 211 <span class="badge__label">${this._esc(opt.label)}</span>`; 212 } 213 214 btn.addEventListener('click', () => { 215 this._handleToggle(section.id, String(opt.id), section); 216 }); 217 218 if (isSingle) { 219 btn.addEventListener('keydown', e => { 220 if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { 221 e.preventDefault(); 222 this._focusNextBadge(btn, +1); 223 } 224 if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { 225 e.preventDefault(); 226 this._focusNextBadge(btn, -1); 227 } 228 }); 229 } 230 231 return btn; 232 } 233 234 /* âââ Ðогика вÑбоÑа ââââââââââââââââââââââââââââââââââââââ */ 235 236 _handleToggle(secId, optId, section) { 237 if (!this._state[secId]) this._state[secId] = new Set(); 238 239 const isDefault = this._isDefault(secId, optId); 240 const wasSelected = this._state[secId].has(optId); 241 const btn = this._badgeMap.get(this._key(secId, optId)); 242 243 if (isDefault) { 244 // Ðлик по default â ÑбÑаÑÑваем веÑÑ Ñаздел 245 this._clearSection(secId); 246 // default визÑалÑно акÑивен 247 if (btn) this._select(btn); 248 249 } else if (!this._isMultiple(section)) { 250 // ÐдиноÑнÑй вÑÐ±Ð¾Ñ 251 this._clearSection(secId); 252 if (!wasSelected) { 253 this._state[secId].add(optId); 254 if (btn) this._select(btn); 255 } 256 257 } else { 258 // ÐножеÑÑвеннÑй вÑÐ±Ð¾Ñ â toggle 259 if (wasSelected) { 260 this._state[secId].delete(optId); 261 if (btn) this._deselect(btn); 262 } else { 263 this._state[secId].add(optId); 264 if (btn) this._select(btn); 265 } 266 } 267 268 // ÐбновлÑем визÑал default-бейджа Ñаздела 269 this._refreshDefaultBadge(secId); 270 271 this._emit('change', { 272 ...this._payload(), 273 lastChanged: { secId, optId, selected: !wasSelected }, 274 }); 275 } 276 277 _clearSection(secId) { 278 const current = this._state[secId]; 279 if (!current) return; 280 current.forEach(optId => { 281 const btn = this._badgeMap.get(this._key(secId, optId)); 282 if (btn) this._deselect(btn); 283 }); 284 current.clear(); 285 } 286 287 /** 288 * ÐбновлÑем визÑалÑное ÑоÑÑоÑние default-бейджа: 289 * акÑивен еÑли в Ñазделе Ð½ÐµÑ ÑеалÑнÑÑ (не-default) вÑбоÑов 290 */ 291 _refreshDefaultBadge(secId) { 292 const section = this._config[secId]; 293 if (!section) return; 294 295 const defaultOpt = section.options?.find(o => o.default); 296 if (!defaultOpt) return; 297 298 const defaultBtn = this._badgeMap.get(this._key(secId, String(defaultOpt.id))); 299 if (!defaultBtn) return; 300 301 const hasRealSelection = this._hasRealSelection(secId); 302 303 if (hasRealSelection) { 304 this._deselect(defaultBtn); 305 } else { 306 this._select(defaultBtn); 307 } 308 } 309 310 /** ÐÑÑÑ Ð»Ð¸ в Ñазделе Ñ Ð¾ÑÑ Ð±Ñ Ð¾Ð´Ð¸Ð½ не-default вÑбÑаннÑй ÑÐ»ÐµÐ¼ÐµÐ½Ñ */ 311 _hasRealSelection(secId) { 312 const set = this._state[secId]; 313 if (!set || set.size === 0) return false; 314 for (const optId of set) { 315 if (!this._isDefault(secId, optId)) return true; 316 } 317 return false; 318 } 319 320 /* âââ ÐизÑал бейджа ââââââââââââââââââââââââââââââââââââââ */ 321 322 _select(btn) { 323 btn.classList.add('is-selected'); 324 btn.setAttribute('aria-checked', 'true'); 325 btn.classList.remove('badge--pop'); 326 void btn.offsetWidth; 327 btn.classList.add('badge--pop'); 328 } 329 330 _deselect(btn) { 331 btn.classList.remove('is-selected', 'badge--pop'); 332 btn.setAttribute('aria-checked', 'false'); 333 } 334 335 /* âââ ÐлавиаÑÑÑа âââââââââââââââââââââââââââââââââââââââââ */ 336 337 _focusNextBadge(currentBtn, dir) { 338 const grid = currentBtn.closest('.badges-wrap'); 339 const badges = [...grid.querySelectorAll('.badge')]; 340 const idx = badges.indexOf(currentBtn); 341 const next = badges[(idx + dir + badges.length) % badges.length]; 342 if (next) { 343 next.focus(); 344 this._handleToggle( 345 next.dataset.section, 346 next.dataset.option, 347 this._config[next.dataset.section] 348 ); 349 } 350 } 351 352 /* âââ СобÑÑÐ¸Ñ ââââââââââââââââââââââââââââââââââââââââââââ */ 353 354 _emit(event, data) { 355 if (!this._listeners[event]) return; 356 this._listeners[event].forEach(fn => {
357 try { fn(data); } 358 catch (err) { console.error(`FilterSelector «${event}»:`, err); } 359 }); 360 } 361 362 /* âââ УÑилиÑÑ ââââââââââââââââââââââââââââââââââââââââââââ */ 363 364 _key(secId, optId) { return `${secId}::${optId}`; } 365 _isMultiple(section) { return section.multiple !== false; } 366 _payload() { return { state: this.getState(), count: this.getCount() }; } 367 368 /** ЯвлÑеÑÑÑ Ð»Ð¸ опÑÐ¸Ñ default */ 369 _isDefault(secId, optId) { 370 const section = this._config[secId]; 371 if (!section) return false; 372 const opt = section.options?.find(o => String(o.id) === String(optId)); 373 return opt?.default === true; 374 } 375 376 _esc(str) { 377 return String(str) 378 .replace(/&/g, '&') 379 .replace(/</g, '<') 380 .replace(/>/g, '>') 381 .replace(/"/g, '"'); 382 } 383} 384 385export class FilterSheet { 386 387 constructor({ http, endpoint = '/api/v1/filters' } = {}) { 388 this._http = http; 389 this._endpoint = endpoint; 390 391 this._cache = new Map(); 392 this._pending = new Map(); 393 this._savedState = new Map(); 394 this._badgeCounts = new Map(); 395 this._payloads = new Map(); 396 397 this._selector = null; 398 399 this._buttons = new Set(); 400 this._activeBtn = null; 401 this._activeSection = null; 402 this._activeDetail = null; 403 404 this._overlay = null; 405 this._modal = null; 406 this._bodyEl = null; 407 this._countEl = null; 408 this._titleEl = null; 409 410 this._listeners = []; 411 this._detailObserver = null; 412 413 // â NEW: ÑекÑии, коÑоÑÑми ÑпÑавлÑÐµÑ Ð²Ð½ÐµÑний Ð¼ÐµÐ½ÐµÐ´Ð¶ÐµÑ 414 this._managedSections = new Set(); 415 } 416 417 /* âââ УÑилиÑа: ÑоÑÑавной клÑÑ ââââââââââââââââââââââââââââ */ 418 419 _stateKey(section, detail) { 420 const d = detail || 'all'; 421 return `${section}::${d}`; 422 } 423 424 /* âââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 425 /* â NEW: УпÑавление Ñежимом "managed" */ 426 /* âââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 427 428 /** 429 * ÐомеÑÐ°ÐµÑ ÑекÑÐ¸Ñ ÐºÐ°Ðº ÑпÑавлÑемÑÑ Ð¸Ð·Ð²Ð½Ðµ. 430 * FilterSheet ÐРбÑдеÑ: 431 * - авÑомаÑиÑеÑки иниÑиализиÑоваÑÑ state из config (selected) 432 * - авÑомаÑиÑеÑки загÑÑжаÑÑ ÑилÑÑÑÑ Ð¿Ñи Ñмене detail 433 * - ÑбÑаÑÑваÑÑ state пÑи авÑономнÑÑ Ð¾Ð¿ÐµÑаÑиÑÑ 434 * 435 * FilterSheet ÐУÐÐТ: 436 * - ÑендеÑиÑÑ Ð¼Ð¾Ð´Ð°Ð», кнопки, бейджи 437 * - пÑинимаÑÑ setPayload / resetFilters Ð¾Ñ Ð²Ð½ÐµÑнего менеджеÑа 438 * - загÑÑжаÑÑ ÐºÐ¾Ð½Ñиг по ÑÐ²Ð½Ð¾Ð¼Ñ Ð²ÑÐ·Ð¾Ð²Ñ _loadFilters() 439 * 440 * @param {string} section 441 */ 442 manage(section) { 443 this._managedSections.add(section); 444 console.log(`[FilterSheet] ÑекÑÐ¸Ñ "${section}" пеÑеведена в managed-Ñежим`); 445 return this; 446 } 447 448 /** 449 * Ð¡Ð½Ð¸Ð¼Ð°ÐµÑ managed-Ñежим Ñ ÑекÑии 450 * @param {string} section 451 */ 452 unmanage(section) { 453 this._managedSections.delete(section); 454 return this; 455 } 456 457 /** 458 * ÐÑовеÑÑеÑ, ÑпÑавлÑеÑÑÑ Ð»Ð¸ ÑекÑÐ¸Ñ Ð¸Ð·Ð²Ð½Ðµ 459 * @param {string} section 460 * @returns {boolean} 461 */ 462 isManaged(section) { 463 return this._managedSections.has(section); 464 } 465 466 /* âââ PUBLIC âââââââââââââââââââââââââââââââââââââââââââââ */ 467 468 init() { 469 const buttons = [...document.querySelectorAll('[data-open-filters]')]; 470 buttons.forEach(btn => this._registerButton(btn)); 471 472 this._on(document, 'filtersheet:register', (e) => { 473 const btn = e.detail?.btn; 474 if (btn) this._registerButton(btn); 475 }); 476 477 // â NEW: ÑÐ²Ð½Ð°Ñ Ð¿ÑедзагÑÑзка по запÑоÑÑ Ð¸Ð·Ð²Ð½Ðµ (напÑимеÑ, из мобилÑной модалки) 478 this._on(document, 'filtersheet:prefetch', (e) => { 479 const { section, detail, payload } = e.detail ?? {}; 480 if (!section) return; 481 482 this.prefetch(section, detail).then(() => { 483 // еÑли пÑиÑÑл ÑÐ¾Ñ ÑанÑннÑй payload â пÑименÑем его повеÑÑ ÐºÐ¾Ð½Ñига 484 if (payload && Object.keys(payload).length) { 485 this.setPayload(section, detail || (document.body.dataset.detail ?? 'all'), payload); 486 } 487 }).catch(() => { /* Ñже залогиÑовано в prefetch */ }); 488 }); 489 490 this._watchDetail(); 491 this._syncAllBadges(); 492 493 return this; 494 } 495 496 destroy() {
497 this._listeners.forEach(({ el, type, fn }) => el.removeEventListener(type, fn)); 498 this._listeners = []; 499 500 this._detailObserver?.disconnect(); 501 this._detailObserver = null; 502 503 this._buttons.clear(); 504 this._activeBtn = null; 505 506 this._selector?.destroy(); 507 this._selector = null; 508 509 this._overlay?.remove(); 510 this._modal?.remove(); 511 this._overlay = null; 512 this._modal = null; 513 514 // â NEW 515 this._managedSections.clear(); 516 } 517 518 /** 519 * ÐÑблиÑÐ½Ð°Ñ Ð¿ÑедзагÑÑзка конÑига ÑилÑÑÑов Ð´Ð»Ñ ÑекÑии/деÑали. 520 * РабоÑÐ°ÐµÑ Ð¸ Ð´Ð»Ñ managed-ÑекÑий (в Ð¾Ð±Ñ Ð¾Ð´ авÑо-логики _onDetailChange). 521 * ÐÑполÑзÑÐµÑ ÐºÑÑ + дедÑпликаÑÐ¸Ñ ÑеÑез _loadFilters. 522 * 523 * @param {string} section 524 * @param {string} detail 525 * @returns {Promise<object>} data конÑига 526 */ 527 async prefetch(section, detail) { 528 const d = detail || (document.body.dataset.detail ?? 'all'); 529 try { 530 const data = await this._loadFilters(section, d); 531 // поÑле загÑÑзки конÑига обновлÑем бейджи (вдÑÑг из config пÑиÑли selected) 532 this._syncBadgesForSection(section, d); 533 return data; 534 } catch (err) { 535 console.warn(`[FilterSheet] prefetch не ÑдалÑÑ: ${section}::${d}`, err); 536 throw err; 537 } 538 } 539 540 /* âââ ÐÑблиÑнÑй доÑÑÑп (без изменений) âââââââââââââââââââ */ 541 542 getPayload(section, detail) { 543 const key = this._stateKey(section, detail); 544 return this._payloads.get(key) ?? {}; 545 } 546 547 getCount(section, detail) { 548 const key = this._stateKey(section, detail); 549 return this._badgeCounts.get(key) ?? 0; 550 } 551 552 resetFilters(section, detail) { 553 const key = this._stateKey(section, detail); 554 this._savedState.delete(key); 555 this._badgeCounts.set(key, 0); 556 this._payloads.set(key, {}); 557 558 if (this._activeSection === section && this._activeDetail === detail) { 559 this._selector?.reset(); 560 this._updateCount(0); 561 } 562 563 this._syncBadgesForSection(section, detail); 564 } 565 566 setPayload(section, detail, payload) { 567 const key = this._stateKey(section, detail); 568 569 const state = {};
570 for (const [secId, ids] of Object.entries(payload || {})) { 571 state[secId] = new Set(ids); 572 } 573 574 this._savedState.set(key, state); 575 576 const count = Object.values(payload || {}) 577 .reduce((sum, arr) => sum + (arr?.length ?? 0), 0); 578 this._badgeCounts.set(key, count); 579 this._payloads.set(key, payload || {}); 580 581 if (this._activeSection === section && this._activeDetail === detail && this._selector) { 582 this._selector.setState(state); 583 this._updateCount(this._selector.getCount()); 584 } 585 586 this._syncBadgesForSection(section, detail); 587 this._updateBoundButtonTexts(section, detail); 588 } 589 590 /* âââ РегиÑÑÑаÑÐ¸Ñ ÐºÐ½Ð¾Ð¿Ð¾Ðº (без изменений) âââââââââââââââââ */ 591 592 _registerButton(btn) { 593 if (this._buttons.has(btn)) return; 594 this._buttons.add(btn); 595 this._on(btn, 'click', () => this._handleOpen(btn)); 596 597 if (btn.dataset.originalText === undefined) { 598 const textSpan = btn.querySelector('.filter-btn__text'); 599 if (textSpan) btn.dataset.originalText = textSpan.textContent.trim(); 600 } 601
602 const section = btn.dataset.filterSection ?? 'main'; 603 const detail = document.body.dataset.detail ?? 'all'; 604 const key = this._stateKey(section, detail); 605 this._updateBadgeFor(btn, this._badgeCounts.get(key) ?? 0); 606 } 607 608 /* âââ Следим за data-detail ââââââââââââââââââââââââââââââ */ 609 610 _watchDetail() { 611 this._detailObserver = new MutationObserver((mutations) => { 612 for (const mutation of mutations) { 613 if (mutation.attributeName === 'data-detail') { 614 const newDetail = document.body.dataset.detail ?? 'all'; 615 this._onDetailChange(newDetail); 616 } 617 } 618 }); 619 620 this._detailObserver.observe(document.body, { 621 attributes: true, 622 attributeFilter: ['data-detail'], 623 }); 624 } 625 626async _onDetailChange(newDetail) { 627 const sections = new Set(); 628 this._buttons.forEach(btn => { 629 const s = btn.dataset.filterSection; 630 if (s) sections.add(s); 631 }); 632 633 for (const section of sections) { 634 // â managed-ÑекÑии: ÐРзагÑÑжаем авÑомаÑиÑеÑки пÑи Ñмене detail, 635 // ÑолÑко ÑÐ¸Ð½Ñ ÑонизиÑÑем бейджи (Ð¼ÐµÐ½ÐµÐ´Ð¶ÐµÑ Ñам ÑеÑÐ¸Ñ ÐºÐ¾Ð³Ð´Ð° загÑÑзиÑÑ) 636 if (this.isManaged(section)) { 637 console.log(`[FilterSheet] detail changed â "${section}" is managed, skipping auto-load`); 638 this._syncBadgesForSection(section, newDetail); 639 continue; 640 } 641 642 const key = this._stateKey(section, newDetail); 643 644 if (!this._cache.has(key) && !this._pending.has(key)) { 645 try { 646 await this._loadFilters(section, newDetail); 647 } catch (err) { 648 console.warn(`[FilterSheet] пÑедзагÑÑзка не ÑдалаÑÑ: ${key}`, err); 649 } 650 } 651 652 this._syncBadgesForSection(section, newDetail); 653 } 654 } 655 656 /* âââ DOM модала (без изменений) âââââââââââââââââââââââââ */ 657 658 _buildModal() { 659 this._overlay = document.createElement('div'); 660 this._overlay.className = 'adv-modal-overlay'; 661 662 this._modal = document.createElement('div'); 663 this._modal.id = 'advModal'; 664 this._modal.className = 'adv-modal'; 665 this._modal.setAttribute('role', 'dialog'); 666 this._modal.setAttribute('aria-modal', 'true'); 667 this._modal.setAttribute('aria-labelledby', 'advModalTitle'); 668 this._modal.setAttribute('aria-hidden', 'true'); 669 670 this._modal.innerHTML = ` 671 <div class="adv-modal__header"> 672 <h2 class="adv-modal__title" id="advModalTitle">ФилÑÑÑÑ</h2> 673 <button type="button" class="adv-modal__close" aria-label="ÐакÑÑÑÑ ÑаÑÑиÑеннÑй поиÑк"> 674 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" 675 stroke="currentColor" stroke-width="2.5" stroke-linecap="round"> 676 <line x1="18" y1="6" x2="6" y2="18"/> 677 <line x1="6" y1="6" x2="18" y2="18"/> 678 </svg> 679 </button> 680 </div> 681 <div class="adv-modal__body"></div> 682 <div class="adv-modal__footer"> 683 <span class="adv-modal__selected-count"> 684 ÐÑбÑано ÑÑловий: <strong>0</strong> 685 </span> 686 <div class="adv-modal__actions"> 687 <button type="button" class="adv-modal_btn adv-modal__btn-reset" style="padding-top:10px;margin-right:5px;"> 688 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" 689 stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> 690 <line x1="18" y1="6" x2="6" y2="18"/> 691 <line x1="6" y1="6" x2="18" y2="18"/> 692 </svg> 693 СбÑоÑиÑÑ 694 </button> 695 <button type="button" class="adv-modal_btn adv-modal__btn-apply"> 696 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" 697 stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> 698 <polyline points="20 6 9 17 4 12"/> 699 </svg> 700 ÐÑимениÑÑ 701 </button> 702 </div> 703 </div>`; 704 705 this._bodyEl = this._modal.querySelector('.adv-modal__body'); 706 this._countEl = this._modal.querySelector('.adv-modal__selected-count strong'); 707 this._titleEl = this._modal.querySelector('.adv-modal__title'); 708 709 document.body.appendChild(this._overlay); 710 document.body.appendChild(this._modal); 711 } 712 713 _bindModal() { 714 const closeBtn = this._modal.querySelector('.adv-modal__close'); 715 this._on(closeBtn, 'click', () => this._close()); 716 this._on(this._overlay, 'click', () => this._close()); 717 718 this._on(document, 'keydown', e => { 719 if (e.key === 'Escape' && this._isOpen()) this._close(); 720 }); 721 722 const resetBtn = this._modal.querySelector('.adv-modal__btn-reset'); 723 this._on(resetBtn, 'click', () => { 724 this._selector?.reset();
725 const key = this._stateKey(this._activeSection, this._activeDetail); 726 this._savedState.delete(key); 727 this._badgeCounts.set(key, 0); 728 this._payloads.set(key, {}); 729 this._updateCount(0); 730 this._syncBadgesForSection(this._activeSection, this._activeDetail); 731 this._updateBoundButtonTexts(this._activeSection, this._activeDetail); 732 733 document.dispatchEvent(new CustomEvent('filtersheet:reset', { 734 bubbles: true, 735 detail: { section: this._activeSection, detail: this._activeDetail }, 736 })); 737 }); 738 739 const applyBtn = this._modal.querySelector('.adv-modal__btn-apply'); 740 this._on(applyBtn, 'click', () => this._handleApply()); 741 } 742 743 /* âââ ÐÑкÑÑÑÑ (без изменений) ââââââââââââââââââââââââââââ */ 744 745 async _handleOpen(btn) { 746 this._activeBtn = btn; 747 this._activeSection = btn.dataset.filterSection ?? 'main'; 748 this._activeDetail = document.body.dataset.detail ?? 'all'; 749 750 if (!this._modal) { 751 this._buildModal(); 752 this._bindModal(); 753 } 754 755 const key = this._stateKey(this._activeSection, this._activeDetail); 756 757 let data = this._cache.get(key); 758 759 if (!data) { 760 this._renderLoading(); 761 this._open(); 762 763 try { 764 data = await this._loadFilters(this._activeSection, this._activeDetail); 765 } catch (err) { 766 console.error('[FilterSheet] оÑибка загÑÑзки ÑилÑÑÑов:', err); 767 this._renderError(); 768 return; 769 } 770 } 771 772 this._renderFilters(data, this._activeSection, this._activeDetail); 773 774 if (!this._isOpen()) this._open(); 775 } 776 777 /* âââ ÐагÑÑзка ÑилÑÑÑов (Ñ Ð´ÐµÐ´ÑпликаÑией) ââââââââââââââââ */ 778 779 async _loadFilters(section, detail) { 780 const key = this._stateKey(section, detail); 781 782 if (this._cache.has(key)) return this._cache.get(key); 783 if (this._pending.has(key)) return this._pending.get(key); 784 785 const promise = this._fetchAndProcess(section, detail, key); 786 this._pending.set(key, promise); 787 788 try { 789 return await promise; 790 } finally { 791 this._pending.delete(key); 792 } 793 } 794 795 async _fetchAndProcess(section, detail, key) { 796 const data = await this._http.post(this._endpoint, { section, detail }); 797 if (!data?.success) throw new Error('success !== true'); 798 799 this._cache.set(key, data); 800 801 // â FIX: иниÑиализиÑÑем state из конÑига Ð´Ð»Ñ ÐСÐÐ¥ ÑекÑий 802 // пÑи пеÑвой загÑÑзке (еÑли savedState еÑÑ Ð½ÐµÑ). 803 // Managed-ÑекÑии заÑиÑÐµÐ½Ñ Ð¾Ñ Ð¿ÐµÑезапиÑи пÑи Ñмене detail 804 // в _onDetailChange(), а не здеÑÑ. 805 if (!this._savedState.has(key)) { 806 this._initStateFromConfig(key, data.config); 807 } 808 809 return data; 810 } 811 812 /** Ðзвлекаем selected из конÑига â state + badge + payload */ 813 _initStateFromConfig(key, config) { 814 const initialState = this._extractSelectedFromConfig(config); 815 if (Object.keys(initialState).length === 0) return; 816 817 const setState = {};
818 for (const [secId, ids] of Object.entries(initialState)) { 819 setState[secId] = new Set(ids); 820 } 821 this._savedState.set(key, setState); 822 823 const payload = {}; 824 for (const [secId, ids] of Object.entries(initialState)) { 825 const sectionCfg = config[secId]; 826 const filtered = ids.filter(id => { 827 const opt = sectionCfg.options?.find(o => String(o.id) === String(id)); 828 return !opt?.default; 829 }); 830 if (filtered.length) payload[secId] = filtered; 831 } 832 833 const count = Object.values(payload).reduce((s, a) => s + a.length, 0); 834 this._payloads.set(key, payload); 835 this._badgeCounts.set(key, count); 836 } 837 838 _extractSelectedFromConfig(config) { 839 const state = {}; 840 for (const [secId, section] of Object.entries(config || {})) { 841 if (!Array.isArray(section.options)) continue; 842 const selected = section.options 843 .filter(opt => opt.selected === true) 844 .map(opt => String(opt.id)); 845 if (selected.length > 0) state[secId] = selected; 846 } 847 return state; 848 } 849 850 /* âââ Ð ÐµÐ½Ð´ÐµÑ FilterSelector (без изменений) ââââââââââââââ */ 851 852 _renderFilters({ order, config }, section, detail) { 853 this._selector?.destroy(); 854 this._bodyEl.innerHTML = ''; 855 856 this._selector = new FilterSelector(this._bodyEl, order, config); 857 858 const key = this._stateKey(section, detail); 859 const saved = this._savedState.get(key); 860 861 if (saved) this._selector.setState(saved); 862 863 this._selector.on('change', ({ count }) => this._updateCount(count)); 864 this._updateCount(this._selector.getCount()); 865 } 866 867 _renderLoading() { 868 this._selector?.destroy(); 869 this._selector = null; 870 this._bodyEl.innerHTML = ` 871 <div class="adv-modal__loading"> 872 <div class="spinner"></div> 873 <p>ÐагÑÑзка ÑилÑÑÑов...</p> 874 </div>`; 875 this._updateCount(0); 876 } 877 878 _renderError() { 879 this._selector?.destroy(); 880 this._selector = null; 881 this._bodyEl.innerHTML = `<div class="adv-modal__empty"><p>ФилÑÑÑов неÑ</p></div>`; 882 this._updateCount(0); 883 } 884 885 /* âââ ÐÑимениÑÑ (без изменений) ââââââââââââââââââââââââââ */ 886 887 _handleApply() { 888 const payload = this._selector?.getPayload() ?? {}; 889 const count = this._selector?.getCount() ?? 0; 890 const section = this._activeSection; 891 const detail = this._activeDetail; 892 const key = this._stateKey(section, detail); 893 894 const state = this._selector?.getState() ?? {}; 895 this._savedState.set(key, state); 896 this._badgeCounts.set(key, count); 897 this._payloads.set(key, payload); 898 899 this._syncBadgesForSection(section, detail); 900 this._updateBoundButtonTexts(section, detail); 901 902 document.dispatchEvent(new CustomEvent('filtersheet:apply', { 903 bubbles: true, 904 detail: { section, detail, payload, count }, 905 })); 906 907 this._close(); 908 } 909 910 /* âââ bindToButton (без изменений) âââââââââââââââââââââââ */ 911 912 _updateBoundButtonTexts(section, detail) { 913 const key = this._stateKey(section, detail); 914 const data = this._cache.get(key); 915 const config = data?.config; 916 if (!config) return; 917 918 const payload = this._payloads.get(key) ?? {}; 919
920 for (const [secId, secConfig] of Object.entries(config)) { 921 if (!secConfig.bindToButton) continue; 922 923 const buttons = [...this._buttons].filter(btn => 924 btn.dataset.section === secId && 925 (btn.dataset.filterSection ?? section) === section 926 ); 927 if (!buttons.length) continue; 928 929 const selectedIds = payload[secId] || []; 930 const firstOpt = selectedIds.length 931 ? secConfig.options?.find(o => String(o.id) === String(selectedIds[0]) && !o.default) 932 : null; 933 934 buttons.forEach(btn => { 935 const textSpan = btn.querySelector('.filter-btn__text'); 936 if (!textSpan) return; 937 textSpan.textContent = firstOpt 938 ? firstOpt.label + '...' 939 : (btn.dataset.originalText || ''); 940 }); 941 } 942 } 943 944 /* âââ ÐÑкÑÑÑÑ / закÑÑÑÑ Ð¼Ð¾Ð´Ð°Ð» (без изменений) ââââââââââââ */ 945 946 _open() { 947 this._syncBadgesForSection(this._activeSection, this._activeDetail); 948 949 this._overlay.classList.add('is-visible'); 950 this._modal.classList.add('is-open', 'is-visible'); 951 this._modal.setAttribute('aria-hidden', 'false'); 952 document.body.classList.add('modal-open'); 953 } 954 955 _close() { 956 if (this._activeSection && this._selector) { 957 const key = this._stateKey(this._activeSection, this._activeDetail); 958 const state = this._selector.getState(); 959 this._savedState.set(key, state); 960 } 961 962 this._overlay.classList.remove('is-visible'); 963 this._modal.classList.remove('is-open', 'is-visible'); 964 this._modal.setAttribute('aria-hidden', 'true'); 965 document.body.classList.remove('modal-open'); 966 } 967 968 _isOpen() { 969 return this._modal?.classList.contains('is-open') ?? false; 970 } 971 972 /* âââ СÑÑÑÑик / бейджи (без изменений) âââââââââââââââââââ */ 973 974 _updateCount(count) { 975 if (this._countEl) this._countEl.textContent = count; 976 } 977 978 _syncBadgesForSection(section, detail) { 979 const key = this._stateKey(section, detail); 980 const count = this._badgeCounts.get(key) ?? 0; 981 982 // â ÐбновлÑем заÑегиÑÑÑиÑованнÑе кнопки 983 this._buttons.forEach(btn => {
984 const btnSection = btn.dataset.filterSection ?? 'main'; 985 if (btnSection === section) { 986 this._updateBadgeFor(btn, count); 987 } 988 }); 989 990 // â¡ â FIX: иÑем ÐСРкнопки в DOM Ñ matching section 991 // ÐÑо покÑÑÐ²Ð°ÐµÑ ÑлÑÑай когда кнопки ÑендеÑÑÑÑÑ Ð´Ð¸Ð½Ð°Ð¼Ð¸ÑеÑки 992 // или бÑли пÑопÑÑÐµÐ½Ñ Ð¿Ñи init() 993 this._findAllButtonsForSection(section).forEach(btn => { 994 this._updateBadgeFor(btn, count); 995 // авÑо-ÑегиÑÑÑаÑÐ¸Ñ Ð¿ÑопÑÑеннÑÑ ÐºÐ½Ð¾Ð¿Ð¾Ðº 996 if (!this._buttons.has(btn)) { 997 this._registerButton(btn); 998 } 999 }); 1000 } 1001 1002 /** 1003 * â NEW: Ð½Ð°Ñ Ð¾Ð´Ð¸Ñ Ð²Ñе кнопки ÑилÑÑÑов в DOM Ð´Ð»Ñ Ð´Ð°Ð½Ð½Ð¾Ð¹ ÑекÑии 1004 * УÑиÑÑÐ²Ð°ÐµÑ ÐºÐ°Ðº ÑвнÑй data-filter-section, Ñак и fallback 'main' 1005 */ 1006 _findAllButtonsForSection(section) { 1007 const result = []; 1008 // Ðнопки Ñ Ñвно Ñказанной ÑекÑией 1009 const explicit = document.querySelectorAll( 1010 `[data-open-filters][data-filter-section="${section}"]` 1011 ); 1012 explicit.forEach(btn => result.push(btn)); 1013 1014 // ÐÑли section === 'main', Ñакже иÑем кнопки ÐÐÐ data-filter-section 1015 // (Ñ.к. fallback = 'main') 1016 if (section === 'main') { 1017 const allFilterBtns = document.querySelectorAll('[data-open-filters]'); 1018 allFilterBtns.forEach(btn => {
1019 if (!btn.dataset.filterSection) { 1020 result.push(btn); 1021 } 1022 }); 1023 } 1024 1025 return result; 1026 } 1027 1028 _syncAllBadges() { 1029 const detail = document.body.dataset.detail ?? 'all'; 1030 1031 // â FIX: ÑобиÑаем ÑекÑии не ÑолÑко из заÑегиÑÑÑиÑованнÑÑ ÐºÐ½Ð¾Ð¿Ð¾Ðº, 1032 // но и из вÑÐµÑ ÐºÐ½Ð¾Ð¿Ð¾Ðº в DOM 1033 const sections = new Set(); 1034 1035 this._buttons.forEach(btn => { 1036 sections.add(btn.dataset.filterSection ?? 'main'); 1037 }); 1038 1039 document.querySelectorAll('[data-open-filters]').forEach(btn => { 1040 sections.add(btn.dataset.filterSection ?? 'main'); 1041 }); 1042 1043 for (const section of sections) { 1044 this._syncBadgesForSection(section, detail); 1045 } 1046 } 1047 1048 _updateBadgeFor(btn, count) { 1049 let badge = btn.querySelector('.widget-advanced-btn__badge'); 1050 1051 // â FIX: не пеÑедаÑм пÑÑÑÑÑ ÑÑÑÐ¾ÐºÑ Ð² getElementById 1052 if (!badge && btn.dataset.badge) { 1053 badge = document.getElementById(btn.dataset.badge); 1054 } 1055 1056 if (!badge) { 1057 badge = document.createElement('span'); 1058 badge.className = 'widget-advanced-btn__badge'; 1059 btn.appendChild(badge); 1060 } 1061 1062 badge.textContent = count; 1063 badge.hidden = count === 0; 1064 } 1065 1066 /* âââ УÑилиÑа ââââââââââââââââââââââââââââââââââââââââââââ */ 1067 1068 _on(el, type, fn) { 1069 el.addEventListener(type, fn); 1070 this._listeners.push({ el, type, fn }); 1071 } 1072}
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.