diff --git a/server/src/wled_controller/static/css/components.css b/server/src/wled_controller/static/css/components.css index 6c10ef0..f3f0e60 100644 --- a/server/src/wled_controller/static/css/components.css +++ b/server/src/wled_controller/static/css/components.css @@ -462,6 +462,124 @@ textarea:focus-visible { padding: 6px 10px; border: 1px solid var(--border-color); border-radius: var(--radius); - background: var(--bg-primary); + background: var(--bg-secondary); font-size: 0.9rem; } + +/* ── Icon Select (reusable type picker) ──────────────────────── */ + +.icon-select-trigger { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-color); + color: var(--text-color); + font-size: 1rem; + cursor: pointer; + transition: border-color 0.15s; + text-align: left; +} +.icon-select-trigger:hover { + border-color: var(--primary-color); +} +.icon-select-trigger-icon { + display: flex; + align-items: center; + flex-shrink: 0; +} +.icon-select-trigger-icon .icon { + width: 18px; + height: 18px; +} +.icon-select-trigger-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.icon-select-trigger-arrow { + flex-shrink: 0; + font-size: 0.8rem; + opacity: 0.5; + margin-left: auto; +} + +.icon-select-popup { + position: relative; + max-height: 0; + overflow: hidden; + opacity: 0; + transition: max-height 0.2s ease, opacity 0.15s ease, margin 0.2s ease; + margin-top: 0; +} +.icon-select-popup.open { + max-height: 600px; + opacity: 1; + margin-top: 6px; +} + +.icon-select-grid { + display: grid; + gap: 6px; + padding: 6px; + border: 1px solid var(--border-color); + border-radius: var(--radius); + background: var(--bg-color); +} + +.icon-select-cell { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 10px 6px; + border: 2px solid transparent; + border-radius: var(--radius); + background: var(--bg-secondary); + cursor: pointer; + transition: border-color 0.15s, background 0.15s, transform 0.1s; + text-align: center; +} +.icon-select-cell:hover { + border-color: var(--primary-color); + transform: scale(1.03); +} +.icon-select-cell.active { + border-color: var(--primary-color); + background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-secondary)); +} + +.icon-select-cell-icon { + display: flex; + align-items: center; + justify-content: center; +} +.icon-select-cell-icon .icon { + width: 24px; + height: 24px; +} +.icon-select-cell.active .icon-select-cell-icon .icon { + stroke: var(--primary-color); +} +.icon-select-cell-label { + font-size: 0.85rem; + font-weight: 600; + line-height: 1.2; +} +.icon-select-cell-desc { + font-size: 0.72rem; + opacity: 0.6; + line-height: 1.3; +} + +/* Hide descriptions on narrow screens */ +@media (max-width: 480px) { + .icon-select-cell-desc { display: none; } + .icon-select-cell { padding: 8px 4px; } + .icon-select-grid { gap: 4px; padding: 4px; } +} diff --git a/server/src/wled_controller/static/js/core/icon-select.js b/server/src/wled_controller/static/js/core/icon-select.js new file mode 100644 index 0000000..3f55307 --- /dev/null +++ b/server/src/wled_controller/static/js/core/icon-select.js @@ -0,0 +1,160 @@ +/** + * Reusable icon-grid selector (replaces a plain to enhance + * items: [ + * { value: 'fire', icon: '', label: 'Fire', desc: 'Warm flickering effect' }, + * { value: 'water', icon: '', label: 'Water', desc: 'Cool flowing colors' }, + * ], + * onChange: (value) => { … }, // optional callback after selection + * columns: 2, // grid columns (default: 2) + * }); + * + * The original 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 `
+ ${item.icon} + ${item.label} + ${item.desc ? `${item.desc}` : ''} +
`; + }).join(''); + + return `
${cells}
`; + } + + _syncTrigger() { + const val = this._select.value; + const item = this._items.find(i => i.value === val); + if (item) { + this._trigger.innerHTML = + `${item.icon}` + + `${item.label}` + + ``; + } + // 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