Уже есть статья с фильтрацией записей по тегам ССЫЛКА. В ней фильтрация работает только если свойство тегов НЕ множественное. Здесь решим задачу более универсально. Теги будут работать для НЕ множественного и множественного свойства тип список. Также прикрутим полноценный поиск к списку новостей без перехода на другую страницу. Итак, погнали!
Комплексный компонент news
<?$APPLICATION->IncludeComponent(
"bitrix:news",
"news",
Array(
"FILTER_NAME" => "catalogFilter", // Даем имя нашему фильтру =)
"FILTER_FIELD_CODE" => array("NAME",""), // Пару слов об это ниже
"FILTER_PROPERTY_CODE" => array("TAGS",""), // Свойство с тегами
"LIST_PROPERTY_CODE" => array("TAGS",""), // Свойство с тегами
"SEF_FOLDER" => "/blog/",
"SEF_MODE" => "Y",
"SEF_URL_TEMPLATES" => Array("detail"=>"#ELEMENT_CODE#/","news"=>"","search"=>"","section"=>""), // Сотрем дефолтный путь для search
"USE_FILTER" => "Y", // Включаем фильтр
// Все остальные настройки опциональны...
)
);?>
"FILTER_FIELD_CODE" => array("NAME","") - Я по этому параметру проверяю показывать поле поиска или нет. Мне было так удобнее. При необходимости можно изменить эту логику.
news.php
Подключаем наш шаблон catalog.filter с дефолтными параметрами
<?
if($arParams["USE_FILTER"]=="Y"):
$APPLICATION->IncludeComponent(
"bitrix:catalog.filter",
"news",
[
"IBLOCK_TYPE" => $arParams["IBLOCK_TYPE"],
"IBLOCK_ID" => $arParams["IBLOCK_ID"],
"FILTER_NAME" => $arParams["FILTER_NAME"],
"FIELD_CODE" => $arParams["FILTER_FIELD_CODE"],
"PROPERTY_CODE" => $arParams["FILTER_PROPERTY_CODE"],
"CACHE_TYPE" => $arParams["CACHE_TYPE"],
"CACHE_TIME" => $arParams["CACHE_TIME"],
"CACHE_GROUPS" => $arParams["CACHE_GROUPS"],
"PAGER_PARAMS_NAME" => $arParams["PAGER_PARAMS_NAME"],
"PREFILTER_NAME" => $arParams["FILTER_NAME"],
],
$component,
['HIDE_ICONS' => 'Y']
);
endif;
?>
catalog.filter
Вся логика будет в этом компоненте
template.php
<? if (!defined("B_PROLOG_INCLUDED") || B_PROLOG_INCLUDED !== true) {
die();
}
/** @var array $arParams */
/** @var array $arResult */
/** @global CMain $APPLICATION */
/** @global CUser $USER */
/** @global CDatabase $DB */
/** @var CBitrixComponentTemplate $this */
/** @var string $templateName */
/** @var string $templateFile */
/** @var string $templateFolder */
/** @var string $componentPath */
/** @var CBitrixComponent $component */
$this->setFrameMode(true);
use Bitrix\Main\Application;
$request = Application::getInstance()->getContext()->getRequest();
$requestFilterTags = $request->get("catalogFilter_pf")['TAGS'];
$requestName = $request->get("catalogFilter_ff")['NAME'];
// Поиск
$filterName = $arResult['ITEMS']['NAME'];
// Теги
$filterTags = $arResult['ITEMS']['PROPERTY_71'];
$tagsIsMultiple = $filterTags['MULTIPLE'] === 'Y';
// name для input у тегов
$tagAttrName = $tagsIsMultiple ? $filterTags["INPUT_NAME"] . '[]' : $filterTags["INPUT_NAME"];
$tagAttrType = $tagsIsMultiple ? 'checkbox' : 'radio';
// Функция для определения выбранного тега
$isTagSelected = static function($tagKey) use ($tagsIsMultiple, $requestFilterTags) {
if ($tagsIsMultiple && is_array($requestFilterTags)) {
return in_array((string) $tagKey, $requestFilterTags, true);
}
return !$tagsIsMultiple && $tagKey !== '' && $requestFilterTags === (string) $tagKey;
};
$hasFilterName = !empty($filterName);
$filerNameValue = $requestName ?: '';
?>
<div class="s-filter-news">
<form class="filter-news" name="<? echo $arResult["FILTER_NAME"] . "_form" ?>"
action="<? echo $arResult["FORM_ACTION"] ?>" method="get" id="filterNews">
<div class="filter-section">
<div class="filter-title d-none"><?=GetMessage("IBLOCK_FILTER_TITLE")?></div>
<div class="filter-body">
<? foreach ($arResult["ITEMS"] as $arItem) {
if (array_key_exists("HIDDEN", $arItem)) {
echo $arItem["INPUT"];
}
} ?>
<? if ($hasFilterName) { ?>
<div class="filter-row filter-search" id="filterSearch">
<button type="button" class="filter-search-clean" id="filterSearchClean">
<svg class="icon icon-close">
<use xlink:href="#icon-close"></use>
</svg>
</button>
<input class="form-control" id="filterSearchInput" type="text" name="catalogFilter_ff[NAME]"
value="<?=$filerNameValue?>"
placeholder="Поиск..."
>
<button class="filter-search-button">
<svg class="icon icon-search">
<use xlink:href="#icon-search"></use>
</svg>
</button>
</div>
<? } ?>
<div class="filter-row filter-tags" id="filterTags">
<a class="filter-tag-button btn btn-outline" id="filterReset" href="javascript:void(0);">
<span>Сбросить все</span>
</a>
<? foreach ($filterTags['LIST'] as $k => $tag) {
if ($k === '') {
continue;
}
$displayTag = ($k === '') ? 'Сбросить все' : $tag;
$isChecked = $isTagSelected($k);
?>
<label class="filter-tag-button btn btn-outline" for="filterTag<?=$k?>">
<input class="filter-tag-input d-none<?=$isChecked ? ' checked' : ''?>"
id="filterTag<?=$k?>"
type="<?=$tagAttrType?>"
name="<?=$tagAttrName?>"
value="<?=$k?>"
<?=$isChecked ? 'checked' : ''?>
>
<span><?=$displayTag?></span>
<? if($isChecked) {?>
<button type="button" class="tag-close">
<svg class="icon icon-close">
<use xlink:href="#icon-close"></use>
</svg>
</button>
<? } ?>
</label>
<? } ?>
</div>
</div>
<div class="filter-footer d-none">
<div class="filter-actions">
<input type="hidden" name="set_filter" value="Y"/>
<input type="submit" name="set_filter" value="<?=GetMessage("IBLOCK_SET_FILTER")?>"/>
<input type="submit" name="del_filter" value="<?=GetMessage("IBLOCK_DEL_FILTER")?>"/>
</div>
</div>
</div>
</form>
</div>
script.js
/**
* Модуль фильтрации новостей
*/
class NewsFilter {
constructor() {
this.elements = {
filterForm: null,
filterableContent: null,
filterTags: null,
searchInput: null,
searchCleanButton: null,
resetButton: null
};
this.config = {
submitDelay: 300,
tagParamName: 'catalogFilter_pf[TAGS]'
};
this.init();
}
/**
* Инициализация модуля
*/
init() {
document.addEventListener('DOMContentLoaded', () => {
this.cacheElements();
if (!this.validateRequiredElements()) {
console.warn('Не найдены обязательные элементы для инициализации фильтра');
return;
}
this.initializeFilters();
});
}
/**
* Кэширование DOM элементов
*/
cacheElements() {
this.elements = {
filterForm: document.querySelector('#filterNews'),
filterableContent: document.querySelector('#filterableContent'),
filterTags: document.querySelector('#filterTags'),
searchInput: document.querySelector('#filterSearchInput'),
searchCleanButton: document.querySelector('#filterSearchClean'),
resetButton: document.querySelector('#filterReset')
};
}
/**
* Проверка наличия обязательных элементов
*/
validateRequiredElements() {
return this.elements.filterForm && this.elements.filterableContent;
}
/**
* Инициализация всех фильтров
*/
initializeFilters() {
this.initTagsFilter();
this.initSearchFilter();
this.initResetFilter();
}
/**
* Инициализация фильтра по тегам
*/
initTagsFilter() {
if (!this.elements.filterTags) return;
const tagInputs = this.elements.filterTags.querySelectorAll('.filter-tag-input');
if (tagInputs.length === 0) return;
const isRadioType = tagInputs[0].type === 'radio';
tagInputs.forEach(tagInput => {
const eventType = isRadioType ? 'click' : 'change';
tagInput.addEventListener(eventType, (event) => {
this.handleTagInput(tagInput, isRadioType);
});
});
}
/**
* Обработка клика/изменения тега
*/
handleTagInput(tagInput, isRadioType) {
if (isRadioType && tagInput.checked && tagInput.classList.contains('checked')) {
this.removeFilterFromUrl(tagInput.value);
} else {
this.submitForm();
}
}
/**
* Инициализация поискового фильтра
*/
initSearchFilter() {
if (!this.elements.searchInput || !this.elements.searchCleanButton) return;
// Инициализация состояния кнопки очистки
this.updateSearchCleanButton();
// Обработка ввода в поле поиска
this.elements.searchInput.addEventListener('input', () => {
this.updateSearchCleanButton();
});
// Обработка клика по кнопке очистки
this.elements.searchCleanButton.addEventListener('click', () => {
this.clearSearch();
});
}
/**
* Обновление состояния кнопки очистки поиска
*/
updateSearchCleanButton() {
const hasValue = this.elements.searchInput.value.trim().length > 0;
this.elements.searchCleanButton.classList.toggle('active', hasValue);
}
/**
* Очистка поискового поля
*/
clearSearch() {
this.elements.searchInput.value = '';
this.updateSearchCleanButton();
this.submitForm();
}
/**
* Инициализация кнопки сброса
*/
initResetFilter() {
if (!this.elements.resetButton) return;
this.elements.resetButton.addEventListener('click', (event) => {
event.preventDefault();
this.resetAllFilters();
});
}
/**
* Сброс всех фильтров
*/
resetAllFilters() {
this.toggleLoader(true);
// Переход на чистый URL без параметров
const cleanUrl = `${window.location.origin}${window.location.pathname}`;
window.location.href = cleanUrl;
}
/**
* Отправка формы с задержкой
*/
submitForm() {
this.toggleLoader(true);
setTimeout(() => {
this.elements.filterForm.submit();
}, this.config.submitDelay);
}
/**
* Переключение состояния загрузчика
*/
toggleLoader(show) {
if (typeof toggleLoader === 'function') {
toggleLoader(this.elements.filterableContent);
}
}
/**
* Удаление фильтра из URL
*/
removeFilterFromUrl(filterId) {
try {
const url = new URL(window.location);
const params = url.searchParams;
const currentTags = params.get(this.config.tagParamName);
if (!currentTags) return;
const tagsArray = currentTags.split(',').map(tag => tag.trim());
const filteredTags = tagsArray.filter(tag => tag !== filterId.toString());
if (filteredTags.length > 0) {
params.set(this.config.tagParamName, filteredTags.join(','));
} else {
params.delete(this.config.tagParamName);
}
window.location.href = url.toString();
} catch (error) {
console.error('Ошибка при удалении фильтра из URL:', error);
}
}
}
// Инициализация модуля
new NewsFilter();
Теперь нам нужно заставить наш фильтр по названию "FILTER_FIELD_CODE" => array("NAME","") искать как полноценный поиск, т.к. фильтр ищет по строгому вхождению, а мы хотим плюшки морфологии, подтягивать из соседних инфоблоков элементы со своей логикой и т.д. Поэтому создаем файл result_modifier.php и пользуемся API поиска
result_modifier.php
<? if (!defined("B_PROLOG_INCLUDED") || B_PROLOG_INCLUDED !== true) {
die();
}
/**
* @global object $APPLICATION
* @var array $arResult
* @var array $arParams
*/
use Bitrix\Main\Application;
$request = Application::getInstance()->getContext()->getRequest();
$requestName = $request->get("catalogFilter_ff")['NAME'];
// Поиск
if (!empty($requestName)) {
if (!CModule::IncludeModule('search')) {
die('Модуль search не установлен!');
}
$searchResult = [];
$obSearch = new CSearch;
$obSearch->SetOptions([ // Чтобы избежать ошибки форматирования запроса
'ERROR_ON_EMPTY_STEM' => false,
]);
$obSearch->Search(
[ // Параметры
'QUERY' => $requestName,
'SITE_ID' => SITE_ID,
'MODULE_ID' => 'iblock',
'PARAM2' => $arParams['IBLOCK_ID'],
],
[ // Сортировка
'RANK' => 'DESC', // по Релевантности, по убыванию
'DATE_FROM' => 'ASC', // По дате начала активности, по убыванию
],
['STEMMING' => true]
);
// Если не найдено с морфологией
if (!$obSearch->selectedRowsCount()) {
$obSearch->Search(
[
'QUERY' => $requestName,
'SITE_ID' => SITE_ID,
'MODULE_ID' => 'iblock',
'PARAM2' => $arParams['IBLOCK_ID'],
],
[ // Сортировка
'RANK' => 'DESC', // по Релевантности, по убыванию
'DATE_FROM' => 'DESC', // По дате начала активности, по убыванию
],
['STEMMING' => false]
); // Морфология отключена
}
// Чтобы при обращении к модулю поиска поисковая фраза проиндексировалась в статистике Поиска,
// нужно обязательно вызвать NavStart()
// ПРИМЕЧАНИЕ:
// такой вызов NavStart() породит дополнительный PAGEN_.
// Если он мешает, то можно заменить NavStart на такую конструкцию: см. Оф. Документацию.
$obSearch->NavStart();
while ($row = $obSearch->fetch()) {
$searchResult[] = $row['ITEM_ID']; // ID нужных элементов
}
if (!empty($searchResult)) {
if (empty($GLOBALS[$arParams["FILTER_NAME"]])) {
$GLOBALS[$arParams["FILTER_NAME"]] = [
'ACTIVE' => 'Y',
'ID' => $searchResult,
];
} else {
unset($GLOBALS[$arParams["FILTER_NAME"]]['?NAME']);
$GLOBALS[$arParams["FILTER_NAME"]]['ID'] = $searchResult;
}
}
}
Я упростил логику поиска для кратности, его можно настраивать очень гибко.
Что мы сделали:
-
Забрали из запроса строку с поисковой строкой
-
Скормили ее модулю поиска
-
Собрали ID элементов
-
И добавили их в фильтр, который дальше принимает news.list
Таким образом все фильтры (в нашем случае только Теги) и поиск комбинируются между собой, а не работают отдельно.
CSS
Ну и стили до кучи
.s-filter-news {
margin-bottom: 20px;
}
.filter-tags {
display: flex;
gap: 15px;
}
.filter-section,
.filter-body {
display: flex;
flex-direction: column;
gap: 20px;
}
.filter-body {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
}
.filter-tag-button:has(input:checked) {
background-color: var(--green);
color: var(--white);
}
.filter-search {
display: flex;
justify-content: flex-end;
width: 100%;
max-width: 440px;
position: relative;
}
.filter-search input {
width: 100%;
max-width: 400px;
padding-right: 50px;
}
.filter-search input::placeholder {
color: var(--gray-60);
}
.filter-search-button {
display: flex;
align-items: center;
justify-content: center;
background-color: transparent;
padding: 2px;
outline: none;
position: absolute;
top: 1px;
right: 1px;
height: calc(100% - 2px);
width: 40px;
border: none;
opacity: .4;
transition: var(--tr25);
}
.filter-search-button:hover {
opacity: 1;
}
.filter-search .icon {
width: 24px;
height: 24px;
}
.filter-search-clean,
.tag-close {
display: none;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: none;
outline: none;
cursor: pointer;
padding: 0;
margin: 0;
transition: var(--tr25);
background-color: transparent;
pointer-events: none;
}
.tag-close .icon {
width: 18px;
min-width: 18px;
height: 18px;
min-height: 18px;
fill: var(--black);
stroke: var(--black);
stroke-width: 1px;
margin-left: 10px;
}
.filter-tag-button:has(input:checked) .tag-close {
display: flex;
}
.filter-tag-button:has(input:checked) .tag-close .icon {
fill: var(--white);
stroke: var(--white);
}
.filter-search-clean.active {
display: flex;
width: 40px;
height: 100%;
cursor: pointer;
pointer-events: auto;
opacity: .4;
transition: var(--tr25);
}
.filter-search-clean.active:hover {
opacity: 1;
}