1410a8d2cb
- Add ScriptParameterConfig model (string, integer, float, boolean, select types) - Server-side validation at both define-time and execute-time - Parameters passed as SCRIPT_PARAM_* environment variables - Web UI parameter editor in script create/edit dialog (add/remove/reorder) - Icon-grid selector component (ported from wled-screen-controller) - Replace audio device dropdown with icon-grid selector - Replace callback event dropdown with icon-grid selector - Localization for parameter UI (en, ru)
161 lines
5.7 KiB
JavaScript
161 lines
5.7 KiB
JavaScript
// ============================================================
|
|
// IconSelect: visual icon-grid selector (replaces <select>)
|
|
// Ported from wled-screen-controller (TypeScript → vanilla JS)
|
|
//
|
|
// Trigger replaces the <select> inline. Popup is absolutely
|
|
// positioned inside a wrapper that sits next to the trigger.
|
|
// Works inside <dialog showModal()> — dialog must have
|
|
// overflow: visible.
|
|
// ============================================================
|
|
|
|
const POPUP_CLASS = 'icon-select-popup';
|
|
|
|
let _globalListenerAdded = false;
|
|
|
|
export function closeAllIconSelects() {
|
|
document.querySelectorAll(`.${POPUP_CLASS}.open`).forEach(p => {
|
|
p.classList.remove('open');
|
|
});
|
|
}
|
|
|
|
function _ensureGlobalListener() {
|
|
if (_globalListenerAdded) return;
|
|
_globalListenerAdded = true;
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest(`.${POPUP_CLASS}`) && !e.target.closest('.icon-select-trigger')) {
|
|
closeAllIconSelects();
|
|
}
|
|
});
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') closeAllIconSelects();
|
|
});
|
|
}
|
|
|
|
export class IconSelect {
|
|
constructor({ target, items, onChange, columns = 2, placeholder = '', horizontal = false }) {
|
|
_ensureGlobalListener();
|
|
|
|
this._select = target;
|
|
this._items = items;
|
|
this._onChange = onChange;
|
|
this._columns = columns;
|
|
this._placeholder = placeholder;
|
|
this._horizontal = horizontal;
|
|
|
|
// Hide native select
|
|
this._select.style.display = 'none';
|
|
|
|
// Trigger button (replaces select visually)
|
|
this._trigger = document.createElement('button');
|
|
this._trigger.type = 'button';
|
|
this._trigger.className = 'icon-select-trigger';
|
|
this._trigger.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this._toggle();
|
|
});
|
|
this._select.parentNode.insertBefore(this._trigger, this._select.nextSibling);
|
|
|
|
// Popup — absolutely positioned, appended to dialog (overflow:visible)
|
|
// or body, escaping any scrollable ancestors
|
|
this._popup = document.createElement('div');
|
|
this._popup.className = POPUP_CLASS;
|
|
this._popup.addEventListener('click', (e) => e.stopPropagation());
|
|
this._popup.innerHTML = this._buildGrid();
|
|
|
|
const portal = this._select.closest('dialog') || document.body;
|
|
portal.appendChild(this._popup);
|
|
|
|
this._bindCells();
|
|
this._syncTrigger();
|
|
}
|
|
|
|
_buildGrid() {
|
|
const cells = this._items.map(item =>
|
|
`<div class="icon-select-cell" data-value="${item.value}">
|
|
<span class="icon-select-cell-icon">${item.icon}</span>
|
|
<span class="icon-select-cell-label">${item.label}</span>
|
|
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''}
|
|
</div>`
|
|
).join('');
|
|
|
|
const cls = 'icon-select-grid' + (this._horizontal ? ' icon-select-grid--horizontal' : '');
|
|
return `<div class="${cls}" style="grid-template-columns:repeat(${this._columns},1fr)">${cells}</div>`;
|
|
}
|
|
|
|
_bindCells() {
|
|
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
|
cell.addEventListener('click', () => {
|
|
this.setValue(cell.dataset.value, true);
|
|
this._popup.classList.remove('open');
|
|
});
|
|
});
|
|
}
|
|
|
|
_syncTrigger() {
|
|
const val = this._select.value;
|
|
const item = this._items.find(i => i.value === val);
|
|
if (item) {
|
|
this._trigger.innerHTML =
|
|
`<span class="icon-select-trigger-icon">${item.icon}</span>` +
|
|
`<span class="icon-select-trigger-label">${item.label}</span>` +
|
|
`<span class="icon-select-trigger-arrow">▾</span>`;
|
|
} else if (this._placeholder) {
|
|
this._trigger.innerHTML =
|
|
`<span class="icon-select-trigger-label">${this._placeholder}</span>` +
|
|
`<span class="icon-select-trigger-arrow">▾</span>`;
|
|
}
|
|
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
|
cell.classList.toggle('active', cell.dataset.value === val);
|
|
});
|
|
}
|
|
|
|
_positionPopup() {
|
|
// Get trigger position relative to the popup's offset parent
|
|
// (the dialog or body). Use getBoundingClientRect for both and
|
|
// compute the offset.
|
|
const triggerRect = this._trigger.getBoundingClientRect();
|
|
const parentRect = this._popup.offsetParent
|
|
? this._popup.offsetParent.getBoundingClientRect()
|
|
: { left: 0, top: 0 };
|
|
|
|
const relTop = triggerRect.bottom - parentRect.top;
|
|
const relLeft = triggerRect.left - parentRect.left;
|
|
const popupW = Math.max(triggerRect.width, 200);
|
|
|
|
this._popup.style.left = relLeft + 'px';
|
|
this._popup.style.top = (relTop + 4) + 'px';
|
|
this._popup.style.width = popupW + 'px';
|
|
}
|
|
|
|
_toggle() {
|
|
const wasOpen = this._popup.classList.contains('open');
|
|
closeAllIconSelects();
|
|
if (!wasOpen) {
|
|
this._positionPopup();
|
|
this._popup.classList.add('open');
|
|
}
|
|
}
|
|
|
|
setValue(value, fireChange = false) {
|
|
this._select.value = value;
|
|
this._syncTrigger();
|
|
if (fireChange) {
|
|
this._select.dispatchEvent(new Event('change', { bubbles: true }));
|
|
if (this._onChange) this._onChange(value);
|
|
}
|
|
}
|
|
|
|
updateItems(items) {
|
|
this._items = items;
|
|
this._popup.innerHTML = this._buildGrid();
|
|
this._bindCells();
|
|
this._syncTrigger();
|
|
}
|
|
|
|
destroy() {
|
|
this._trigger.remove();
|
|
this._popup.remove();
|
|
this._select.style.display = '';
|
|
}
|
|
}
|