Add reusable icon-grid type selector for CSS source editor
Replaces the plain <select> dropdown with a visual grid popup showing icon, label, and description for each source type. The IconSelect component is generic and reusable for other type selectors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
160
server/src/wled_controller/static/js/core/icon-select.js
Normal file
160
server/src/wled_controller/static/js/core/icon-select.js
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Reusable icon-grid selector (replaces a plain <select>).
|
||||
*
|
||||
* Usage:
|
||||
* import { IconSelect } from '../core/icon-select.js';
|
||||
*
|
||||
* const sel = new IconSelect({
|
||||
* target: document.getElementById('my-select'), // the <select> to enhance
|
||||
* items: [
|
||||
* { value: 'fire', icon: '<svg>…</svg>', label: 'Fire', desc: 'Warm flickering effect' },
|
||||
* { value: 'water', icon: '<svg>…</svg>', label: 'Water', desc: 'Cool flowing colors' },
|
||||
* ],
|
||||
* onChange: (value) => { … }, // optional callback after selection
|
||||
* columns: 2, // grid columns (default: 2)
|
||||
* });
|
||||
*
|
||||
* The original <select> is hidden but stays in the DOM (value kept in sync).
|
||||
* Call sel.setValue(v) to change programmatically, sel.destroy() to remove.
|
||||
*/
|
||||
|
||||
import { t } from './i18n.js';
|
||||
|
||||
const POPUP_CLASS = 'icon-select-popup';
|
||||
|
||||
/** Close every open icon-select popup. */
|
||||
export function closeAllIconSelects() {
|
||||
document.querySelectorAll(`.${POPUP_CLASS}`).forEach(p => {
|
||||
p.classList.remove('open');
|
||||
});
|
||||
}
|
||||
|
||||
// Global click-away listener (registered once)
|
||||
let _globalListenerAdded = false;
|
||||
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 {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {HTMLSelectElement} opts.target - the <select> element to enhance
|
||||
* @param {Array<{value:string, icon:string, label:string, desc?:string}>} opts.items
|
||||
* @param {Function} [opts.onChange] - called with (value) after user picks
|
||||
* @param {number} [opts.columns=2] - grid column count
|
||||
*/
|
||||
constructor({ target, items, onChange, columns = 2 }) {
|
||||
_ensureGlobalListener();
|
||||
|
||||
this._select = target;
|
||||
this._items = items;
|
||||
this._onChange = onChange;
|
||||
this._columns = columns;
|
||||
|
||||
// Hide the native select
|
||||
this._select.style.display = 'none';
|
||||
|
||||
// Build trigger button
|
||||
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);
|
||||
|
||||
// Build popup
|
||||
this._popup = document.createElement('div');
|
||||
this._popup.className = POPUP_CLASS;
|
||||
this._popup.addEventListener('click', (e) => e.stopPropagation());
|
||||
this._popup.innerHTML = this._buildGrid();
|
||||
this._select.parentNode.insertBefore(this._popup, this._trigger.nextSibling);
|
||||
|
||||
// Bind item clicks
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
cell.addEventListener('click', () => {
|
||||
this.setValue(cell.dataset.value, true);
|
||||
this._popup.classList.remove('open');
|
||||
});
|
||||
});
|
||||
|
||||
// Sync to current select value
|
||||
this._syncTrigger();
|
||||
}
|
||||
|
||||
_buildGrid() {
|
||||
const cells = this._items.map(item => {
|
||||
return `<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('');
|
||||
|
||||
return `<div class="icon-select-grid" style="grid-template-columns:repeat(${this._columns},1fr)">${cells}</div>`;
|
||||
}
|
||||
|
||||
_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>`;
|
||||
}
|
||||
// Update active state in grid
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
cell.classList.toggle('active', cell.dataset.value === val);
|
||||
});
|
||||
}
|
||||
|
||||
_toggle() {
|
||||
const wasOpen = this._popup.classList.contains('open');
|
||||
closeAllIconSelects();
|
||||
if (!wasOpen) {
|
||||
this._popup.classList.add('open');
|
||||
}
|
||||
}
|
||||
|
||||
/** Change the value programmatically. */
|
||||
setValue(value, fireChange = false) {
|
||||
this._select.value = value;
|
||||
this._syncTrigger();
|
||||
if (fireChange) {
|
||||
// Fire native change event so existing onchange handlers work
|
||||
this._select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
if (this._onChange) this._onChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh labels (e.g. after language change). Call with new items array. */
|
||||
updateItems(items) {
|
||||
this._items = items;
|
||||
this._popup.innerHTML = this._buildGrid();
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
cell.addEventListener('click', () => {
|
||||
this.setValue(cell.dataset.value, true);
|
||||
this._popup.classList.remove('open');
|
||||
});
|
||||
});
|
||||
this._syncTrigger();
|
||||
}
|
||||
|
||||
/** Remove the enhancement, restore native <select>. */
|
||||
destroy() {
|
||||
this._trigger.remove();
|
||||
this._popup.remove();
|
||||
this._select.style.display = '';
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { attachProcessPicker } from '../core/process-picker.js';
|
||||
import { IconSelect } from '../core/icon-select.js';
|
||||
|
||||
class CSSEditorModal extends Modal {
|
||||
constructor() {
|
||||
@@ -70,10 +71,46 @@ class CSSEditorModal extends Modal {
|
||||
|
||||
const cssEditorModal = new CSSEditorModal();
|
||||
|
||||
/* ── Icon-grid type selector ──────────────────────────────────── */
|
||||
|
||||
const CSS_TYPE_KEYS = [
|
||||
'picture', 'static', 'gradient', 'color_cycle',
|
||||
'effect', 'composite', 'mapped', 'audio',
|
||||
'api_input', 'notification',
|
||||
];
|
||||
|
||||
function _buildCSSTypeItems() {
|
||||
return CSS_TYPE_KEYS.map(key => ({
|
||||
value: key,
|
||||
icon: getColorStripIcon(key),
|
||||
label: t(`color_strip.type.${key}`),
|
||||
desc: t(`color_strip.type.${key}.desc`),
|
||||
}));
|
||||
}
|
||||
|
||||
let _cssTypeIconSelect = null;
|
||||
|
||||
function _ensureCSSTypeIconSelect() {
|
||||
const sel = document.getElementById('css-editor-type');
|
||||
if (!sel) return;
|
||||
if (_cssTypeIconSelect) {
|
||||
// Refresh labels (language may have changed)
|
||||
_cssTypeIconSelect.updateItems(_buildCSSTypeItems());
|
||||
return;
|
||||
}
|
||||
_cssTypeIconSelect = new IconSelect({
|
||||
target: sel,
|
||||
items: _buildCSSTypeItems(),
|
||||
columns: 2,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Type-switch helper ───────────────────────────────────────── */
|
||||
|
||||
export function onCSSTypeChange() {
|
||||
const type = document.getElementById('css-editor-type').value;
|
||||
// Sync icon-select trigger display
|
||||
if (_cssTypeIconSelect) _cssTypeIconSelect.setValue(type);
|
||||
document.getElementById('css-editor-picture-section').style.display = type === 'picture' ? '' : 'none';
|
||||
document.getElementById('css-editor-static-section').style.display = type === 'static' ? '' : 'none';
|
||||
document.getElementById('css-editor-color-cycle-section').style.display = type === 'color_cycle' ? '' : 'none';
|
||||
@@ -967,6 +1004,9 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
document.getElementById('css-editor-led-count').value = css.led_count ?? 0;
|
||||
};
|
||||
|
||||
// Initialize icon-grid type selector (idempotent)
|
||||
_ensureCSSTypeIconSelect();
|
||||
|
||||
// Hide type selector in edit mode (type is immutable)
|
||||
document.getElementById('css-editor-type-group').style.display = cssId ? 'none' : '';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user