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:
2026-03-08 23:15:39 +03:00
parent d6bda9afed
commit d95eb683e1
7 changed files with 350 additions and 2 deletions

View 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">&#x25BE;</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 = '';
}
}

View File

@@ -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' : '';