PageSourceSearch

https://pedspecialist.ru/local/templates/pedspecialist/assets/js/catalog.js?v=a9fdaef6

js pedspecialist.ru collected 2026-10-05 00:31:59 UTC 30,428 bytes, 501 lines download raw bytes

1/* Поведение каталога: поиск, отбор, сортировка и подгрузка порций.
2
3   В макете отбор был клиентским — все наборы лежали в разметке. На сайте выдачу считает сервер:
4   отбор и сортировка меняют параметры адреса, а строка поиска, «Показать ещё» и пересчёт под
5   открытым окном отбора подтягивают выдачу тем же компонентом. Устройство панели отбора целиком —
6   docs/reference/45-catalog-filters.md.
7
8   Порция и поиск ходят по одному адресу — текущая страница с `ajax=items`. Значит, выдача
9   строки поиска и выдача по адресу `/katalog/?q=…` совпадают по построению: это один и тот же
10   ответ одного и того же компонента, а не два похожих отбора. Ищет при этом класс CatalogSearch —
11   тот же, которым отвечает окно по лупе.
12
13   Числа взяты у окна поиска и не выдуманы заново (docs/reference/03-search.md):
14     2 знака    короче запрос не даёт ни одной основы, и поиск переходит на подстроку;
15     200 мс     при обычной скорости набора между нажатиями 160–250 мс — запрос уходит раз на слово;
16     50 ответов кэш на сеанс: стирание последнего знака и возврат к прежнему запросу не создают запроса.
17
18   Скрипт подключают две страницы — каталог и раздел. Строка на них одна и та же: раздел
19   отвечает тем же порционным ответом, только сузив выдачу своим составом. Всё, что касается
20   строки, включается при наличии поля: в разделе без наборов его нет вовсе. */
21(function () {
22  var form = document.querySelector('[data-catalog-filters]');
23  var grid = document.getElementById('grid');
24
25  /* ── Окно отбора живёт только на узком экране ────────────────────────────── */
26  /* На широком панель стоит колонкой слева прямо в потоке: окно и его обёртки становятся
27     display:contents и своей коробки не рисуют. Границу можно пересечь с открытым окном —
28     планшет из портрета в ландшафт даёт ровно 1024 px. Тогда панель уходит в поток, а окно
29     остаётся модальным: страница под ним недоступна, а закрыть её нечем — крестик спрятан
30     тем же lg. Закрываем сами на переходе через границу. */
31  var panel = document.getElementById('filters-panel');
32  var wide = window.matchMedia('(min-width: 64rem)');
33  function keepInFlow() { if (panel && panel.open && wide.matches) panel.close(); }
34  keepInFlow();
35  if (wide.addEventListener) wide.addEventListener('change', keepInFlow);
36
37  if (!grid) return;
38
39  var q = document.getElementById('q');
40  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
41
42  /* Отбор, сортировка и запрос живут в адресе страницы: он и есть состояние выдачи.
43     Сортировка, порция, отбор и поиск собирают свой адрес отсюда, поэтому ни одно условие
44     не теряется по дороге. */
45  function params() {
46    var address = new URLSearchParams(window.location.search);
47    address.delete('ajax');
48    address.delete('page');
49    return address;
50  }
51
52  /* Строка запроса из собранного адреса. Запятые остаются запятыми: несколько отмеченных
53     пунктов одной группы компонент ждёт списком через запятую (pickList), а повтор ключа
54     PHP не складывает — из «price=free&price=le100» до отбора доходит только последнее,
55     и второй отмеченный пункт пропадал молча. Запятая в строке запроса законна, и сервер
56     получает её той же, что и в виде %2C. */
57  function query(address) {
58    return address.toString().replace(/%2C/g, ',');
59  }
60
61  // адрес страницы с собранной строкой запроса — им ходят и переходы, и обращения за выдачей
62  function link(address) {
63    var search = query(address);
64    return window.location.pathname + (search ? '?' + search : '');
65  }
66
67  /* ── Сортировка ─────────────────────────────────────────────────────────── */
68  [].slice.call(document.querySelectorAll('[data-sort]')).forEac
68h(function (btn) {
69    btn.addEventListener('click', function () {
70      var address = params();
71      address.set('sort', btn.dataset.sort);
72      window.location.href = link(address);
73    });
74  });
75
76  /* ── Счётчики выдачи ────────────────────────────────────────────────────── */
77  var moreWrap = document.querySelector('[data-more-wrap]');
78  var moreBtn = document.querySelector('[data-more]');
79  var moreCount = document.querySelector('[data-more-count]');
80  var moreWord = document.querySelector('[data-more-word]');
81  var shown = document.querySelector('[data-shown]');
82  var shownBtn = document.querySelector('[data-shown-btn]');
83  var empty = document.getElementById('empty');
84  var emptyNote = document.querySelector('[data-empty]');
85  /* Выход в общий поиск из пустой выдачи — только у раздела: набор мог лежать в другом.
86     В каталоге искать шире уже негде, и строки там нет. */
87  var searchAll = document.querySelector('[data-search-all]');
88  var searchAllLink = document.querySelector('[data-search-all-link]');
89
90  function plural(n, one, few, many) {
91    var t = n % 10, h = n % 100;
92    if (h >= 11 && h <= 14) return many;
93    if (t === 1) return one;
94    if (t >= 2 && t <= 4) return few;
95    return many;
96  }
97
98  /* Ответ приходит со счётом первой строкой (шаблон items.php): сколько найдено, сколько
99     показано, сколько осталось и что писать при пустой выдаче. Числа берём оттуда, а не
100     считаем заново — иначе правила порции пришлось бы держать в двух местах. */
101  function parse(html) {
102    var box = document.createElement('div');
103    box.innerHTML = html.trim();
104
105    var meta = box.querySelector('[data-catalog-meta]');
106    if (meta) box.removeChild(meta);
107
108    return { meta: meta ? meta.dataset : null, cards: box };
109  }
110
111  function counters(meta) {
112    if (!meta) return;
113
114    var found = parseInt(meta.found, 10);
115    var left = parseInt(meta.left, 10);
116
117    if (shown) shown.textContent = meta.shown;
118    if (moreWrap) moreWrap.hidden = left === 0;
119    if (moreCount) moreCount.textContent = left;
120    if (moreWord) moreWord.textContent = plural(left, 'набор', 'набора', 'наборов');
121    if (moreBtn) moreBtn.dataset.page = meta.page;
122    if (empty) empty.hidden = found > 0;
123    if (emptyNote && meta.empty) emptyNote.textContent = meta.empty;
124
125    // адрес общего поиска приходит с ответом: пустой — значит вести некуда (запроса нет)
126    if (searchAll && meta.all !== undefined) {
127      searchAll.hidden = meta.all === '';
128      if (searchAllLink && meta.all !== '') searchAllLink.href = meta.all;
129    }
130
131    if (shownBtn) {
132      shownBtn.textContent = found;
133      // слово стоит в кнопке текстом, а не отдельным элементом — так же, как в макете
134      if (shownBtn.nextSibling) {
135        shownBtn.nextSibling.textContent = ' ' + plural(found, 'набор', 'набора', 'наборов');
136      }
137    }
138  }
139
140  /* Ответ заменяет сетку целиком: карточки первой порции нового отбора. Прежние снимаются,
141     иначе к выдаче по новым условиям приписались бы наборы, под них не подошедшие. */
142  function paint(html) {
143    var answer = parse(html);
144    grid.innerHTML = '';
145    while (answer.cards.firstElementChild) grid.appendChild(answer.cards.firstElementChild);
146    counters(answer.meta);
147  }
148
149  /* Адрес отражает состояние выдачи: ссылку можно отдать, а обновление страницы возвращает
150     ровно то, что было на экране. replaceState, а не pushState: ни набор фразы, ни перебор
151     флажков не должны оставлять по записи истории на каждое нажатие. */
152  function remember(address) {
153    history.replaceState(null, '', link(address));
154  }
155
156  /* ── Отбор ──────────────────────────────────────────────────────────────── */
157  /* На широком экране панель стоит колонкой слева, рядом с выдачей: там отметка применяется
158     сразу, страницей — тем же способом, что и выбор сортировки. Ждать нечего, лишний шаг
159     «применить» был бы работой на пустом месте.
160
161     На телефоне панель — окно поверх выдачи, и та же немедленность закрывала бы его после
162     каждой галочки: набрать два условия было нельзя, окно приходилось вызывать заново.
163     Поэтому пока панель открыта окном, отметка не перезагружает страницу, а пересчитывает
164     выдачу под окном тем же ответом `ajax=items`, которым отвечает строка поиска. Счёт
165     приходит с ответом, поэтому «Показать N наборов» называет то, что человек увидит,
166     закрыв окно, а не то, что было до отбора.
167
168     Условие «панель открыта окном» — это `panel.open`: на широком экране окно закрыто всегда
169     (keepInFlow выше), а панель видна за счёт `lg:contents`. */
170
171  var boxes = form ? [].slice.call(form.querySelectorAll('input[type="checkbox"]')) : [];
172  var filtersCount = document.querySelector('[data-filters-count]');
173  var showBtn = shownBtn ? shownBtn.closest('button') : null;
174  var pick = null;
175  var pickTurn = 0;
176
177  // строка поиска стоит вне формы отбора, но в том же адресе: приводим её так же, как поиск
178  function typed() {
179    return q ? q.value.replace(/\s+/g, ' ').trim() : '';
180  }
181
182  /* Адрес выдачи по нынешнему состоянию панели. Сортировка и всё, чего панель не касается,
183     берутся из адреса страницы; отбор — из самих полей, а не из адреса: в окне он мог
184     измениться и ещё не примениться. Номер порции params() снимает — отбор начинает счёт
185     заново. */
186  function chosen() {
187    var address = params();
188
189    ['price', 'vol', 'rev'].forEach(function (name) { address.delete(name); });
190
191    var groups = {};
192    boxes.forEach(function (box) {
193      if (!box.checked) return;
194      (groups[box.name] = groups[box.name] || []).push(box.value);
195    });
196    Object.keys(groups).forEach(function (name) { address.set(name, groups[name].join(',')); });
197
198    var value = typed();
199    if (value === '') address.delete('q'); else address.set('q', value);
200
201    return address;
202  }
203
204  // Число в кружке у кнопки «Фильтры»: на телефоне панель спрятана, и кружок — единственный
205  // признак, что человек что-то отметил. Считает он именно отмеченное, а не суженность
206  // выдачи: флажок стоит с того мгновения, как его нажали, ещё до ответа сервера.
207  function badge() {
208    if (!filtersCount) return;
209
210    var on = boxes.filter(function (box) { return box.checked; }).length;
211    filtersCount.textContent = on;
212    filtersCount.hidden = on === 0;
213  }
214
215  /* Пересчёт выдачи под открытым окном. Прежнее обращение отменяется, а номер обращения
216     отличает ответ на отмеченное сейчас от опоздавшего — те же две меры, что у строки
217     поиска, и по той же причине: отмены мало, отправленный до неё ответ может прийти
218     после нового. */
219  function recount() {
220    if (pick) { pick.abort(); pick = null; }
221    var ticket = ++pickTurn;
222
223    var address = chosen();
224    var request = new URLSearchParams(address);
225    request.set('ajax', 'items');
226
227    pick = window.AbortController ? new AbortController() : null;
228    if (showBtn) showBtn.setAttribute('aria-busy', 'true');
229
230    window.fetch(link(request), {
231      headers: { 'X-Requested-With': 'fetch' },
232      credentials: 'same-origin',
233      signal: pick ? pick.signal : undefined
234    }).then(function (response) {
235      return response.ok ? response.text() : null;
236    }).then(function (html) {
237      if (ticket !== pickTurn) return;
238
239      // пометку снимает то обращение, которое её поставило, — и когда ответ пришёл,
240      // и когда сервер отказал: иначе на отказе она осталась бы висеть навсегда
241      if (showBtn) showBtn.removeAttribute('aria-busy');
242      if (html === null || html === undefined) return;
243
244      /* Адрес переписывается вместе с выдачей, а не до неё. Адрес — состояние выдачи;
245         если обращение не дошло, он обещал бы отбор, которого на экране нет, а ссылка
246         вела бы не туда, куда смотрел человек. Отмеченный флажок в этом случае остаётся
247         отмеченным — это просьба, которая ещё не исполнилась, и снимать её за человека
248         мы не вправе. */
249      remember(address);
250      paint(html);
251    }).catch(function () {
252      /* Отменённое обращение и оборванная сеть выглядят одинаково: и выдача, и адрес,
253         и число в кнопке остаются прежними — то есть сходятся между собой. Пометку
254         снимает то обращение, которое её поставило. */
255      if (ticket === pickTurn && showBtn) showBtn.removeAttribute('aria-busy');
256    });
257  }
258
259  function apply() {
260    badge();
261
262    if (panel && panel.open && window.fetch) {
263      recount();
264      return;
265    }
266
267    window.location.href = link(chosen());
268  }
269
270  boxes.forEach(function (box) { box.addEventListener('change', apply); });
271
272  /* Сброс. Кнопок на странице две, и работа у них разная.
273
274     «Сбросить фильтры» в панели снимает отметки — и только их: строка поиска и сортировка
275     стоят вне панели, и стирать их этой кнопкой значило бы отменять то, чего человек
276     не отменял. Раньше кнопка была `type="reset"`, и не работала нигде: обычный сброс
277     возвращает поля к напечатанному сервером, а сервер печатает их отмеченными по адресу —
278     галочки снимались и тут же возвращались.
279
280     «Показать все N наборов» в пустой выдаче снимает и отметки, и запрос: обещание кнопки —
281     весь каталог. Сортировку она сохраняет — это порядок выдачи, а не условие отбора. */
282  [].slice.call(document.querySelectorAll('[data-reset-filters]')).forEac
282h(function (btn) {
283    btn.addEventListener('click', function () {
284      boxes.forEach(function (box) { box.checked = false; });
285      apply();
286    });
287  });
288
289  [].slice.call(document.querySelectorAll('[data-reset-all]')).forEach(function (btn) {
290    btn.addEventListener('click', function () {
291      boxes.forEach(function (box) { box.checked = false; });
292      if (q) q.value = '';
293      apply();
294    });
295  });
296
297  /* «Показать N наборов» закрывает окно разметкой (command="close") — выдача под ним уже
298     пересчитана, применять нечего. Остаётся подвести к ней глаза: страница осталась там,
299     где человек нажал «Фильтры», и без прокрутки он не увидит, что изменилось. Прокрутка
300     идёт по событию close, а не сразу: пока окно открыто, страница под ним не прокручивается. */
301  var toResults = false;
302
303  if (showBtn && panel) {
304    showBtn.addEventListener('click', function () { toResults = true; });
305    panel.addEventListener('close', function () {
306      if (!toResults) return;
307      toResults = false;
308
309      var header = document.querySelector('header');
310      var gap = (header ? header.getBoundingClientRect().height : 0) + 16;
311      var top = grid.getBoundingClientRect().top + window.scrollY - gap;
312
313      window.scrollTo({ top: Math.max(0, top), behavior: reduce.matches ? 'auto' : 'smooth' });
314    });
315  }
316
317  /* ── Подгрузка порции ───────────────────────────────────────────────────── */
318  if (moreBtn) {
319    moreBtn.addEventListener('click', function () {
320      var next = parseInt(moreBtn.dataset.page || '1', 10) + 1;
321      var request = params();
322      request.set('page', String(next));
323      request.set('ajax', 'items');
324
325      moreBtn.disabled = true;
326      fetch(link(request), { headers: { 'X-Requested-With': 'fetch' } })
327        .then(function (response) { return response.text(); })
328        .then(function (html) {
329          var answer = parse(html);
330          while (answer.cards.firstElementChild) grid.appendChild(answer.cards.firstElementChild);
331          counters(answer.meta);
332        })
333        .catch(function () {
334          // подгрузка не удалась — оставляем кнопку рабочей, чтобы можно было повторить
335        })
336        .finally(function () { moreBtn.disabled = false; });
337    });
338  }
339
340  if (!q) return;
341
342  /* ── Лупа в шапке ───────────────────────────────────────────────────────── */
343  /* Там, где поиск уже стоит на странице, лупа не открывает окно, а ставит курсор в строку
344     и подводит её к глазам (макеты design/catalog.html и design/category.html). Кнопка
345     в шапке одна на все страницы, поэтому переключаем её здесь, на странице со строкой:
346     снимаем command и commandfor — без них окно не откроет ни встроенный вызов, ни его
347     подмена в @tailwindplus/elements. Без скрипта кнопка остаётся прежней и открывает окно. */
348  var lens = document.querySelector('header [commandfor="search-dialog"]');
349  if (lens) {
350    lens.removeAttribute('command');
351    lens.removeAttribute('commandfor');
352    lens.setAttribute('data-focus-search', '');
353
354    // название говорит о действии, а не об охвате: в каталоге строка ищет по каталогу,
355    // в разделе — по разделу, а кнопка на обеих страницах делает одно и то же
356    var name = lens.querySelector('.sr-only');
357    if (name) name.textContent = 'Перейти к строке поиска';
358
359    lens.addEventListener('click', function () {
360      // ведём страницу так, чтобы строка встала сразу под шапкой: одного курсора в поле
361      // мало — движение показывает, что нажатие сработало
362      var header = document.querySelector('header');
363      var gap = (header ? header.getBoundingClientRect().height : 0) + 16;
364      var top = q.getBoundingClientRect().top + window.scrollY - gap;
365
366      window.scrollTo({ top: Math.max(0, top), behavior: reduce.matches ? 'auto' : 'smooth' });
367      q.focus({ preventScroll: true });
368
369      // если строка и так была на экране, прокрутки не будет — подсвечиваем поле
370      q.style.backgroundColor = 'var(--color-accent-soft)';
371      setTimeout(function () { q.style.backgroundColor = ''; }, 900);
372    });
373  }
374
375  /* ── Строка поиска ──────────────────────────────────────────────────────── */
376  /* Без fetch строка остаётся обычной формой: Enter отправляет её и страница
377     перезагружается — выдача та же, просто не на лету. */
378  if (!window.fetch) return;
379
380  var MIN = 2, DELAY = 200, KEEP = 50;
381  var clear = document.querySelector('[data-clear]');
382  var cache = new Map();
383  var timer = null;
384  var flight = null;
385  var turn = 0;
386
387  // запрос короче двух знаков поиск не разбирает: для сервера он равен пустому,
388  // и каталог остаётся целиком — как окно в этом же состоянии показывает «Чаще всего покупают»
389  function asked(value) {
390    return value.length >= MIN ? value : '';
391  }
392
393  function ask(value) {
394    // не `query`: так называется сборка строки запроса выше, и обращение к ней отсюда
395    // молча получило бы строку вместо функции
396    var asking = asked(value);
397
398    /* Адрес отражает набранное: ссылку можно отдать, а обновление страницы возвращает ту же
399       выдачу — короткий запрос сервер не разбирает точно так же, как не отправляет его скрипт.
400       Отмеченные пункты отбора приходят из адреса и остаются в нём: поиск их не теряет. */
401    var address = params();
402    if (value === '') address.delete('q'); else address.set('q', value);
403    remember(address);
404
405    var request = params();
406    if (asking === '') request.delete('q'); else request.set('q', asking);
407    request.set('ajax', 'items');
408
409    /* Ключ кэша — весь адрес выдачи, а не одна фраза. Пока отбор менялся только
410       перезагрузкой страницы, фразы хватало: кэш умирал вместе со страницей. Теперь окно
411       на телефоне меняет отбор без перезагрузки, и ответ, сложенный до отбора, по одной
412       фразе выдавался бы после него — адрес обещал бы два набора, а в сетке лежали бы
413       восемь, и ссылка показала бы не то, что человек видел.
414
415       Регистр снимается с самой фразы, до сборки ключа. Со всего адреса его снимать нельзя:
416       в собранном адресе фраза процентно-кодирована, и свёртка задела бы шестнадцатеричные
417       цифры, а не буквы, — «Грамоты» и «грамоты» разошлись бы на %D0%93 и %D0%B3 и стали
418       двумя обращениями вместо одного. Остальные значения адреса и так в нижнем регистре. */
419    var keyed = new URLSearchParams(request);
420    if (asking !== '') keyed.set('q', asking.toLocaleLowerCase());
421    var key = query(keyed);
422
423    // Прежний ответ не нужен ни при попадании в кэш, ни при новом обращении: иначе ответ
424    // на «день к» пришёл бы после ответа на «день космонавтики». Номер обращения растёт
425    // на каждый запрос — по нему отличается ответ на набранное сейчас от опоздавшего.
426    if (flight) { flight.abort(); flight = null; }
427    var ticket = ++turn;
428
429    if (cache.has(key)) {
430      q.removeAttribute('aria-busy');
431      paint(cache.get(key));
432      return;
433    }
434
435    flight = window.AbortController ? new AbortController() : null;
436    q.setAttribute('aria-busy', 'true');
437
438    window.fetch(link(request), {
439      headers: { 'X-Requested-With': 'fetch' },
440      credentials: 'same-origin',
441      signal: flight ? flight.signal : undefined
442    }).then(function (response) {
443      return response.ok ? response.text() : null;
444    }).then(function (html) {
445      // ответ годен и опоздавший: набранное к нему ещё вернётся стиранием знака
446      if (html !== null && html !== undefined) {
447        if (cache.size >= KEEP) cache.delete(cache.keys().next().value);
448        cache.set(key, html);
449      }
450
451      if (ticket !== turn) return;
452
453      // пометку снимает то обращение, которое её поставило, — и на отказе сервера тоже:
454      // при !response.ok сюда приходит пустой ответ, а не отказ промиса, и catch молчит
455      q.removeAttribute('aria-busy');
456      if (html === null || html === undefined) return;
457
458      paint(html);
459    }).catch(function () {
460      /* Отменённый запрос и оборванная сеть выглядят одинаково: в обоих случаях
461         показанное остаётся на месте. Пометку снимает то обращение, которое её поставило. */
462      if (ticket === turn) q.removeAttribute('aria-busy');
463    });
464  }
465
466  function stop() {
467    if (timer) { clearTimeout(timer); timer = null; }
468  }
469
470  function schedule() {
471    if (clear) clear.hidden = q.value === '';
472    stop();
473    timer = setTimeout(function () { timer = null; ask(typed()); }, DELAY);
474  }
475
476  q.addEventListener('input', schedule);
477  // Escape и крестик самого поля стирают строку событием search, а не input
478  q.addEventListener('search', schedule);
479
480  if (clear) {
481    clear.hidden = q.value === '';
482    clear.addEventListener('click', function () {
483      q.value = '';
484      clear.hidden = true;
485      q.focus();
486      stop();
487      ask('');
488    });
489  }
490
491  /* Enter отправляет форму и перезагружает страницу — тем же отбором и с тем же итогом,
492     что уже на экране. Без скрипта это единственный способ искать, поэтому форма остаётся
493     как есть; со скриптом отправку перехватываем и выполняем отложенный запрос досрочно. */
494  if (form) {
495    form.addEventListener('submit', function (event) {
496      event.preventDefault();
497      stop();
498      ask(typed());
499    });
500  }
501})();

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.