Фильтр по тегам и поиск на странице списка новостей Битрикс. Множественное свойство у тегов


Уже есть статья с фильтрацией записей по тегам ССЫЛКА. В ней фильтрация работает только если свойство тегов НЕ множественное. Здесь решим задачу более универсально. Теги будут работать для НЕ множественного и множественного свойства тип список. Также прикрутим полноценный поиск к списку новостей без перехода на другую страницу. Итак, погнали!

Комплексный компонент 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;
        }
    }
}

Я упростил логику поиска для кратности, его можно настраивать очень гибко.

Что мы сделали:

  1. Забрали из запроса строку с поисковой строкой

  2. Скормили ее модулю поиска

  3. Собрали ID элементов

  4. И добавили их в фильтр, который дальше принимает 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;
}