Add EntitySelect/IconSelect UI improvements across modals

- Portal IconSelect popups to document.body with position:fixed to prevent
  clipping by modal overflow-y:auto
- Replace custom scene selectors in automation editor with EntitySelect
  command-palette pickers (main scene + fallback scene)
- Add IconSelect grid for automation deactivation mode (none/revert/fallback)
- Add IconSelect grid for automation condition type and match type
- Replace mapped zone source dropdowns with EntitySelect pickers
- Replace scene target selector with EntityPalette.pick() pattern
- Remove effect palette preview bar from CSS editor
- Remove sensitivity badge from audio color strip source cards
- Clean up unused scene-selector CSS and scene-target-add-row CSS
- Add locale keys for all new UI elements across en/ru/zh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 16:00:30 +03:00
parent 186940124c
commit 2712c6682e
32 changed files with 1204 additions and 391 deletions

View File

@@ -13,6 +13,7 @@ import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICO
import * as P from '../core/icon-paths.js';
import { wrapCard } from '../core/card-colors.js';
import { IconSelect } from '../core/icon-select.js';
import { EntitySelect } from '../core/entity-palette.js';
import { attachProcessPicker } from '../core/process-picker.js';
import { csScenes, createSceneCard } from './scene-presets.js';
@@ -227,6 +228,7 @@ export async function openAutomationEditor(automationId, cloneData) {
condList.innerHTML = '';
_ensureConditionLogicIconSelect();
_ensureDeactivationModeIconSelect();
// Fetch scenes for selector
try {
@@ -235,6 +237,7 @@ export async function openAutomationEditor(automationId, cloneData) {
// Reset deactivation mode
document.getElementById('automation-deactivation-mode').value = 'none';
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue('none');
document.getElementById('automation-fallback-scene-group').style.display = 'none';
if (automationId) {
@@ -255,12 +258,14 @@ export async function openAutomationEditor(automationId, cloneData) {
}
// Scene selector
_initSceneSelector('automation-scene', automation.scene_preset_id);
_initSceneSelector('automation-scene-id', automation.scene_preset_id);
// Deactivation mode
document.getElementById('automation-deactivation-mode').value = automation.deactivation_mode || 'none';
const deactMode = automation.deactivation_mode || 'none';
document.getElementById('automation-deactivation-mode').value = deactMode;
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(deactMode);
_onDeactivationModeChange();
_initSceneSelector('automation-fallback-scene', automation.deactivation_scene_preset_id);
_initSceneSelector('automation-fallback-scene-id', automation.deactivation_scene_preset_id);
} catch (e) {
showToast(e.message, 'error');
return;
@@ -281,11 +286,13 @@ export async function openAutomationEditor(automationId, cloneData) {
addAutomationConditionRow(clonedCond);
}
_initSceneSelector('automation-scene', cloneData.scene_preset_id);
_initSceneSelector('automation-scene-id', cloneData.scene_preset_id);
document.getElementById('automation-deactivation-mode').value = cloneData.deactivation_mode || 'none';
const cloneDeactMode = cloneData.deactivation_mode || 'none';
document.getElementById('automation-deactivation-mode').value = cloneDeactMode;
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(cloneDeactMode);
_onDeactivationModeChange();
_initSceneSelector('automation-fallback-scene', cloneData.deactivation_scene_preset_id);
_initSceneSelector('automation-fallback-scene-id', cloneData.deactivation_scene_preset_id);
} else {
titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`;
idInput.value = '';
@@ -293,8 +300,8 @@ export async function openAutomationEditor(automationId, cloneData) {
enabledInput.checked = true;
logicSelect.value = 'or';
if (_conditionLogicIconSelect) _conditionLogicIconSelect.setValue('or');
_initSceneSelector('automation-scene', null);
_initSceneSelector('automation-fallback-scene', null);
_initSceneSelector('automation-scene-id', null);
_initSceneSelector('automation-fallback-scene-id', null);
}
// Wire up deactivation mode change
@@ -319,102 +326,60 @@ export async function closeAutomationEditorModal() {
await automationModal.close();
}
// ===== Scene selector logic =====
// ===== Scene selector (EntitySelect) =====
function _initSceneSelector(prefix, selectedId) {
const hiddenInput = document.getElementById(`${prefix}-id`);
const searchInput = document.getElementById(`${prefix}-search`);
const clearBtn = document.getElementById(`${prefix}-clear`);
const dropdown = document.getElementById(`${prefix}-dropdown`);
let _sceneEntitySelect = null;
let _fallbackSceneEntitySelect = null;
hiddenInput.value = selectedId || '';
function _getSceneItems() {
return (scenePresetsCache.data || []).map(s => ({
value: s.id,
label: s.name,
icon: `<span class="scene-color-dot" style="background:${s.color || '#4fc3f7'}"></span>`,
}));
}
// Set initial display text
if (selectedId) {
const scene = scenePresetsCache.data.find(s => s.id === selectedId);
searchInput.value = scene ? scene.name : '';
clearBtn.classList.toggle('visible', true);
} else {
searchInput.value = '';
clearBtn.classList.toggle('visible', false);
}
function _initSceneSelector(selectId, selectedId) {
const sel = document.getElementById(selectId);
// Populate <select> with scene options
sel.innerHTML = (scenePresetsCache.data || []).map(s =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
).join('');
sel.value = selectedId || '';
// Render dropdown items
function renderDropdown(filter) {
const query = (filter || '').toLowerCase();
const filtered = query ? scenePresetsCache.data.filter(s => s.name.toLowerCase().includes(query)) : scenePresetsCache.data;
// Determine which EntitySelect slot to use
const isMain = selectId === 'automation-scene-id';
const existing = isMain ? _sceneEntitySelect : _fallbackSceneEntitySelect;
if (existing) existing.destroy();
if (filtered.length === 0) {
dropdown.innerHTML = `<div class="scene-selector-empty">${t('automations.scene.none_available')}</div>`;
} else {
dropdown.innerHTML = filtered.map(s => {
const selected = s.id === hiddenInput.value ? ' selected' : '';
return `<div class="scene-selector-item${selected}" data-scene-id="${s.id}"><span class="scene-color-dot" style="background:${escapeHtml(s.color || '#4fc3f7')}"></span>${escapeHtml(s.name)}</div>`;
}).join('');
}
const es = new EntitySelect({
target: sel,
getItems: _getSceneItems,
placeholder: t('automations.scene.search_placeholder'),
allowNone: true,
noneLabel: t('automations.scene.none_selected'),
});
if (isMain) _sceneEntitySelect = es; else _fallbackSceneEntitySelect = es;
}
// Attach click handlers
dropdown.querySelectorAll('.scene-selector-item').forEach(item => {
item.addEventListener('click', () => {
const id = item.dataset.sceneId;
const scene = scenePresetsCache.data.find(s => s.id === id);
hiddenInput.value = id;
searchInput.value = scene ? scene.name : '';
clearBtn.classList.toggle('visible', true);
dropdown.classList.remove('open');
});
});
}
// ===== Deactivation mode IconSelect =====
// Show dropdown on focus/click
searchInput.onfocus = () => {
renderDropdown(searchInput.value);
dropdown.classList.add('open');
};
const DEACT_MODE_KEYS = ['none', 'revert', 'fallback_scene'];
const DEACT_MODE_ICONS = {
none: P.pause, revert: P.undo2, fallback_scene: P.sparkles,
};
let _deactivationModeIconSelect = null;
searchInput.oninput = () => {
renderDropdown(searchInput.value);
dropdown.classList.add('open');
// If text doesn't match any scene, clear the hidden input
const exactMatch = scenePresetsCache.data.find(s => s.name.toLowerCase() === searchInput.value.toLowerCase());
if (!exactMatch) {
hiddenInput.value = '';
clearBtn.classList.toggle('visible', !!searchInput.value);
}
};
searchInput.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
// Select first visible item
const first = dropdown.querySelector('.scene-selector-item');
if (first) first.click();
} else if (e.key === 'Escape') {
dropdown.classList.remove('open');
searchInput.blur();
}
};
// Clear button
clearBtn.onclick = () => {
hiddenInput.value = '';
searchInput.value = '';
clearBtn.classList.remove('visible');
dropdown.classList.remove('open');
};
// Close dropdown when clicking outside
const selectorEl = searchInput.closest('.scene-selector');
// Remove old listener if any (re-init)
if (selectorEl._outsideClickHandler) {
document.removeEventListener('click', selectorEl._outsideClickHandler);
}
selectorEl._outsideClickHandler = (e) => {
if (!selectorEl.contains(e.target)) {
dropdown.classList.remove('open');
}
};
document.addEventListener('click', selectorEl._outsideClickHandler);
function _ensureDeactivationModeIconSelect() {
const sel = document.getElementById('automation-deactivation-mode');
if (!sel || _deactivationModeIconSelect) return;
const items = DEACT_MODE_KEYS.map(k => ({
value: k,
icon: _icon(DEACT_MODE_ICONS[k]),
label: t(`automations.deactivation_mode.${k}`),
desc: t(`automations.deactivation_mode.${k}.desc`),
}));
_deactivationModeIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}
// ===== Condition editor =====
@@ -423,6 +388,36 @@ export function addAutomationCondition() {
addAutomationConditionRow({ condition_type: 'application', apps: [], match_type: 'running' });
}
const CONDITION_TYPE_KEYS = ['always', 'startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook'];
const CONDITION_TYPE_ICONS = {
always: P.refreshCw, startup: P.power, application: P.smartphone,
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor,
mqtt: P.radio, webhook: P.globe,
};
const MATCH_TYPE_KEYS = ['running', 'topmost', 'topmost_fullscreen', 'fullscreen'];
const MATCH_TYPE_ICONS = {
running: P.play, topmost: P.monitor, topmost_fullscreen: P.tv, fullscreen: P.square,
};
function _buildMatchTypeItems() {
return MATCH_TYPE_KEYS.map(k => ({
value: k,
icon: _icon(MATCH_TYPE_ICONS[k]),
label: t(`automations.condition.application.match_type.${k}`),
desc: t(`automations.condition.application.match_type.${k}.desc`),
}));
}
function _buildConditionTypeItems() {
return CONDITION_TYPE_KEYS.map(k => ({
value: k,
icon: _icon(CONDITION_TYPE_ICONS[k]),
label: t(`automations.condition.${k}`),
desc: t(`automations.condition.${k}.desc`),
}));
}
function addAutomationConditionRow(condition) {
const list = document.getElementById('automation-conditions-list');
const row = document.createElement('div');
@@ -432,14 +427,7 @@ function addAutomationConditionRow(condition) {
row.innerHTML = `
<div class="condition-header">
<select class="condition-type-select">
<option value="always" ${condType === 'always' ? 'selected' : ''}>${t('automations.condition.always')}</option>
<option value="startup" ${condType === 'startup' ? 'selected' : ''}>${t('automations.condition.startup')}</option>
<option value="application" ${condType === 'application' ? 'selected' : ''}>${t('automations.condition.application')}</option>
<option value="time_of_day" ${condType === 'time_of_day' ? 'selected' : ''}>${t('automations.condition.time_of_day')}</option>
<option value="system_idle" ${condType === 'system_idle' ? 'selected' : ''}>${t('automations.condition.system_idle')}</option>
<option value="display_state" ${condType === 'display_state' ? 'selected' : ''}>${t('automations.condition.display_state')}</option>
<option value="mqtt" ${condType === 'mqtt' ? 'selected' : ''}>${t('automations.condition.mqtt')}</option>
<option value="webhook" ${condType === 'webhook' ? 'selected' : ''}>${t('automations.condition.webhook')}</option>
${CONDITION_TYPE_KEYS.map(k => `<option value="${k}" ${condType === k ? 'selected' : ''}>${t('automations.condition.' + k)}</option>`).join('')}
</select>
<button type="button" class="btn-remove-condition" onclick="this.closest('.automation-condition-row').remove()" title="Remove">&#x2715;</button>
</div>
@@ -449,6 +437,13 @@ function addAutomationConditionRow(condition) {
const typeSelect = row.querySelector('.condition-type-select');
const container = row.querySelector('.condition-fields-container');
// Attach IconSelect to the condition type dropdown
const condIconSelect = new IconSelect({
target: typeSelect,
items: _buildConditionTypeItems(),
columns: 4,
});
function renderFields(type, data) {
if (type === 'always') {
container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.always.hint')}</small>`;
@@ -585,6 +580,16 @@ function addAutomationConditionRow(condition) {
`;
const textarea = container.querySelector('.condition-apps');
attachProcessPicker(container, textarea);
// Attach IconSelect to match type
const matchSel = container.querySelector('.condition-match-type');
if (matchSel) {
new IconSelect({
target: matchSel,
items: _buildMatchTypeItems(),
columns: 2,
});
}
}
renderFields(condType, condition);