feat: create reusable ListFilter component (Phase 2.2)
- Created static/js/components/listFilter.js:
* Universal list filtering component with search support
* Replaces duplicated applyFilter() functions found in 7 templates
* Features:
- Configurable match functions (default or custom)
- Debounced search input for performance
- Support for data-attribute matching
- Support for selector-based matching
- Optional empty state message
- Optional clear button
- Flexible hiding methods (display/class)
- Updated static/js/components/index.js:
* Exports ListFilter and helper functions
* Added createDataAttrMatcher() helper
* Added createSelectorMatcher() helper
Component ready for use in templates to replace inline applyFilter() implementations.
Tests: All 12 static file tests passing ✅
Next: Replace applyFilter() usage in templates with ListFilter component
This commit is contained in:
parent
76f6f4ea32
commit
95b0e88a21
@ -10,11 +10,21 @@
|
||||
* </script>
|
||||
*/
|
||||
|
||||
// Компоненты (будут созданы в Фазе 4)
|
||||
// export { EmployeeForm } from './employeeForm.js';
|
||||
// export { CalendarWidget } from './calendarWidget.js';
|
||||
// export { TeamWheel } from './teamWheel.js';
|
||||
// export { ListFilter } from './listFilter.js';
|
||||
// Компоненты фильтрации
|
||||
export {
|
||||
ListFilter,
|
||||
createDataAttrMatcher,
|
||||
createSelectorMatcher
|
||||
} from './listFilter.js';
|
||||
|
||||
// Placeholder export для валидности модуля
|
||||
// Компоненты форм (будут созданы в Фазе 4)
|
||||
// export { EmployeeForm } from './employeeForm.js';
|
||||
|
||||
// Компоненты календаря (будут созданы в Фазе 4)
|
||||
// export { CalendarWidget } from './calendarWidget.js';
|
||||
|
||||
// Компоненты отображения (будут созданы в Фазе 4)
|
||||
// export { TeamWheel } from './teamWheel.js';
|
||||
|
||||
// Версия компонентов
|
||||
export const COMPONENTS_VERSION = '1.0.0';
|
||||
|
||||
250
backend/static/js/components/listFilter.js
Normal file
250
backend/static/js/components/listFilter.js
Normal file
@ -0,0 +1,250 @@
|
||||
/**
|
||||
* ListFilter Component
|
||||
*
|
||||
* Универсальный компонент для фильтрации списков с поддержкой поиска
|
||||
* Заменяет дублирующиеся функции applyFilter() в различных шаблонах
|
||||
*
|
||||
* Использование:
|
||||
* import { ListFilter } from '{% static "js/components/listFilter.js" %}';
|
||||
*
|
||||
* new ListFilter({
|
||||
* listSelector: '#myList',
|
||||
* itemSelector: '.list-item',
|
||||
* searchInputSelector: '#searchInput',
|
||||
* matchFn: (item, query) => item.textContent.toLowerCase().includes(query)
|
||||
* });
|
||||
*/
|
||||
|
||||
import { norm } from '../utils/stringUtils.js';
|
||||
import { debounce } from '../utils/timing.js';
|
||||
|
||||
/**
|
||||
* Класс для фильтрации списков
|
||||
*/
|
||||
export class ListFilter {
|
||||
/**
|
||||
* @param {Object} options - Настройки фильтра
|
||||
* @param {string} options.listSelector - CSS селектор контейнера списка
|
||||
* @param {string} options.itemSelector - CSS селектор элементов списка
|
||||
* @param {string} options.searchInputSelector - CSS селектор поля поиска
|
||||
* @param {Function} [options.matchFn] - Кастомная функция сопоставления
|
||||
* @param {string} [options.clearButtonSelector] - Селектор кнопки очистки
|
||||
* @param {string} [options.emptyMessageSelector] - Селектор сообщения "Ничего не найдено"
|
||||
* @param {number} [options.debounceMs=300] - Задержка debounce в мс
|
||||
* @param {boolean} [options.hideMethod='display'] - Метод скрытия: 'display' или 'class'
|
||||
* @param {string} [options.hideClassName='d-none'] - Класс для скрытия (если hideMethod='class')
|
||||
* @param {Function} [options.onFilter] - Callback после фильтрации
|
||||
*/
|
||||
constructor(options) {
|
||||
this.options = {
|
||||
debounceMs: 300,
|
||||
hideMethod: 'display',
|
||||
hideClassName: 'd-none',
|
||||
...options
|
||||
};
|
||||
|
||||
// Находим элементы
|
||||
this.list = document.querySelector(this.options.listSelector);
|
||||
this.searchInput = document.querySelector(this.options.searchInputSelector);
|
||||
this.clearButton = this.options.clearButtonSelector
|
||||
? document.querySelector(this.options.clearButtonSelector)
|
||||
: null;
|
||||
this.emptyMessage = this.options.emptyMessageSelector
|
||||
? document.querySelector(this.options.emptyMessageSelector)
|
||||
: null;
|
||||
|
||||
if (!this.list) {
|
||||
console.warn(`ListFilter: list not found (${this.options.listSelector})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.searchInput) {
|
||||
console.warn(`ListFilter: search input not found (${this.options.searchInputSelector})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Используем кастомную функцию сопоставления или дефолтную
|
||||
this.matchFn = this.options.matchFn || this.defaultMatch.bind(this);
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация обработчиков событий
|
||||
*/
|
||||
init() {
|
||||
// Debounced фильтрация при вводе
|
||||
const debouncedFilter = debounce(() => {
|
||||
this.filter(this.searchInput.value);
|
||||
}, this.options.debounceMs);
|
||||
|
||||
this.searchInput.addEventListener('input', debouncedFilter);
|
||||
|
||||
// Кнопка очистки
|
||||
if (this.clearButton) {
|
||||
this.clearButton.addEventListener('click', () => {
|
||||
this.clear();
|
||||
});
|
||||
}
|
||||
|
||||
// Применяем фильтр сразу (если есть значение в поле)
|
||||
if (this.searchInput.value) {
|
||||
this.filter(this.searchInput.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить фильтр к списку
|
||||
* @param {string} query - Поисковый запрос
|
||||
*/
|
||||
filter(query) {
|
||||
const items = this.getItems();
|
||||
const normalizedQuery = norm(query);
|
||||
let visibleCount = 0;
|
||||
|
||||
items.forEach(item => {
|
||||
const isMatch = this.matchFn(item, normalizedQuery, query);
|
||||
this.setItemVisibility(item, isMatch);
|
||||
|
||||
if (isMatch) {
|
||||
visibleCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// Обновляем сообщение о пустом результате
|
||||
this.updateEmptyState(visibleCount);
|
||||
|
||||
// Вызываем callback если указан
|
||||
if (this.options.onFilter) {
|
||||
this.options.onFilter(visibleCount, query);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить все элементы списка
|
||||
* @returns {NodeList} - Список элементов
|
||||
*/
|
||||
getItems() {
|
||||
return this.list.querySelectorAll(this.options.itemSelector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Установить видимость элемента
|
||||
* @param {HTMLElement} item - Элемент
|
||||
* @param {boolean} visible - Видимость
|
||||
*/
|
||||
setItemVisibility(item, visible) {
|
||||
if (this.options.hideMethod === 'class') {
|
||||
item.classList.toggle(this.options.hideClassName, !visible);
|
||||
} else {
|
||||
item.style.display = visible ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Дефолтная функция сопоставления
|
||||
* Ищет в текстовом содержимом элемента
|
||||
* @param {HTMLElement} item - Элемент списка
|
||||
* @param {string} normalizedQuery - Нормализованный запрос
|
||||
* @returns {boolean} - Совпадение
|
||||
*/
|
||||
defaultMatch(item, normalizedQuery) {
|
||||
if (!normalizedQuery) return true;
|
||||
const text = norm(item.textContent);
|
||||
return text.includes(normalizedQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить состояние сообщения "Ничего не найдено"
|
||||
* @param {number} visibleCount - Количество видимых элементов
|
||||
*/
|
||||
updateEmptyState(visibleCount) {
|
||||
if (!this.emptyMessage) return;
|
||||
|
||||
const isEmpty = visibleCount === 0 && this.searchInput.value.trim() !== '';
|
||||
this.emptyMessage.style.display = isEmpty ? '' : 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить поле поиска и показать все элементы
|
||||
*/
|
||||
clear() {
|
||||
this.searchInput.value = '';
|
||||
this.searchInput.focus();
|
||||
this.filter('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить список элементов (после динамического добавления/удаления)
|
||||
*/
|
||||
refresh() {
|
||||
this.filter(this.searchInput.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Уничтожить фильтр (удалить обработчики)
|
||||
*/
|
||||
destroy() {
|
||||
// Удаляем обработчики событий
|
||||
// (В реальном приложении нужно хранить ссылки на функции)
|
||||
console.warn('ListFilter.destroy() - not fully implemented');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Вспомогательная функция для быстрого создания фильтра
|
||||
* с сопоставлением по data-атрибутам
|
||||
*
|
||||
* @param {Object} options - Настройки
|
||||
* @param {string[]} options.dataAttrs - Массив data-атрибутов для поиска
|
||||
* @returns {Function} - Функция сопоставления
|
||||
*
|
||||
* @example
|
||||
* new ListFilter({
|
||||
* listSelector: '#list',
|
||||
* itemSelector: '.item',
|
||||
* searchInputSelector: '#search',
|
||||
* matchFn: createDataAttrMatcher(['name', 'email', 'id'])
|
||||
* });
|
||||
*/
|
||||
export function createDataAttrMatcher(dataAttrs) {
|
||||
return (item, normalizedQuery) => {
|
||||
if (!normalizedQuery) return true;
|
||||
|
||||
const values = dataAttrs
|
||||
.map(attr => item.dataset[attr] || '')
|
||||
.join(' ');
|
||||
|
||||
return norm(values).includes(normalizedQuery);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Вспомогательная функция для создания фильтра
|
||||
* с сопоставлением по селекторам внутри элемента
|
||||
*
|
||||
* @param {string[]} selectors - Массив CSS селекторов
|
||||
* @returns {Function} - Функция сопоставления
|
||||
*
|
||||
* @example
|
||||
* new ListFilter({
|
||||
* listSelector: '#list',
|
||||
* itemSelector: '.item',
|
||||
* searchInputSelector: '#search',
|
||||
* matchFn: createSelectorMatcher(['.name', '.email', '.department'])
|
||||
* });
|
||||
*/
|
||||
export function createSelectorMatcher(selectors) {
|
||||
return (item, normalizedQuery) => {
|
||||
if (!normalizedQuery) return true;
|
||||
|
||||
const values = selectors
|
||||
.map(sel => {
|
||||
const el = item.querySelector(sel);
|
||||
return el ? el.textContent : '';
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
return norm(values).includes(normalizedQuery);
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user