PageSourceSearch

https://photohuman.space/wp-content/themes/photohuman/source/js/modules/catalogFilter.js?v=1.6.797

js photohuman.space collected 2026-09-28 08:54:35 UTC 14,077 bytes, 527 lines download raw bytes

1const VISIBLE_COUNT = 4;
2
3/** Filter groups expanded via «смотреть все» — survive AJAX sidebar replace. */
4const expandedFilterGroups = new Set();
5
6/** «Больше фильтров» block open state — null until first sync with DOM. */
7let extraFiltersOpen = null;
8
9const isCatalogFilterLoading = () => document.querySelector(".catalog")?.dataset.filterLoading === "1";
10
11const getFilterGroupKey = (filter) => filter?.dataset?.filterGroup || "";
12
13const rememberExpandedFilter = (filter, isOpen) => {
14	const key = getFilterGroupKey(filter);
15
16	if (!key) {
17		return;
18	}
19
20	if (isOpen) {
21		expandedFilterGroups.add(key);
22		return;
23	}
24
25	expandedFilterGroups.delete(key);
26};
27
28/**
29 * Snapshot currently open «смотреть все» groups before sidebar HTML replace.
30 */
31export const captureExpandedFilterGroups = (catalog = document.querySelector(".catalog")) => {
32	catalog?.querySelectorAll(".catalog-sidebar .filter.open").forEach((filter) => {
33		const key = getFilterGroupKey(filter);
34
35		if (key) {
36			expandedFilterGroups.add(key);
37		}
38	});
39
40	return [...expandedFilterGroups];
41};
42
43/**
44 * Remember whether «Больше фильтров» is open before sidebar HTML replace.
45 */
46export const captureExtraFiltersOpen = (catalog = document.querySelector(".catalog")) => {
47	const extra = catalog?.querySelector(".catalog-sidebar .filters-extra");
48
49	if (!extra) {
50		return extraFiltersOpen;
51	}
52
53	extraFiltersOpen = extra.classList.contains("_open") && !extra.hasAttribute("hidden");
54	return extraFiltersOpen;
55};
56
57const applyExtraFiltersVisibility = (sidebar, open) => {
58	const extra = sidebar?.querySelector(".filters-extra");
59	const btn = sidebar?.querySelector(".filters-more-btn");
60
61	if (!extra || !btn) {
62		return;
63	}
64
65	if (open) {
66		extra.hidden = false;
67		extra.removeAttribute("hidden");
68		extra.classList.add("_open");
69		btn.textContent = btn.dataset.less || "Меньше фильтров";
70		btn.setAttribute("aria-expanded", "true");
71		// Heights were measured while display:none → max-height:0; recalc after show.
72		refreshFilterGroupHeights(extra);
73		return;
74	}
75
76	extra.hidden = true;
77	extra.setAttribute("hidden", "");
78	extra.classList.remove("_open");
79	btn.textContent = btn.dataset.more || "Больше фильтров";
80	btn.setAttribute("aria-expanded", "false");
81};
82
83const refreshFilterGroupHeights = (root) => {
84	if (!root) {
85		return;
86	}
87
88	root.querySelectorAll(".filter").forEach((filter) => {
89		if (filter.classList.contains("_price")) {
90			return;
91		}
92
93		const container = filter.querySelector(".filter-container");
94		const checks = container ? Array.from(container.querySelectorAll(".check")) : [];
95
96		if (!container || !checks.length) {
97			return;
98		}
99
100		if (checks.length > VISIBLE_COUNT) {
101			if (filter.classList.contains("open") || expandedFilterGroups.has(getFilterGroupKey(filter))) {
102				setContainerHeight(container, filter, checks, "all", false);
103				return;
104			}
105
106			setContainerHeight(container, filter, checks, Math.min(VISIBLE_COUNT, checks.length), false);
107			return;
108		}
109
110		setContainerHeight(container, filter, checks, checks.length, false);
111	});
112};
113
114const syncExtraFiltersSection = (catalog) => {
115	const sidebar = catalog?.querySelector(".catalog-sidebar");
116
117	if (!sidebar?.querySelector(".filters-extra")) {
118		return;
119	}
120
121	if (extraFiltersOpen === null) {
122		const extra = sidebar.querySelector(".filters-extra");
123		extraFiltersOpen = extra.classList.contains("_open") && !extra.hasAttribute("hidden");
124	}
125
126	applyExtraFiltersVisibility(sidebar, extraFiltersOpen);
127};
128
129const getScrollbarWidth = () => window.innerWidth - document.documentElement.clientWidth;
130
131const lockBodyScroll = () => {
132	document.body.classList.add("_stopScroll");
133	document.body.style.paddingRight = `${getScrollbarWidth()}px`;
134};
135
136const unlockBodyScroll = () => {
137	document.body.classList.remove("_stopScroll");
138	document.body.style.paddingRight = "";
139};
140
141/**
142 * Keep body scroll lock in sync with an open mobile catalog sidebar.
143 * Call after AJAX sidebar replace (node with `_show` is removed from DOM).
144 */
145export const syncCatalogSidebarScrollLock = (catalog = document.querySelector(".catalog")) => {
146	const openSidebar = catalog?.querySelector(".catalog-sidebar._show");
147
148	if (openSidebar) {
149		lockBodyScroll();
150		return;
151	}
152
153	unlockBodyScroll();
154};
155
156const setChecksVisible = (checks, mode) => {
157	const limit = mode === "all" ? checks.length : mode;
158
159	checks.forEach((check, index) => {
160		if (mode === "all" || index < limit) {
161			check.classList.remove("hidden");
162			check.style.cssText = "";
163			return;
164		}
165
166		check.classList.add("hidden");
167		check.style.cssText = "";
168	});
169};
170
171const measureVisibleHeight = (checks, limit) => {
172	let height = 0;
173
174	checks.forEach((check, index) => {
175		if (index < limit) {
176			height += check.offsetHeight;
177		}
178	});
179
180	return height;
181};
182
183const setContainerHeight = (container, filter, checks, mode, animate = false) => {
184	filter.classList.remove("_animating");
185	container.style.transition = "none";
186
187	if (!animate) {
188		if (mode === "all") {
189			setChecksVisible(checks, checks.length);
190			container.style.maxHeight = `${container.scrollHeight}px`;
191			return;
192		}
193
194		setChecksVisible(checks, mode);
195		container.style.maxHeight = `${measureVisibleHeight(checks, mode)}px`;
196		return;
197	}
198
199	if (mode === "all") {
200		// Expand: reveal items under current clipped max-height, then animate open.
201		setChecksVisible(checks, checks.length);
202		const target = container.scrollHeight;
203		void container.offsetHeight;
204		filter.classList.add("_animating");
205		container.style.maxHeight = `${target}px`;
206		return;
207	}
208
209	// Collapse: animate max-height first, hide overflow items after.
210	const target = measureVisibleHeight(checks, mode);
211	container.style.maxHeight = `${container.scrollHeight}px`;
212	void container.offsetHeight;
213	filter.classList.add("_animating");
214	container.style.maxHeight = `${target}px`;
215
216	const onEnd = (event) => {
217		if (event.target !== container || event.propertyName !== "max-height") {
218			return;
219		}
220
221		container.removeEventListener("transitionend", onEnd);
222		filter.classList.remove("_animating");
223		setChecksVisible(checks, mode);
224		container.style.maxHeight = `${measureVisibleHeight(checks, mode)}px`;
225	};
226
227	container.addEventListener("transitionend", onEnd);
228};
229
230const initPriceFilter = (filter) => {
231	const container = filter.querySelector(".filter-container");
232	const resetBtn = filter.querySelector(".filter-reset");
233	const titleEl = filter.querySelector(".filter-title");
234	const defaultTitle = titleEl?.textContent ?? "";
235
236	if (titleEl) {
237		titleEl.dataset.defaultTitle = defaultTitle;
238	}
239
240	resetBtn?.addEventListener("click", () => {
241		if (isCatalogFilterLoading()) {
242			return;
243		}
244
245		container?.querySelectorAll(".check__input").forEach((input) => {
246			input.checked = false;
247		});
248
249		if (titleEl) {
250			titleEl.textContent = defaultTitle;
251		}
252
253		document.dispatchEvent(new CustomEvent("photohuman:catalog-filter-appl
253y"));
254	});
255};
256
257const initStandardFilter = (filter) => {
258	const container = filter.querySelector(".filter-container");
259	const checks = container ? Array.from(container.querySelectorAll(".check")) : [];
260	const moreBtn = filter.querySelector(".filter-more");
261	const resetBtn = filter.querySelector(".filter-reset");
262	const titleEl = filter.querySelector(".filter-title");
263	const rawTitle = titleEl?.textContent?.trim() ?? "";
264	const defaultTitle = rawTitle.replace(/\s*\[\d+\]\s*$/u, "").trim() || rawTitle;
265	const moreLabelDefault = moreBtn?.dataset.default || moreBtn?.textContent.trim() || "смотреть все";
266	const moreLabelLess = moreBtn?.dataset.less || "свернуть";
267
268	if (!container || !checks.length) {
269		return;
270	}
271
272	if (titleEl) {
273		titleEl.dataset.defaultTitle = defaultTitle;
274	}
275
276	const updateTitle = () => {
277		if (!titleEl) {
278			return;
279		}
280
281		const checkedCount = container.querySelectorAll(".check__input:checked").length;
282
283		titleEl.textContent =
284			checkedCount > 0 ? `${defaultTitle} [${checkedCount}]` : defaultTitle;
285	};
286
287	const applyCollapsedHeight = (animate = false) => {
288		setContainerHeight(container, filter, checks, Math.min(VISIBLE_COUNT, checks.length), animate);
289	};
290
291	const applyExpandedHeight = (animate = false) => {
292		setContainerHeight(container, filter, checks, "all", animate);
293	};
294
295	if (checks.length > VISIBLE_COUNT) {
296		filter.classList.add("with-more");
297
298		if (moreBtn) {
299			moreBtn.hidden = false;
300			moreBtn.style.display = "";
301			moreBtn.dataset.default = moreLabelDefault;
302		}
303
304		const keepExpanded = expandedFilterGroups.has(getFilterGroupKey(filter));
305
306		if (keepExpanded) {
307			filter.classList.add("open");
308			applyExpandedHeight(false);
309
310			if (moreBtn) {
311				moreBtn.textContent = moreLabelLess;
312			}
313		} else {
314			filter.classList.remove("open");
315			applyCollapsedHeight(false);
316
317			if (moreBtn) {
318				moreBtn.textContent = moreLabelDefault;
319			}
320		}
321	} else {
322		filter.classList.remove("with-more", "open");
323		rememberExpandedFilter(filter, false);
324
325		if (moreBtn) {
326			moreBtn.hidden = true;
327			moreBtn.style.display = "none";
328		}
329
330		setContainerHeight(container, filter, checks, checks.length, false);
331	}
332
333	moreBtn?.addEventListener("click", () => {
334		if (filter.classList.contains("open")) {
335			filter.classList.remove("open");
336			rememberExpandedFilter(filter, false);
337			applyCollapsedHeight(true);
338			moreBtn.textContent = moreLabelDefault;
339			return;
340		}
341
342		filter.classList.add("open");
343		rememberExpandedFilter(filter, true);
344		applyExpandedHeight(true);
345		moreBtn.textContent = moreLabelLess;
346	});
347
348	resetBtn?.addEventListener("click", () => {
349		if (isCatalogFilterLoading()) {
350			return;
351		}
352
353		container.querySelectorAll(".check__input").forEach((input) => {
354			input.checked = false;
355		});
356		updateTitle();
357		document.dispatchEvent(new CustomEvent("photohuman:catalog-filter-apply"));
358	});
359
360	container.addEventListener("change", (event) => {
361		if (event.target instanceof HTMLInputElement && event.target.classList.contains("check__input")) {
362			updateTitle();
363		}
364	});
365
366	window.addEventListener("resize", () => {
367		if (filter.classList.contains("open")) {
368			applyExpandedHeight(false);
369			return;
370		}
371
372		if (checks.length > VISIBLE_COUNT) {
373			applyCollapsedHeight(false);
374			return;
375		}
376
377		setContainerHeight(container, filter, checks, checks.length, false);
378	});
379
380	updateTitle();
381};
382
383const resetAllCatalogFilters = (catalog) => {
384	if (isCatalogFilterLoading()) {
385		return;
386	}
387
388	const sidebar = catalog?.querySelector(".catalog-sidebar");
389
390	if (!sidebar) {
391		return;
392	}
393
394	sidebar.querySelectorAll(".filter .check__input").forEach((input) => {
395		input.checked = false;
396	});
397
398	sidebar.querySelectorAll(".filter.open").forEach((filter) => {
399		filter.classList.remove("open");
400		rememberExpandedFilter(filter, false);
401	});
402	expandedFilterGroups.clear();
403
404	sidebar.querySelectorAll(".filter-more").forEach((moreBtn) => {
405		if (moreBtn.dataset.default) {
406			moreBtn.textContent = moreBtn.dataset.default;
407		}
408	});
409
410	sidebar.querySelectorAll(".filter-title").forEach((titleEl) => {
411		if (titleEl.dataset.defaultTitle) {
412			titleEl.textContent = titleEl.dataset.defaultTitle;
413		}
414	});
415
416	document.dispatchEvent(new CustomEvent("photohuman:catalog-filter-appl
416y"));
417};
418
419const initCatalogSidebar = (catalog) => {
420	if (!catalog || catalog.dataset.catalogSidebarToggleBound === "1") {
421		return;
422	}
423
424	// Live query: AJAX filter replace removes the sidebar node from DOM.
425	catalog.dataset.catalogSidebarToggleBound = "1";
426
427	document.body.addEventListener("click", (event) => {
428		const target = event.target;
429
430		if (!(target instanceof Element) || !catalog.contains(target)) {
431			return;
432		}
433
434		const sidebar = catalog.querySelector(".catalog-sidebar");
435
436		if (!sidebar) {
437			return;
438		}
439
440		if (target.closest(".filters-more-btn")) {
441			event.preventDefault();
442			extraFiltersOpen = !extraFiltersOpen;
443			applyExtraFiltersVisibility(sidebar, extraFiltersOpen);
444			return;
445		}
446
447		if (target.closest(".catalog-head__filter")) {
448			event.preventDefault();
449			sidebar.classList.add("_show");
450			lockBodyScroll();
451			return;
452		}
453
454		if (target.closest(".sidebar-close")) {
455			event.preventDefault();
456			sidebar.classList.remove("_show");
457			syncCatalogSidebarScrollLock(catalog);
458
459			window.setTimeout(() => {
460				const sidebarBody = sidebar.querySelector(".sidebar-body");
461
462				if (sidebarBody) {
463					sidebarBody.scrollTop = 0;
464				}
465			}, 400);
466		}
467	});
468};
469
470const initFilterGroups = (catalog) => {
471	const sidebarBody = catalog.querySelector(".sidebar-body");
472
473	if (!sidebarBody) {
474		return;
475	}
476
477	// Init primary filters first (visible). Extra block may be display:none —
478	// its heights are refreshed in syncExtraFiltersSection when opened.
479	sidebarBody.querySelectorAll(".filter").forEach((filter) => {
480		if (filter.classList.contains("_price")) {
481			initPriceFilter(filter);
482			return;
483		}
484
485		initStandardFilter(filter);
486	});
487
488	syncExtraFiltersSection(catalog);
489};
490
491export const initCatalogFilter = () => {
492	const catalog = document.querySelector(".catalog");
493
494	if (!catalog) {
495		return;
496	}
497
498	if (!catalog.dataset.catalogSidebarBound) {
499		catalog.dataset.catalogSidebarBound = "1";
500
501		catalog.addEventListener("click", (event) => {
502			const target = event.target;
503
504			if (!(target instanceof Element)) {
505				return;
506			}
507
508			if (target.closest(".sidebar-button .button")) {
509				event.preventDefault();
510				resetAllCatalogFilters(catalog);
511			}
512		});
513	}
514
515	initCatalogSidebar(catalog);
516	initFilterGroups(catalog);
517};
518
519export const reinitCatalogFilter = () => {
520	const catalog = document.querySelector(".catalog");
521
522	if (!catalog) {
523		return;
524	}
525
526	initFilterGroups(catalog);
527};

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.