- add a generic AJAX submitter for forms inside dialogs - keep modal values and show field errors on server validation failure - soft-refresh the page content on success, preserving scroll, search and filters - share toast and re-init hooks for tables, markdown, sections and group page - require passwords and add minlength/match validation on auth forms
207 lines
9.0 KiB
JavaScript
207 lines
9.0 KiB
JavaScript
/* tablekit: сортировка по клику на заголовок, поиск по таблице и фильтры по колонкам.
|
||
Разметка:
|
||
<table class="tablekit" id="t"> — таблица;
|
||
<input class="table-search" data-target="t"> — поиск по строке;
|
||
<th data-filter> — включить фильтр по колонке (выпадающий список значений);
|
||
<th data-filter="text"> — текстовый фильтр по колонке;
|
||
<th data-nosort> — колонка не сортируется;
|
||
data-sort / data-filter-value / data-filter-label на <td> — значения и подписи.
|
||
Начальная сортировка выполняется на сервере; клик по заголовку меняет её. */
|
||
(function () {
|
||
function normalize(value) {
|
||
return (value == null ? '' : String(value)).trim().toLowerCase().replace(/\s+/g, ' ');
|
||
}
|
||
|
||
function source(cell) {
|
||
if (!cell) return '';
|
||
if (cell.dataset.filterValue !== undefined) return normalize(cell.dataset.filterValue);
|
||
if (cell.dataset.sort !== undefined) return normalize(cell.dataset.sort);
|
||
return normalize(cell.textContent);
|
||
}
|
||
|
||
function label(cell) {
|
||
if (!cell) return '';
|
||
if (cell.dataset.filterLabel !== undefined) return cell.dataset.filterLabel.trim();
|
||
var text = (cell.textContent || '').trim().replace(/\s+/g, ' ');
|
||
return text || source(cell);
|
||
}
|
||
|
||
function compared(a, b) {
|
||
var ma = (a.match(/-?\d+([.,]\d+)?/) || [''])[0].replace(',', '.');
|
||
var mb = (b.match(/-?\d+([.,]\d+)?/) || [''])[0].replace(',', '.');
|
||
var fa = parseFloat(ma);
|
||
var fb = parseFloat(mb);
|
||
if (!isNaN(fa) && !isNaN(fb) && fa !== fb) return fa - fb;
|
||
return a.localeCompare(b, 'ru', { numeric: true, sensitivity: 'base' });
|
||
}
|
||
|
||
function headerRow(table) {
|
||
return table.__headerRow || table.tHead.rows[table.tHead.rows.length - 1];
|
||
}
|
||
|
||
function clearAllMarkers(table) {
|
||
Array.prototype.forEach.call(headerRow(table).cells, function (th) {
|
||
th.classList.remove('is-asc', 'is-desc');
|
||
});
|
||
}
|
||
|
||
function sortColumn(table, index, direction) {
|
||
var body = table.tBodies[0];
|
||
if (!body) return;
|
||
var rows = Array.prototype.slice.call(body.rows);
|
||
rows.sort(function (a, b) {
|
||
return compared(source(a.cells[index]), source(b.cells[index]));
|
||
});
|
||
if (direction < 0) rows.reverse();
|
||
rows.forEach(function (row) { body.appendChild(row); });
|
||
}
|
||
|
||
function applyFilters(table) {
|
||
var body = table.tBodies[0];
|
||
if (!body) return;
|
||
var query = table.__query || '';
|
||
var filters = table.__filters || {};
|
||
var columnCount = headerRow(table).cells.length;
|
||
Array.prototype.forEach.call(body.rows, function (row) {
|
||
var placeholder = row.cells.length < columnCount;
|
||
var show = !query || normalize(row.textContent).indexOf(query) >= 0;
|
||
if (show && !placeholder) {
|
||
for (var key in filters) {
|
||
var filter = filters[key];
|
||
if (!filter || !filter.value) continue;
|
||
var value = source(row.cells[key]);
|
||
var match = filter.kind === 'text'
|
||
? value.indexOf(filter.value) >= 0
|
||
: value === filter.value;
|
||
if (!match) { show = false; break; }
|
||
}
|
||
}
|
||
row.classList.toggle('row-hidden', !show);
|
||
});
|
||
}
|
||
|
||
function buildFilters(table) {
|
||
var head = table.tHead;
|
||
var body = table.tBodies[0];
|
||
if (!head || !body) return;
|
||
var row = headerRow(table);
|
||
var columns = [];
|
||
Array.prototype.forEach.call(row.cells, function (th, index) {
|
||
if (th.hasAttribute('data-filter')) {
|
||
th.classList.add('th-filterable');
|
||
columns.push(index);
|
||
}
|
||
});
|
||
if (!columns.length) return;
|
||
|
||
var filterRow = document.createElement('tr');
|
||
filterRow.className = 'filter-row';
|
||
Array.prototype.forEach.call(row.cells, function (th, index) {
|
||
var cell = document.createElement('th');
|
||
if (th.dataset.lab) cell.setAttribute('data-lab', th.dataset.lab);
|
||
if (columns.indexOf(index) >= 0) {
|
||
var kind = th.getAttribute('data-filter') || 'values';
|
||
var control;
|
||
if (kind === 'text') {
|
||
control = document.createElement('input');
|
||
control.type = 'text';
|
||
control.placeholder = 'фильтр…';
|
||
} else {
|
||
control = document.createElement('select');
|
||
var any = document.createElement('option');
|
||
any.value = '';
|
||
any.textContent = 'все';
|
||
control.appendChild(any);
|
||
var seen = {};
|
||
var options = [];
|
||
Array.prototype.forEach.call(body.rows, function (tr) {
|
||
var td = tr.cells[index];
|
||
if (!td) return;
|
||
var value = source(td);
|
||
if (!value || seen[value]) return;
|
||
seen[value] = 1;
|
||
options.push({ value: value, label: label(td) });
|
||
});
|
||
options.sort(function (a, b) { return compared(a.label, b.label); });
|
||
options.forEach(function (option) {
|
||
var item = document.createElement('option');
|
||
item.value = option.value;
|
||
item.textContent = option.label;
|
||
control.appendChild(item);
|
||
});
|
||
}
|
||
control.className = 'filter-control';
|
||
control.setAttribute('aria-label', (th.textContent || '').trim() + ' — фильтр');
|
||
control.addEventListener(kind === 'text' ? 'input' : 'change', function () {
|
||
table.__filters = table.__filters || {};
|
||
table.__filters[index] = {
|
||
kind: kind,
|
||
value: kind === 'text' ? normalize(control.value) : control.value,
|
||
};
|
||
applyFilters(table);
|
||
});
|
||
cell.appendChild(control);
|
||
}
|
||
filterRow.appendChild(cell);
|
||
});
|
||
head.appendChild(filterRow);
|
||
}
|
||
|
||
function bind(table) {
|
||
if (table.dataset.tablekitBound) return;
|
||
table.dataset.tablekitBound = '1';
|
||
var head = table.tHead;
|
||
if (!head) return;
|
||
var row = head.rows[head.rows.length - 1];
|
||
table.__headerRow = row;
|
||
Array.prototype.forEach.call(row.cells, function (th, index) {
|
||
if (th.hasAttribute('data-nosort')) return;
|
||
th.classList.add('th-sortable');
|
||
if (!th.title) th.title = 'Сортировать по этому столбцу';
|
||
// сортировка с клавиатуры: фокус + Enter/пробел, состояние доступно скринридеру
|
||
th.tabIndex = 0;
|
||
if (th.classList.contains('is-asc')) th.setAttribute('aria-sort', 'ascending');
|
||
function doSort() {
|
||
var asc = !th.classList.contains('is-asc');
|
||
clearAllMarkers(table);
|
||
th.classList.add(asc ? 'is-asc' : 'is-desc');
|
||
th.setAttribute('aria-sort', asc ? 'ascending' : 'descending');
|
||
sortColumn(table, index, asc ? 1 : -1);
|
||
th.title = (th.textContent.trim() || 'Столбец') + ' — ' + (asc ? 'по возрастанию' : 'по убыванию');
|
||
}
|
||
th.addEventListener('click', function (event) {
|
||
if (event.target.closest('form, button, a, select, input')) return;
|
||
doSort();
|
||
});
|
||
th.addEventListener('keydown', function (event) {
|
||
if (event.target !== th) return;
|
||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||
event.preventDefault();
|
||
doSort();
|
||
});
|
||
});
|
||
buildFilters(table);
|
||
}
|
||
|
||
function bindSearch(input) {
|
||
var table = document.getElementById(input.dataset.target);
|
||
if (!table || !table.tBodies[0]) return;
|
||
input.addEventListener('input', function () {
|
||
table.__query = normalize(input.value);
|
||
applyFilters(table);
|
||
});
|
||
}
|
||
|
||
function init() {
|
||
document.querySelectorAll('table.tablekit').forEach(bind);
|
||
document.querySelectorAll('input.table-search').forEach(bindSearch);
|
||
}
|
||
window.__lmsInitTablekit = init;
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|