PageSourceSearch

https://piter.tours/js/n/modules/service/FilterSheet.js

js piter.tours collected 2026-10-08 12:09:18 UTC 35,966 bytes, 1,072 lines download raw bytes

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, '&amp;')
379      .replace(/</g, '&lt;')
380      .replace(/>/g, '&gt;')
381      .replace(/"/g, '&quot;');
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.