feat(ui): MiniSelect primitive + IconSelect XSS hardening + typed globals

MiniSelect replaces the forbidden plain <select> in editors where the
option list isn't large enough to justify the full IconSelect grid. It
shares the IconSelect look-and-feel (chip + dropdown panel, keyboard
nav, search). IconSelect grows an explicit HTML-escape for item labels
and keeps `item.icon` documented as a trusted-SVG sink for callers
that build the icon string from constants. global-types.d.ts gives
existing window.* accesses real types so feature modules can stop
falling back to `(window as any)`. The modal.css additions style the
two selectors and the new dropdown panels.
This commit is contained in:
2026-05-23 00:47:45 +03:00
parent d6cc80074d
commit 9ff83bd6ca
5 changed files with 927 additions and 40 deletions
+309
View File
@@ -5982,3 +5982,312 @@ body.composite-layer-dragging .composite-layer-drag-handle {
.icon-picker-toolbar { flex-direction: column; align-items: stretch; } .icon-picker-toolbar { flex-direction: column; align-items: stretch; }
} }
/* ── HTTP endpoint editor: custom headers list ─────────────────
Mirrors the .group-child-row vocabulary used by device-groups so
the modal feels native to the rest of the app. Each row is a
bordered card on `--bg-color`, with two input slots and a trash
button on the right; the leading numeric index gives the rows a
sense of order and matches the rack-panel section numbering. */
.http-headers-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
max-height: 320px;
overflow-y: auto;
padding: 2px 0;
}
.http-headers-empty {
color: var(--text-secondary);
font-size: 0.8125rem;
font-style: italic;
text-align: center;
padding: 16px;
border: 1px dashed var(--border-color);
border-radius: 8px;
}
.http-header-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px 6px 10px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm, 6px);
background: var(--bg-color);
transition: border-color 0.2s, background 0.15s, box-shadow 0.2s;
}
.http-header-row:hover,
.http-header-row:focus-within {
border-color: color-mix(in srgb, var(--primary-color) 40%, var(--border-color));
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.http-header-index {
font-size: 0.7rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
min-width: 18px;
text-align: center;
opacity: 0.6;
user-select: none;
}
.http-header-fields {
flex: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
gap: 6px;
min-width: 0;
}
.http-header-name,
.http-header-value {
width: 100%;
padding: 5px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--card-bg);
color: var(--text-color);
font-size: 0.8125rem;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
}
.http-header-name {
font-weight: 600;
}
.http-header-name:focus,
.http-header-value:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color) 25%, transparent);
}
.http-header-remove {
flex-shrink: 0;
width: 28px;
height: 28px;
padding: 0;
opacity: 0.55;
transition: opacity 0.15s, color 0.15s, background 0.15s;
}
.http-header-row:hover .http-header-remove,
.http-header-row:focus-within .http-header-remove {
opacity: 1;
}
.http-header-remove:hover {
color: var(--danger-color);
border-color: color-mix(in srgb, var(--danger-color) 40%, var(--border-color));
background: color-mix(in srgb, var(--danger-color) 10%, transparent);
}
.btn-add-header {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
padding: 6px 12px;
border-radius: 6px;
}
.btn-add-header-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 1rem;
line-height: 1;
font-weight: 600;
opacity: 0.8;
}
@media (max-width: 600px) {
.http-header-fields {
grid-template-columns: minmax(0, 1fr);
}
.http-header-index {
align-self: flex-start;
margin-top: 6px;
}
.http-header-row {
align-items: flex-start;
}
.http-header-remove {
align-self: flex-start;
margin-top: 2px;
}
}
/* ── HTTP endpoint editor: inline test request UI ─────────────
The Test button sits inside the request section and renders its
response below as a result card. Status badges use the success /
danger tokens to stay consistent with toast colors. */
.http-endpoint-test-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.http-endpoint-test-btn {
display: inline-flex;
align-items: center;
gap: 6px;
align-self: flex-start;
}
.http-endpoint-test-btn .http-endpoint-test-btn-icon {
display: inline-flex;
align-items: center;
}
.http-endpoint-test-btn .http-endpoint-test-btn-icon .icon {
width: 14px;
height: 14px;
}
.http-endpoint-test-btn.loading {
opacity: 0.7;
pointer-events: none;
}
.http-test-output {
margin-top: 0;
}
.http-test-pending {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
color: var(--text-secondary);
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-color);
}
.http-test-pending-spinner {
width: 12px;
height: 12px;
border: 2px solid color-mix(in srgb, var(--primary-color) 25%, transparent);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: http-test-spin 0.8s linear infinite;
}
@keyframes http-test-spin {
to { transform: rotate(360deg); }
}
.http-test-result {
border: 1px solid var(--border-color);
border-left-width: 3px;
border-radius: 6px;
background: var(--bg-color);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.http-test-result.http-test-ok {
border-left-color: var(--success-color, #28a745);
background: color-mix(in srgb, var(--success-color, #28a745) 6%, var(--bg-color));
}
.http-test-result.http-test-fail {
border-left-color: var(--danger-color, #f44336);
background: color-mix(in srgb, var(--danger-color, #f44336) 6%, var(--bg-color));
}
.http-test-line {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.http-test-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.http-test-badge .icon {
width: 12px;
height: 12px;
}
.http-test-badge-ok {
background: color-mix(in srgb, var(--success-color, #28a745) 18%, transparent);
color: var(--success-color, #28a745);
}
.http-test-badge-fail {
background: color-mix(in srgb, var(--danger-color, #f44336) 18%, transparent);
color: var(--danger-color, #f44336);
}
.http-test-status {
display: inline-flex;
align-items: center;
padding: 1px 8px;
border-radius: 4px;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 0.75rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.http-test-error {
display: block;
padding: 6px 10px;
border-radius: 4px;
background: color-mix(in srgb, var(--danger-color, #f44336) 10%, var(--card-bg));
color: var(--text-color);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
}
.http-test-body-label {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-secondary);
}
.http-test-body {
margin: 0;
padding: 8px 10px;
max-height: 220px;
overflow: auto;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 0.75rem;
line-height: 1.45;
color: var(--text-color);
white-space: pre;
word-break: normal;
}
+274 -39
View File
@@ -19,17 +19,42 @@
*/ */
import { desktopFocus } from './ui.ts'; import { desktopFocus } from './ui.ts';
import { escapeHtml } from './api.ts';
const POPUP_CLASS = 'icon-select-popup'; const POPUP_CLASS = 'icon-select-popup';
const FOCUSED_CLASS = 'focused';
const FOCUSED_SELECTOR = `.icon-select-cell.${FOCUSED_CLASS}`;
const CELL_SELECTOR = '.icon-select-cell';
const NAVIGABLE_SELECTOR = '.icon-select-cell:not(.disabled)';
/** Close every open icon-select popup. */ /**
export function closeAllIconSelects() { * Escape a value for use inside a double-quoted HTML attribute.
document.querySelectorAll(`.${POPUP_CLASS}`).forEach(p => { * `escapeHtml` (text-content escape) does not escape `"`, which leaves a
(p as HTMLElement).classList.remove('open'); * stored-XSS vector when interpolating user-typed labels into attribute
}); * contexts like `data-value="${value}"`. This belt-and-braces helper
* covers ``& < > " '`` so the result is safe in any attribute slot.
*/
function escAttr(text: string | undefined | null): string {
if (text == null) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
} }
// Global click-away listener (registered once) /** All registered IconSelect instances; lets `closeAllIconSelects` reach scroll-listener state. */
const _registry: Set<IconSelect> = new Set();
/** Close every open icon-select popup (and tear down their scroll listeners). */
export function closeAllIconSelects() {
for (const sel of _registry) {
sel._closeIfOpen();
}
}
// Global listeners (registered once)
let _globalListenerAdded = false; let _globalListenerAdded = false;
function _ensureGlobalListener() { function _ensureGlobalListener() {
if (_globalListenerAdded) return; if (_globalListenerAdded) return;
@@ -64,6 +89,79 @@ export interface IconSelectOpts {
searchPlaceholder?: string; searchPlaceholder?: string;
} }
/**
* Move a keyboard cursor over a grid of cells.
*
* Returns the new focused index (clamped). Marks the chosen cell with the
* shared `.focused` class and scrolls it into view.
*/
function applyFocus(grid: HTMLElement, cells: HTMLElement[], idx: number): number {
grid.querySelectorAll(FOCUSED_SELECTOR).forEach(c => c.classList.remove(FOCUSED_CLASS));
if (cells.length === 0) return -1;
const clamped = Math.max(0, Math.min(idx, cells.length - 1));
cells[clamped].classList.add(FOCUSED_CLASS);
cells[clamped].scrollIntoView({ block: 'nearest', inline: 'nearest' });
return clamped;
}
/**
* Compute the column count of a CSS grid by comparing `offsetTop` of cells
* in the first row. Triggers a layout read — callers should cache the result
* and invalidate only when the grid is rebuilt or filtered.
*/
function detectColumns(cells: HTMLElement[]): number {
if (cells.length === 0) return 1;
const firstTop = cells[0].offsetTop;
let cols = 0;
for (const c of cells) {
if (c.offsetTop !== firstTop) break;
cols++;
}
return Math.max(1, cols);
}
interface GridNavAction {
/** New focused index, or -1 to leave focus unchanged. */
nextIndex: number;
/** True when the key was consumed (caller should preventDefault). */
handled: boolean;
/** True when Enter was pressed and a cell should be picked. */
pick: boolean;
}
/**
* Pure keyboard-nav state machine shared by IconSelect and the standalone
* type-picker overlay. Returns what should happen; the caller decides
* preventDefault, stopPropagation, and cell-pick wiring.
*/
function handleGridKey(
key: string,
cur: number,
cellCount: number,
columns: number,
): GridNavAction {
if (cellCount === 0) return { nextIndex: -1, handled: false, pick: false };
const safe = cur >= 0 && cur < cellCount ? cur : 0;
switch (key) {
case 'ArrowRight':
return { nextIndex: Math.min(safe + 1, cellCount - 1), handled: true, pick: false };
case 'ArrowLeft':
return { nextIndex: Math.max(safe - 1, 0), handled: true, pick: false };
case 'ArrowDown':
return { nextIndex: Math.min(safe + columns, cellCount - 1), handled: true, pick: false };
case 'ArrowUp':
return { nextIndex: Math.max(safe - columns, 0), handled: true, pick: false };
case 'Home':
return { nextIndex: 0, handled: true, pick: false };
case 'End':
return { nextIndex: cellCount - 1, handled: true, pick: false };
case 'Enter':
return { nextIndex: safe, handled: true, pick: true };
default:
return { nextIndex: -1, handled: false, pick: false };
}
}
export class IconSelect { export class IconSelect {
_select: HTMLSelectElement; _select: HTMLSelectElement;
_items: IconSelectItem[]; _items: IconSelectItem[];
@@ -77,6 +175,7 @@ export class IconSelect {
_searchInput: HTMLInputElement | null = null; _searchInput: HTMLInputElement | null = null;
_scrollHandler: (() => void) | null = null; _scrollHandler: (() => void) | null = null;
_scrollTargets: (HTMLElement | Window)[] = []; _scrollTargets: (HTMLElement | Window)[] = [];
_focusedIndex: number = -1;
constructor({ target, items, onChange, columns = 2, placeholder = '', searchable = false, searchPlaceholder = 'Filter…' }: IconSelectOpts) { constructor({ target, items, onChange, columns = 2, placeholder = '', searchable = false, searchPlaceholder = 'Filter…' }: IconSelectOpts) {
_ensureGlobalListener(); _ensureGlobalListener();
@@ -109,6 +208,8 @@ export class IconSelect {
this._trigger = document.createElement('button'); this._trigger = document.createElement('button');
this._trigger.type = 'button'; this._trigger.type = 'button';
this._trigger.className = 'icon-select-trigger'; this._trigger.className = 'icon-select-trigger';
this._trigger.setAttribute('aria-haspopup', 'listbox');
this._trigger.setAttribute('aria-expanded', 'false');
this._trigger.addEventListener('click', (e) => { this._trigger.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
this._toggle(); this._toggle();
@@ -118,7 +219,10 @@ export class IconSelect {
// Build popup (portaled to body to avoid overflow clipping) // Build popup (portaled to body to avoid overflow clipping)
this._popup = document.createElement('div'); this._popup = document.createElement('div');
this._popup.className = POPUP_CLASS; this._popup.className = POPUP_CLASS;
this._popup.tabIndex = -1;
this._popup.setAttribute('role', 'listbox');
this._popup.addEventListener('click', (e) => e.stopPropagation()); this._popup.addEventListener('click', (e) => e.stopPropagation());
this._popup.addEventListener('keydown', (e) => this._handleKeydown(e));
this._popup.innerHTML = this._buildGrid(); this._popup.innerHTML = this._buildGrid();
document.body.appendChild(this._popup); document.body.appendChild(this._popup);
@@ -126,15 +230,17 @@ export class IconSelect {
// Sync to current select value // Sync to current select value
this._syncTrigger(); this._syncTrigger();
_registry.add(this);
} }
_bindPopupEvents() { _bindPopupEvents() {
// Bind item clicks // Bind item clicks
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => { this._popup.querySelectorAll(CELL_SELECTOR).forEach(cell => {
cell.setAttribute('role', 'option');
cell.addEventListener('click', () => { cell.addEventListener('click', () => {
this.setValue((cell as HTMLElement).dataset.value!, true); this.setValue((cell as HTMLElement).dataset.value!, true);
this._popup.classList.remove('open'); this._closeIfOpen();
this._removeScrollListener();
}); });
}); });
@@ -143,26 +249,68 @@ export class IconSelect {
if (this._searchInput) { if (this._searchInput) {
this._searchInput.addEventListener('input', () => { this._searchInput.addEventListener('input', () => {
const q = this._searchInput!.value.toLowerCase().trim(); const q = this._searchInput!.value.toLowerCase().trim();
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => { this._popup.querySelectorAll(CELL_SELECTOR).forEach(cell => {
const el = cell as HTMLElement; const el = cell as HTMLElement;
el.classList.toggle('disabled', !!q && !el.dataset.search!.includes(q)); el.classList.toggle('disabled', !!q && !el.dataset.search!.includes(q));
}); });
// Re-anchor keyboard cursor to first visible cell after filtering
this._setFocusedIndex(0);
}); });
} }
} }
/** Cells eligible for keyboard navigation (selectable + visible). */
_getNavigableCells(): HTMLElement[] {
return Array.from(this._popup.querySelectorAll<HTMLElement>(NAVIGABLE_SELECTOR));
}
/** Move the keyboard cursor to cell `idx` (clamped), scrolling it into view. */
_setFocusedIndex(idx: number) {
const cells = this._getNavigableCells();
this._focusedIndex = applyFocus(this._popup, cells, idx);
if (this._focusedIndex >= 0) {
const activeId = cells[this._focusedIndex].id || `icon-select-cell-${this._focusedIndex}`;
cells[this._focusedIndex].id = activeId;
this._popup.setAttribute('aria-activedescendant', activeId);
} else {
this._popup.removeAttribute('aria-activedescendant');
}
}
_handleKeydown(e: KeyboardEvent) {
if (!this._popup.classList.contains('open')) return;
const cells = this._getNavigableCells();
const action = handleGridKey(e.key, this._focusedIndex, cells.length, this._columns);
if (!action.handled) return;
e.preventDefault();
e.stopPropagation();
if (action.pick) {
const cell = cells[action.nextIndex];
if (!cell) return;
this.setValue(cell.dataset.value!, true);
this._closeIfOpen();
desktopFocus(this._trigger);
return;
}
this._setFocusedIndex(action.nextIndex);
}
_buildGrid() { _buildGrid() {
// item.icon is a raw SVG string by design (callers pass project-owned
// icon literals). label/desc/value are user-visible text and may
// originate from user input — escape them everywhere they cross
// an innerHTML boundary.
const cells = this._items.map(item => { const cells = this._items.map(item => {
const search = (item.label + ' ' + (item.desc || '')).toLowerCase(); const search = (item.label + ' ' + (item.desc || '')).toLowerCase();
return `<div class="icon-select-cell" data-value="${item.value}" data-search="${search}"> return `<div class="icon-select-cell" data-value="${escAttr(item.value)}" data-search="${escAttr(search)}">
<span class="icon-select-cell-icon">${item.icon}</span> <span class="icon-select-cell-icon">${item.icon}</span>
<span class="icon-select-cell-label">${item.label}</span> <span class="icon-select-cell-label">${escapeHtml(item.label)}</span>
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''} ${item.desc ? `<span class="icon-select-cell-desc">${escapeHtml(item.desc)}</span>` : ''}
</div>`; </div>`;
}).join(''); }).join('');
const searchHTML = this._searchable const searchHTML = this._searchable
? `<input class="icon-select-search" type="text" placeholder="${this._searchPlaceholder}" autocomplete="off">` ? `<input class="icon-select-search" type="text" placeholder="${escAttr(this._searchPlaceholder)}" autocomplete="off">`
: ''; : '';
return searchHTML + `<div class="icon-select-grid" style="grid-template-columns:repeat(${this._columns},1fr)">${cells}</div>`; return searchHTML + `<div class="icon-select-grid" style="grid-template-columns:repeat(${this._columns},1fr)">${cells}</div>`;
} }
@@ -173,17 +321,19 @@ export class IconSelect {
if (item) { if (item) {
this._trigger.innerHTML = this._trigger.innerHTML =
`<span class="icon-select-trigger-icon">${item.icon}</span>` + `<span class="icon-select-trigger-icon">${item.icon}</span>` +
`<span class="icon-select-trigger-label">${item.label}</span>` + `<span class="icon-select-trigger-label">${escapeHtml(item.label)}</span>` +
`<span class="icon-select-trigger-arrow">&#x25BE;</span>`; `<span class="icon-select-trigger-arrow">&#x25BE;</span>`;
} else if (this._placeholder) { } else if (this._placeholder) {
this._trigger.innerHTML = this._trigger.innerHTML =
`<span class="icon-select-trigger-label">${this._placeholder}</span>` + `<span class="icon-select-trigger-label">${escapeHtml(this._placeholder)}</span>` +
`<span class="icon-select-trigger-arrow">&#x25BE;</span>`; `<span class="icon-select-trigger-arrow">&#x25BE;</span>`;
} }
// Update active state in grid // Update active state in grid
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => { this._popup.querySelectorAll(CELL_SELECTOR).forEach(cell => {
const el = cell as HTMLElement; const el = cell as HTMLElement;
el.classList.toggle('active', el.dataset.value === val); const active = el.dataset.value === val;
el.classList.toggle('active', active);
el.setAttribute('aria-selected', active ? 'true' : 'false');
}); });
} }
@@ -225,23 +375,46 @@ export class IconSelect {
if (!wasOpen) { if (!wasOpen) {
this._positionPopup(); this._positionPopup();
this._popup.classList.add('open'); this._popup.classList.add('open');
this._trigger.setAttribute('aria-expanded', 'true');
this._addScrollListener(); this._addScrollListener();
if (this._searchInput) { if (this._searchInput) {
this._searchInput.value = ''; this._searchInput.value = '';
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => { this._popup.querySelectorAll(CELL_SELECTOR).forEach(cell => {
(cell as HTMLElement).classList.remove('disabled'); (cell as HTMLElement).classList.remove('disabled');
}); });
requestAnimationFrame(() => desktopFocus(this._searchInput!)); requestAnimationFrame(() => desktopFocus(this._searchInput!));
} else {
// No search input — focus the popup itself so it captures keydown
requestAnimationFrame(() => desktopFocus(this._popup));
} }
// Seed keyboard cursor on the currently-selected cell (or first cell)
const cells = this._getNavigableCells();
const activeIdx = cells.findIndex(c => c.dataset.value === this._select.value);
this._setFocusedIndex(activeIdx >= 0 ? activeIdx : 0);
} }
} }
/** Close the popup if it is open and tear down listeners / focus state. */
_closeIfOpen() {
if (!this._popup.classList.contains('open')) return;
this._popup.classList.remove('open');
this._trigger.setAttribute('aria-expanded', 'false');
this._removeScrollListener();
this._clearFocusedCell();
}
_clearFocusedCell() {
this._popup.querySelectorAll(FOCUSED_SELECTOR)
.forEach(c => c.classList.remove(FOCUSED_CLASS));
this._focusedIndex = -1;
this._popup.removeAttribute('aria-activedescendant');
}
/** Close popup when any scrollable ancestor scrolls (prevents stale position). */ /** Close popup when any scrollable ancestor scrolls (prevents stale position). */
_addScrollListener() { _addScrollListener() {
if (this._scrollHandler) return; if (this._scrollHandler) return;
this._scrollHandler = () => { this._scrollHandler = () => {
this._popup.classList.remove('open'); this._closeIfOpen();
this._removeScrollListener();
}; };
// Listen on capture phase to catch scroll on any ancestor // Listen on capture phase to catch scroll on any ancestor
let el: Node | null = this._trigger.parentNode; let el: Node | null = this._trigger.parentNode;
@@ -289,6 +462,7 @@ export class IconSelect {
/** Remove the enhancement, restore native <select>. */ /** Remove the enhancement, restore native <select>. */
destroy() { destroy() {
this._removeScrollListener(); this._removeScrollListener();
_registry.delete(this);
this._trigger.remove(); this._trigger.remove();
this._popup.remove(); this._popup.remove();
this._select.style.display = ''; this._select.style.display = '';
@@ -317,11 +491,14 @@ export function showTypePicker({ title, items, onPick, filterTabs, onFilterChang
const showFilter = items.length > 9; const showFilter = items.length > 9;
function buildCells(cellItems: IconSelectItem[]): string { function buildCells(cellItems: IconSelectItem[]): string {
// item.icon is trusted raw SVG. label/desc/value are escaped at
// every innerHTML boundary because callers route user-typed text
// (device names, entity labels) through this picker.
return cellItems.map(item => return cellItems.map(item =>
`<div class="icon-select-cell" data-value="${item.value}" data-search="${(item.label + ' ' + (item.desc || '')).toLowerCase()}"> `<div class="icon-select-cell" data-value="${escAttr(item.value)}" data-search="${escAttr((item.label + ' ' + (item.desc || '')).toLowerCase())}" role="option">
<span class="icon-select-cell-icon">${item.icon}</span> <span class="icon-select-cell-icon">${item.icon}</span>
<span class="icon-select-cell-label">${item.label}</span> <span class="icon-select-cell-label">${escapeHtml(item.label)}</span>
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''} ${item.desc ? `<span class="icon-select-cell-desc">${escapeHtml(item.desc)}</span>` : ''}
</div>` </div>`
).join(''); ).join('');
} }
@@ -329,7 +506,7 @@ export function showTypePicker({ title, items, onPick, filterTabs, onFilterChang
// Build filter tabs HTML // Build filter tabs HTML
const tabsHtml = filterTabs && filterTabs.length > 0 const tabsHtml = filterTabs && filterTabs.length > 0
? `<div class="type-picker-tabs">${filterTabs.map((tab, i) => ? `<div class="type-picker-tabs">${filterTabs.map((tab, i) =>
`<button class="type-picker-tab${i === 0 ? ' active' : ''}" data-filter-key="${tab.key}">${tab.label}</button>` `<button class="type-picker-tab${i === 0 ? ' active' : ''}" data-filter-key="${escAttr(tab.key)}">${escapeHtml(tab.label)}</button>`
).join('')}</div>` ).join('')}</div>`
: ''; : '';
@@ -337,20 +514,46 @@ export function showTypePicker({ title, items, onPick, filterTabs, onFilterChang
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'type-picker-overlay'; overlay.className = 'type-picker-overlay';
overlay.innerHTML = ` overlay.innerHTML = `
<div class="type-picker-dialog"> <div class="type-picker-dialog" role="dialog" aria-modal="true" aria-label="${escAttr(title)}">
<div class="type-picker-title">${title}</div> <div class="type-picker-title">${escapeHtml(title)}</div>
${tabsHtml} ${tabsHtml}
${showFilter ? '<input class="type-picker-filter" type="text" placeholder="Filter…" autocomplete="off">' : ''} ${showFilter ? '<input class="type-picker-filter" type="text" placeholder="Filter…" autocomplete="off">' : ''}
<div class="icon-select-grid">${buildCells(items)}</div> <div class="icon-select-grid" role="listbox">${buildCells(items)}</div>
</div>`; </div>`;
document.body.appendChild(overlay); document.body.appendChild(overlay);
const close = () => { overlay.remove(); document.removeEventListener('keydown', onKey); }; const close = () => { overlay.remove(); document.removeEventListener('keydown', onKey); };
const grid = overlay.querySelector('.icon-select-grid') as HTMLElement; const grid = overlay.querySelector('.icon-select-grid') as HTMLElement;
const filterInput = (showFilter
? overlay.querySelector('.type-picker-filter') as HTMLInputElement
: null);
let focusedIdx = -1;
// Cache the column count; recomputed only when the grid is rebuilt or filtered.
let cachedColumns = 1;
const getNavCells = (): HTMLElement[] =>
Array.from(grid.querySelectorAll<HTMLElement>(NAVIGABLE_SELECTOR));
const refreshColumns = () => {
cachedColumns = detectColumns(getNavCells());
};
const setFocused = (idx: number) => {
const cells = getNavCells();
focusedIdx = applyFocus(grid, cells, idx);
if (focusedIdx >= 0) {
const id = cells[focusedIdx].id || `type-picker-cell-${focusedIdx}`;
cells[focusedIdx].id = id;
grid.setAttribute('aria-activedescendant', id);
} else {
grid.removeAttribute('aria-activedescendant');
}
};
function bindCellClicks() { function bindCellClicks() {
grid.querySelectorAll('.icon-select-cell').forEach(cell => { grid.querySelectorAll(CELL_SELECTOR).forEach(cell => {
cell.addEventListener('click', () => { cell.addEventListener('click', () => {
if (cell.classList.contains('disabled')) return; if (cell.classList.contains('disabled')) return;
close(); close();
@@ -370,22 +573,25 @@ export function showTypePicker({ title, items, onPick, filterTabs, onFilterChang
const newItems = onFilterChange(key); const newItems = onFilterChange(key);
grid.innerHTML = buildCells(newItems); grid.innerHTML = buildCells(newItems);
bindCellClicks(); bindCellClicks();
refreshColumns();
setFocused(0);
}); });
}); });
} }
// Filter logic // Filter logic
if (showFilter) { if (filterInput) {
const input = overlay.querySelector('.type-picker-filter') as HTMLInputElement; filterInput.addEventListener('input', () => {
input.addEventListener('input', () => { const q = filterInput.value.toLowerCase().trim();
const q = input.value.toLowerCase().trim(); grid.querySelectorAll(CELL_SELECTOR).forEach(cell => {
grid.querySelectorAll('.icon-select-cell').forEach(cell => {
const el = cell as HTMLElement; const el = cell as HTMLElement;
const match = !q || el.dataset.search!.includes(q); const match = !q || el.dataset.search!.includes(q);
el.classList.toggle('disabled', !match); el.classList.toggle('disabled', !match);
}); });
refreshColumns();
setFocused(0);
}); });
requestAnimationFrame(() => setTimeout(() => desktopFocus(input), 200)); requestAnimationFrame(() => setTimeout(() => desktopFocus(filterInput), 200));
} }
// Backdrop click // Backdrop click
@@ -393,12 +599,41 @@ export function showTypePicker({ title, items, onPick, filterTabs, onFilterChang
if (e.target === overlay) close(); if (e.target === overlay) close();
}); });
// Escape key // Keyboard navigation
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close(); if (e.key === 'Escape') { close(); return; }
// Don't hijack arrow keys while the user is editing the filter
// input — let the caret move inside the text field normally.
if (filterInput && document.activeElement === filterInput
&& (e.key === 'ArrowLeft' || e.key === 'ArrowRight'
|| e.key === 'Home' || e.key === 'End')) {
return;
}
const cells = getNavCells();
if (cells.length === 0) return;
const action = handleGridKey(e.key, focusedIdx, cells.length, cachedColumns);
if (!action.handled) return;
e.preventDefault();
if (action.pick) {
// Treat focusedIdx === -1 (rAF race before initial setFocused) as
// the first cell, matching the visual cursor seed.
const idx = action.nextIndex >= 0 ? action.nextIndex : 0;
const cell = cells[idx];
if (!cell) return;
close();
onPick(cell.dataset.value!);
return;
}
setFocused(action.nextIndex);
}; };
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
// Animate in // Animate in, prime the column cache, and seed keyboard cursor on first cell.
requestAnimationFrame(() => overlay.classList.add('open')); requestAnimationFrame(() => {
overlay.classList.add('open');
refreshColumns();
setFocused(0);
});
} }
+2 -1
View File
@@ -18,7 +18,7 @@ const _targetTypeIcons = { led: _svg(P.lightbulb), wled: _svg(P.lightbulb
const _pictureSourceTypeIcons = { raw: _svg(P.monitor), processed: _svg(P.palette), static_image: _svg(P.image), video: _svg(P.film) }; const _pictureSourceTypeIcons = { raw: _svg(P.monitor), processed: _svg(P.palette), static_image: _svg(P.image), video: _svg(P.film) };
const _colorStripTypeIcons = { const _colorStripTypeIcons = {
picture_advanced: _svg(P.monitor), picture_advanced: _svg(P.monitor),
static: _svg(P.palette), gradient: _svg(P.rainbow), single_color: _svg(P.palette), gradient: _svg(P.rainbow),
effect: _svg(P.zap), composite: _svg(P.link), effect: _svg(P.zap), composite: _svg(P.link),
mapped: _svg(P.mapPin), mapped_zones: _svg(P.mapPin), mapped: _svg(P.mapPin), mapped_zones: _svg(P.mapPin),
audio: _svg(P.music), audio_visualization: _svg(P.music), audio: _svg(P.music), audio_visualization: _svg(P.music),
@@ -42,6 +42,7 @@ const _valueSourceTypeIcons = {
css_extract: _svg(P.droplets), css_extract: _svg(P.droplets),
system_metrics: _svg(P.cpu), system_metrics: _svg(P.cpu),
game_event: _svg(P.gamepad2), game_event: _svg(P.gamepad2),
http: _svg(P.globe),
}; };
const _audioSourceTypeIcons = { capture: _svg(P.volume2), processed: _svg(P.slidersHorizontal) }; const _audioSourceTypeIcons = { capture: _svg(P.volume2), processed: _svg(P.slidersHorizontal) };
const _deviceTypeIcons = { const _deviceTypeIcons = {
@@ -0,0 +1,274 @@
/**
* MiniSelect — compact, icon-less dropdown that replaces plain ``<select>``.
*
* IconSelect requires an SVG icon per option, which doesn't fit the
* dashboard's inline perf-cell controls (mode / window / yScale). Plain
* ``<select>`` is banned project-wide because it breaks the UI's visual
* consistency. MiniSelect fills the gap: a styled trigger button that
* shows the current option label, plus a small popup with the option
* labels. The original ``<select>`` is hidden but kept in the DOM and
* receives a native ``change`` event whenever the user picks an option,
* so existing handlers keep working with no changes.
*
* Usage:
* const sel = document.getElementById('perf-mode') as HTMLSelectElement;
* new MiniSelect(sel);
*
* The trigger displays each option's visible text from its ``<option>``
* label; option values come from the underlying ``<select>``.
*/
import { closeAllIconSelects } from './icon-select.ts';
import { escapeHtml } from './api.ts';
import { desktopFocus } from './ui.ts';
const POPUP_CLASS = 'mini-select-popup';
const FOCUSED_CLASS = 'focused';
const CELL_SELECTOR = '.mini-select-option';
const FOCUSED_SELECTOR = `${CELL_SELECTOR}.${FOCUSED_CLASS}`;
const _registry: Set<MiniSelect> = new Set();
/** Close every open MiniSelect popup. */
export function closeAllMiniSelects(): void {
for (const ms of _registry) ms._close();
}
let _globalListenerAdded = false;
function _ensureGlobalListener(): void {
if (_globalListenerAdded) return;
_globalListenerAdded = true;
document.addEventListener('click', (e) => {
const t = e.target as HTMLElement;
if (!t.closest(`.${POPUP_CLASS}`) && !t.closest('.mini-select-trigger')) {
closeAllMiniSelects();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAllMiniSelects();
});
}
interface MiniSelectOption {
value: string;
label: string;
}
export class MiniSelect {
_select: HTMLSelectElement;
_options: MiniSelectOption[];
_trigger: HTMLButtonElement;
_popup: HTMLDivElement;
_focusedIndex = -1;
constructor(target: HTMLSelectElement) {
// Picking up plain ``<select>``s in the same modal as IconSelects is
// intentional — we want the same click-away/Escape behaviour, so we
// also close any open IconSelect popup when ours opens.
_ensureGlobalListener();
this._select = target;
this._options = Array.from(target.options).map((opt) => ({
value: opt.value,
label: opt.textContent || opt.value,
}));
target.style.display = 'none';
this._trigger = document.createElement('button');
this._trigger.type = 'button';
this._trigger.className = 'mini-select-trigger';
this._trigger.setAttribute('aria-haspopup', 'listbox');
this._trigger.setAttribute('aria-expanded', 'false');
if (target.title) this._trigger.title = target.title;
this._trigger.addEventListener('click', (e) => {
e.stopPropagation();
this._toggle();
});
target.parentNode!.insertBefore(this._trigger, target.nextSibling);
this._popup = document.createElement('div');
this._popup.className = POPUP_CLASS;
this._popup.tabIndex = -1;
this._popup.setAttribute('role', 'listbox');
this._popup.addEventListener('click', (e) => e.stopPropagation());
this._popup.addEventListener('keydown', (e) => this._onKey(e));
this._popup.innerHTML = this._buildPopup();
document.body.appendChild(this._popup);
this._bindOptionClicks();
this._syncTrigger();
_registry.add(this);
}
/** Refresh trigger label + popup content after an external change. */
refresh(): void {
// Rebuild option list in case the underlying <select> changed.
this._options = Array.from(this._select.options).map((opt) => ({
value: opt.value,
label: opt.textContent || opt.value,
}));
this._popup.innerHTML = this._buildPopup();
this._bindOptionClicks();
this._syncTrigger();
}
/** Remove the enhancement and restore the native <select>. */
destroy(): void {
_registry.delete(this);
this._trigger.remove();
this._popup.remove();
this._select.style.display = '';
}
// ── Internals ─────────────────────────────────────────────────
_buildPopup(): string {
return this._options
.map(
(o, i) =>
`<div class="mini-select-option" role="option" data-value="${escapeHtml(o.value)}" data-index="${i}">${escapeHtml(o.label)}</div>`,
)
.join('');
}
_bindOptionClicks(): void {
this._popup.querySelectorAll<HTMLElement>(CELL_SELECTOR).forEach((cell) => {
cell.addEventListener('click', () => {
this._pick(cell.dataset.value || '');
});
});
}
_syncTrigger(): void {
const cur = this._options.find((o) => o.value === this._select.value);
const label = cur ? cur.label : this._options[0]?.label || '';
this._trigger.innerHTML = `<span class="mini-select-trigger-label">${escapeHtml(label)}</span><span class="mini-select-trigger-arrow">&#x25BE;</span>`;
this._popup.querySelectorAll<HTMLElement>(CELL_SELECTOR).forEach((cell) => {
const active = cell.dataset.value === this._select.value;
cell.classList.toggle('active', active);
cell.setAttribute('aria-selected', active ? 'true' : 'false');
});
}
_position(): void {
const rect = this._trigger.getBoundingClientRect();
const pad = 8;
const gap = 4;
const popupW = Math.max(rect.width, 120);
const spaceBelow = window.innerHeight - rect.bottom - gap - pad;
const spaceAbove = rect.top - gap - pad;
const openUp = spaceBelow < 160 && spaceAbove > spaceBelow;
const available = openUp ? spaceAbove : spaceBelow;
let left = rect.left;
if (left + popupW > window.innerWidth - pad) {
left = window.innerWidth - pad - popupW;
}
if (left < pad) left = pad;
this._popup.style.left = `${left}px`;
this._popup.style.width = `${popupW}px`;
this._popup.style.maxHeight = `${available}px`;
if (openUp) {
this._popup.style.top = '';
this._popup.style.bottom = `${window.innerHeight - rect.top + gap}px`;
} else {
this._popup.style.top = `${rect.bottom + gap}px`;
this._popup.style.bottom = '';
}
}
_toggle(): void {
const isOpen = this._popup.classList.contains('open');
closeAllIconSelects();
closeAllMiniSelects();
if (isOpen) return;
this._position();
this._popup.classList.add('open');
this._trigger.setAttribute('aria-expanded', 'true');
requestAnimationFrame(() => desktopFocus(this._popup));
const activeIdx = this._options.findIndex((o) => o.value === this._select.value);
this._setFocused(activeIdx >= 0 ? activeIdx : 0);
}
_close(): void {
if (!this._popup.classList.contains('open')) return;
this._popup.classList.remove('open');
this._trigger.setAttribute('aria-expanded', 'false');
this._clearFocused();
}
_clearFocused(): void {
this._popup.querySelectorAll(FOCUSED_SELECTOR).forEach((c) => c.classList.remove(FOCUSED_CLASS));
this._focusedIndex = -1;
}
_setFocused(idx: number): void {
const cells = Array.from(this._popup.querySelectorAll<HTMLElement>(CELL_SELECTOR));
if (cells.length === 0) return;
const clamped = Math.max(0, Math.min(idx, cells.length - 1));
this._clearFocused();
cells[clamped].classList.add(FOCUSED_CLASS);
cells[clamped].scrollIntoView({ block: 'nearest', inline: 'nearest' });
this._focusedIndex = clamped;
}
_onKey(e: KeyboardEvent): void {
if (!this._popup.classList.contains('open')) return;
const total = this._options.length;
const cur = this._focusedIndex >= 0 ? this._focusedIndex : 0;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this._setFocused(Math.min(cur + 1, total - 1));
break;
case 'ArrowUp':
e.preventDefault();
this._setFocused(Math.max(cur - 1, 0));
break;
case 'Home':
e.preventDefault();
this._setFocused(0);
break;
case 'End':
e.preventDefault();
this._setFocused(total - 1);
break;
case 'Enter':
e.preventDefault();
if (this._focusedIndex >= 0) {
this._pick(this._options[this._focusedIndex].value);
}
break;
}
}
_pick(value: string): void {
this._select.value = value;
this._syncTrigger();
// Dispatch the native event so existing handlers attached to the
// underlying <select> keep working without modification.
this._select.dispatchEvent(new Event('change', { bubbles: true }));
this._close();
desktopFocus(this._trigger);
}
}
/**
* Enhance every plain ``<select>`` matching *selector* under *root* into a
* MiniSelect. Selects that are already enhanced by another component
* (``IconSelect`` / ``EntitySelect`` — both hide the target via
* ``style.display = 'none'``) are skipped so the two wrappers don't compete
* for the same `<select>`.
*/
export function enhanceMiniSelects(root: ParentNode, selector = 'select'): MiniSelect[] {
const out: MiniSelect[] = [];
root.querySelectorAll<HTMLSelectElement>(selector).forEach((sel) => {
if (sel.dataset.miniEnhanced === '1') return;
// Skip selects already hidden by an upstream IconSelect/EntitySelect.
if (sel.style.display === 'none') return;
sel.dataset.miniEnhanced = '1';
out.push(new MiniSelect(sel));
});
return out;
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Ambient declarations for the project's `window` globals.
*
* Several legacy modules attach helpers onto `window` so they can be
* called from HTML ``onclick`` attributes / global tab-registry lookups.
* Without these declarations every callsite used ``(window as any).foo``,
* which silently erases type errors at the call boundary. Declaring the
* known fields here lets `tsc --noEmit` flag real typos while keeping
* the call sites readable.
*
* The list is the minimal subset that covers the ``(window as any).<name>``
* sites flagged by the cross-file audit. Anything indexed by dynamic
* string ($name = `${kind}Foo`) still legitimately needs an indexed
* access — those cases keep their narrow casts.
*/
export {};
declare global {
interface Window {
// Auth / setup gates (set from core/api.ts during boot)
_authRequired?: boolean;
_setupRequired?: boolean;
_setupModalOpen?: boolean;
// i18n shim — present once core/i18n.ts initialises.
__t?: (key: string) => string;
// UI helpers exposed for inline onclick handlers in templates.
applyAccentColor?: () => void;
hideSetupRequiredModal?: () => void;
configureKCRegions?: (sourceId: string) => void;
removeZ2MLightMapping?: (btn: HTMLElement) => void;
// Feature reloaders. They are called from cross-feature code that
// doesn't want a hard module import (to avoid cycles).
loadAutomations?: () => Promise<void> | void;
loadPictureSources?: () => Promise<void> | void;
showPatternTemplateEditor?: (...args: unknown[]) => unknown;
// Internal helpers attached by features for inline-call reuse.
_autoGenerateAutomationName?: () => void;
_openKCRegionEditor?: (sourceId: string) => void;
// HTTP endpoint editor — used by the integrations tab and
// automation rule editor.
showHTTPEndpointModal?: (editData?: unknown) => Promise<void>;
closeHTTPEndpointModal?: () => Promise<void>;
saveHTTPEndpoint?: () => Promise<void>;
editHTTPEndpoint?: (id: string) => Promise<void>;
cloneHTTPEndpoint?: (id: string) => Promise<void>;
deleteHTTPEndpoint?: (id: string) => Promise<void>;
testHTTPEndpoint?: () => Promise<void>;
addHTTPEndpointHeader?: () => void;
toggleHTTPEndpointTokenVisibility?: () => void;
// HA / MQTT source editors — declared so app.ts assignments
// satisfy strict mode. (Not exhaustive; only what http-endpoints
// wires up.)
showHASourceModal?: (...args: unknown[]) => unknown;
closeHASourceModal?: () => Promise<void>;
saveHASource?: () => Promise<void>;
editHASource?: (id: string) => Promise<void>;
cloneHASource?: (id: string) => Promise<void>;
deleteHASource?: (id: string) => Promise<void>;
testHASource?: () => Promise<void>;
}
}