mirror of
https://github.com/webmin/webmin.git
synced 2026-09-16 18:40:39 +01:00
315 lines
12 KiB
JavaScript
315 lines
12 KiB
JavaScript
/*
|
|
* ui-lib.js
|
|
* Behavior for the widgets added to ui-lib.pl.
|
|
*
|
|
* Everything is wired through delegated event listeners keyed off
|
|
* data-ui-* attributes, so no inline handlers are generated and pages
|
|
* remain compatible with a strict Content-Security-Policy. Tabs, sorting
|
|
* and select-all links keep using the scripts the existing ui-lib
|
|
* functions and themes already provide.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
// Replace old listeners when Ajax navigation reloads this script.
|
|
if (window.webminUiWidgets) window.webminUiWidgets.remove();
|
|
var listeners = [];
|
|
function on(type, fn, capture, target) {
|
|
target = target || document;
|
|
target.addEventListener(type, fn, capture);
|
|
listeners.push([ type, fn, capture, target ]);
|
|
}
|
|
window.webminUiWidgets = {
|
|
remove: function () {
|
|
listeners.forEach(function (l) {
|
|
l[3].removeEventListener(l[0], l[1], l[2]);
|
|
});
|
|
}
|
|
};
|
|
|
|
// Filter table rows or list entries in the target container by the
|
|
// text typed into a ui_search box, leaving header rows in place
|
|
function applyFilter(input) {
|
|
var sel = input.getAttribute('data-ui-filter');
|
|
var target = sel && document.querySelector(sel);
|
|
if (!target) return;
|
|
var query = input.value.trim().toLowerCase();
|
|
var items = target.querySelectorAll(
|
|
'tbody tr, .ui_list_item, [data-ui-filter-item]');
|
|
items.forEach(function (item) {
|
|
if (item.classList.contains('ui_columns_heads') ||
|
|
item.classList.contains('ui_columns_heading')) return;
|
|
item.hidden = query !== '' &&
|
|
item.textContent.toLowerCase().indexOf(query) < 0;
|
|
});
|
|
}
|
|
|
|
// Confirmation prompts on buttons and links carrying data-ui-confirm,
|
|
// including existing ui_submit buttons given the attribute in tags.
|
|
// Capture the click before a theme or inline handler performs the action.
|
|
on('click', function (e) {
|
|
var confirmer = e.target.closest && e.target.closest('[data-ui-confirm]');
|
|
if (confirmer &&
|
|
!window.confirm(confirmer.getAttribute('data-ui-confirm'))) {
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
}
|
|
}, true);
|
|
|
|
on('input', function (e) {
|
|
var input = e.target.closest && e.target.closest('[data-ui-filter]');
|
|
if (input) applyFilter(input);
|
|
});
|
|
// Choice lists : focusing an input of an option selects that option,
|
|
// as the existing ui_opt_textbox does
|
|
on('focusin', function (e) {
|
|
// Links, help buttons and other focusable content do not change the
|
|
// selected option; only focusing one of its editable controls does.
|
|
if (!e.target.matches ||
|
|
!e.target.matches('input:not([type="button"]):not([type="submit"]):not([type="reset"]), select, textarea')) return;
|
|
var item = e.target.closest && e.target.closest('.ui_choice_item');
|
|
if (!item) return;
|
|
var input = item.querySelector('input[type="radio"]');
|
|
if (input && e.target !== input && !input.checked && !input.disabled) {
|
|
input.checked = true;
|
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
});
|
|
|
|
// Select switches : show the block of the chosen option, hide the rest
|
|
function applySwitch(select) {
|
|
var box = select.closest('.ui_select_switch');
|
|
if (!box) return;
|
|
box.querySelectorAll('.ui_select_switch_panel').forEach(function (panel) {
|
|
// Nested switches manage their own panels independently.
|
|
if (panel.closest('.ui_select_switch') !== box) return;
|
|
panel.hidden =
|
|
panel.getAttribute('data-ui-switch-value') !== select.value;
|
|
});
|
|
}
|
|
on('change', function (e) {
|
|
var select = e.target;
|
|
if (select.matches && select.matches('select[data-ui-switch]')) {
|
|
applySwitch(select);
|
|
}
|
|
});
|
|
|
|
// Native reset restores select values after the reset event, without
|
|
// firing change. Update the panels once those values have been restored.
|
|
on('reset', function (e) {
|
|
window.setTimeout(function () {
|
|
if (e.defaultPrevented) return;
|
|
document.querySelectorAll('select[data-ui-switch]').forEach(function (select) {
|
|
if (select.form === e.target) applySwitch(select);
|
|
});
|
|
}, 0);
|
|
});
|
|
|
|
// Multiple selection lists submit newline-joined values in a hidden input.
|
|
var multiAnchors = new WeakMap();
|
|
// Find the checkbox inside any theme wrapper.
|
|
function multiInput(item) {
|
|
return item.querySelector('input[type="checkbox"]');
|
|
}
|
|
// Closing clears the query and returns focus to the button.
|
|
function openMultiFilter(box, open) {
|
|
var filter = box.querySelector('.ui_multi_filter');
|
|
var search = box.querySelector('[data-ui-multi-search]');
|
|
if (!filter || !search || search.disabled) return;
|
|
filter.classList.toggle('ui_multi_filter_open', open);
|
|
if (!open) search.value = '';
|
|
applyMulti(box);
|
|
(open ? search : filter.querySelector('.ui_multi_filter_toggle')).focus();
|
|
}
|
|
function applyMulti(box, reset) {
|
|
// Resets and history restores need not fire change events.
|
|
var mode = box.querySelector('.ui_multi_modes select, .ui_multi_modes input[type="radio"]:checked');
|
|
var body = box.querySelector('.ui_multi_body');
|
|
var hide = JSON.parse(box.getAttribute('data-ui-multi-hide') || '[]');
|
|
if (body) body.hidden = !!mode && hide.indexOf(mode.value) >= 0;
|
|
var search = box.querySelector('[data-ui-multi-search]');
|
|
var query = search ? search.value.trim().toLowerCase() : '';
|
|
// Reopen restored searches.
|
|
var filter = box.querySelector('.ui_multi_filter');
|
|
if (filter) {
|
|
if (query) filter.classList.add('ui_multi_filter_open');
|
|
filter.classList.toggle('ui_multi_filter_active', query !== '');
|
|
filter.querySelector('.ui_multi_filter_toggle').setAttribute('aria-expanded',
|
|
String(filter.classList.contains('ui_multi_filter_open')));
|
|
}
|
|
var fold = box.querySelector('[data-ui-multi-action="children"]');
|
|
var folded = !!(fold && fold.checked);
|
|
var matched = 0;
|
|
var chosen = [];
|
|
var selectedCount = 0;
|
|
box.querySelectorAll('.ui_multi_item').forEach(function (item) {
|
|
var input = multiInput(item);
|
|
if (input && input.checked) chosen.push(input.value);
|
|
// Show child counts only while folded.
|
|
var note = item.querySelector('.ui_multi_note');
|
|
if (note) note.hidden = !folded;
|
|
// The parent covers folded children.
|
|
if (folded && item.getAttribute('data-ui-multi-level')) {
|
|
item.hidden = true;
|
|
return;
|
|
}
|
|
// Retain folded selections, but count only unfolded entries.
|
|
if (input && input.checked) selectedCount++;
|
|
var match = query === '' ||
|
|
(item.getAttribute('data-ui-multi-text') || '').toLowerCase().indexOf(query) >= 0;
|
|
if (match) matched++;
|
|
item.hidden = !match;
|
|
});
|
|
var empty = box.querySelector('.ui_multi_empty');
|
|
if (empty) empty.hidden = matched > 0;
|
|
// Hide the count for empty selections or hidden lists.
|
|
var count = box.querySelector('.ui_multi_count');
|
|
if (count) {
|
|
count.textContent = box.getAttribute('data-ui-multi-text-selected')
|
|
.replace('$1', selectedCount);
|
|
count.hidden = selectedCount === 0 || !!(body && body.hidden);
|
|
}
|
|
var name = box.getAttribute('data-ui-multi');
|
|
box.querySelectorAll('input[type="hidden"]').forEach(function (hidden) {
|
|
if (hidden.name !== name) return;
|
|
var order = box.getAttribute('data-ui-multi-order');
|
|
if (order !== null) {
|
|
// Capture browser-decoded values once, surviving script reinjection.
|
|
if (order === '') {
|
|
order = JSON.stringify(hidden.value.split('\n'));
|
|
box.setAttribute('data-ui-multi-order', order);
|
|
}
|
|
// Retain legacy selection order; newly added entries go first.
|
|
var checked = new Set(chosen);
|
|
var previous = (reset ? JSON.parse(order) : hidden.value.split('\n'))
|
|
.filter(function (value) { return checked.has(value); });
|
|
var retained = new Set(previous);
|
|
var added = chosen.filter(function (value) { return !retained.has(value); });
|
|
hidden.value = added.reverse().concat(previous).join('\n');
|
|
} else {
|
|
hidden.value = chosen.join('\n');
|
|
}
|
|
});
|
|
}
|
|
// Bulk actions affect only visible, enabled rows.
|
|
function eachShownRow(box, fn) {
|
|
box.querySelectorAll('.ui_multi_item').forEach(function (item) {
|
|
var input = multiInput(item);
|
|
if (input && !input.disabled && !item.hidden) fn(input);
|
|
});
|
|
}
|
|
// Prevent text selection during Shift-click.
|
|
on('mousedown', function (e) {
|
|
var item = e.target.closest && e.target.closest('.ui_multi_item');
|
|
if (e.shiftKey && e.button === 0 && item &&
|
|
item.closest('[data-ui-multi]') &&
|
|
!e.target.closest('input, a, button')) e.preventDefault();
|
|
});
|
|
on('input', function (e) {
|
|
var search = e.target.closest && e.target.closest('[data-ui-multi-search]');
|
|
var box = search && search.closest('[data-ui-multi]');
|
|
if (box) applyMulti(box);
|
|
});
|
|
// Capture Enter/Escape before theme handlers can submit or leave the page.
|
|
on('keydown', function (e) {
|
|
var search = e.target.closest && e.target.closest('[data-ui-multi-search]');
|
|
var box = search && search.closest('[data-ui-multi]');
|
|
if (!box || e.isComposing) return;
|
|
if (e.key === 'Escape' || e.key === 'Enter') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
if (e.key === 'Escape') openMultiFilter(box, false);
|
|
}, true);
|
|
on('change', function (e) {
|
|
var box = e.target.closest && e.target.closest('[data-ui-multi]');
|
|
if (!box) return;
|
|
var t = e.target;
|
|
if (t.matches('.ui_multi_modes select, .ui_multi_modes input[type="radio"]')) {
|
|
// Start a new range after a mode change.
|
|
multiAnchors.delete(box);
|
|
}
|
|
applyMulti(box);
|
|
});
|
|
on('click', function (e) {
|
|
var t = e.target.closest && e.target.closest(
|
|
'[data-ui-multi-action], .ui_multi_item');
|
|
var box = t && t.closest('[data-ui-multi]');
|
|
if (!box) return;
|
|
var action = t.getAttribute('data-ui-multi-action');
|
|
if (action === 'filter' || action === 'filter-clear') {
|
|
// Cross clears text or closes an empty filter; funnel toggles.
|
|
e.preventDefault();
|
|
if (t.disabled) return;
|
|
var search = box.querySelector('[data-ui-multi-search]');
|
|
var open = action === 'filter-clear' ? search.value !== '' :
|
|
!t.closest('.ui_multi_filter').classList.contains('ui_multi_filter_open');
|
|
if (action === 'filter-clear') search.value = '';
|
|
openMultiFilter(box, open);
|
|
return;
|
|
}
|
|
if (action === 'all' || action === 'invert') {
|
|
// Apply the bulk action without following the link.
|
|
e.preventDefault();
|
|
eachShownRow(box, function (input) {
|
|
input.checked = action === 'all' || !input.checked;
|
|
});
|
|
multiAnchors.delete(box);
|
|
}
|
|
else if (action) {
|
|
// The switch is handled by change.
|
|
return;
|
|
}
|
|
else {
|
|
var input = multiInput(t);
|
|
if (!input || input.disabled || t.hidden) return;
|
|
// Only row backgrounds need toggling; checkboxes and labels do it natively.
|
|
if (e.target !== input) {
|
|
if (e.target.closest('input, label, a, button')) return;
|
|
input.checked = !input.checked;
|
|
}
|
|
// Apply the clicked state across visible, enabled rows in either direction.
|
|
if (e.shiftKey) {
|
|
var shown = [];
|
|
eachShownRow(box, function (row) { shown.push(row); });
|
|
var start = shown.indexOf(multiAnchors.get(box));
|
|
var end = shown.indexOf(input);
|
|
if (start >= 0 && end >= 0) {
|
|
var checked = input.checked;
|
|
shown.slice(Math.min(start, end), Math.max(start, end) + 1)
|
|
.forEach(function (row) { row.checked = checked; });
|
|
}
|
|
}
|
|
multiAnchors.set(box, input);
|
|
}
|
|
applyMulti(box);
|
|
});
|
|
// Wait for native reset before syncing rows and submitted values.
|
|
on('reset', function (e) {
|
|
window.setTimeout(function () {
|
|
if (e.defaultPrevented) return;
|
|
document.querySelectorAll('[data-ui-multi]').forEach(function (box) {
|
|
if (e.target.contains(box)) {
|
|
multiAnchors.delete(box);
|
|
applyMulti(box, true);
|
|
}
|
|
});
|
|
}, 0);
|
|
});
|
|
// Sync new or restored widgets and discard old range anchors.
|
|
function initMulti() {
|
|
document.querySelectorAll('[data-ui-multi]').forEach(function (box) {
|
|
multiAnchors.delete(box);
|
|
applyMulti(box);
|
|
});
|
|
}
|
|
// Back/forward cache restores do not reload scripts.
|
|
on('pageshow', initMulti, false, window);
|
|
if (document.readyState === 'loading') {
|
|
on('DOMContentLoaded', initMulti);
|
|
} else {
|
|
initMulti();
|
|
}
|
|
|
|
})();
|