From 9ff83bd6caacb13c38229daad8cd21117e01da46 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 23 May 2026 00:47:45 +0300 Subject: [PATCH] feat(ui): MiniSelect primitive + IconSelect XSS hardening + typed globals MiniSelect replaces the forbidden plain ` + ? `` : ''; return searchHTML + `
${cells}
`; } @@ -173,17 +321,19 @@ export class IconSelect { if (item) { this._trigger.innerHTML = `${item.icon}` + - `${item.label}` + + `${escapeHtml(item.label)}` + ``; } else if (this._placeholder) { this._trigger.innerHTML = - `${this._placeholder}` + + `${escapeHtml(this._placeholder)}` + ``; } // 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; - 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) { this._positionPopup(); this._popup.classList.add('open'); + this._trigger.setAttribute('aria-expanded', 'true'); this._addScrollListener(); if (this._searchInput) { this._searchInput.value = ''; - this._popup.querySelectorAll('.icon-select-cell').forEach(cell => { + this._popup.querySelectorAll(CELL_SELECTOR).forEach(cell => { (cell as HTMLElement).classList.remove('disabled'); }); 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). */ _addScrollListener() { if (this._scrollHandler) return; this._scrollHandler = () => { - this._popup.classList.remove('open'); - this._removeScrollListener(); + this._closeIfOpen(); }; // Listen on capture phase to catch scroll on any ancestor let el: Node | null = this._trigger.parentNode; @@ -289,6 +462,7 @@ export class IconSelect { /** Remove the enhancement, restore native ' : ''} -
${buildCells(items)}
+
${buildCells(items)}
`; document.body.appendChild(overlay); const close = () => { overlay.remove(); document.removeEventListener('keydown', onKey); }; 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(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() { - grid.querySelectorAll('.icon-select-cell').forEach(cell => { + grid.querySelectorAll(CELL_SELECTOR).forEach(cell => { cell.addEventListener('click', () => { if (cell.classList.contains('disabled')) return; close(); @@ -370,22 +573,25 @@ export function showTypePicker({ title, items, onPick, filterTabs, onFilterChang const newItems = onFilterChange(key); grid.innerHTML = buildCells(newItems); bindCellClicks(); + refreshColumns(); + setFocused(0); }); }); } // Filter logic - if (showFilter) { - const input = overlay.querySelector('.type-picker-filter') as HTMLInputElement; - input.addEventListener('input', () => { - const q = input.value.toLowerCase().trim(); - grid.querySelectorAll('.icon-select-cell').forEach(cell => { + if (filterInput) { + filterInput.addEventListener('input', () => { + const q = filterInput.value.toLowerCase().trim(); + grid.querySelectorAll(CELL_SELECTOR).forEach(cell => { const el = cell as HTMLElement; const match = !q || el.dataset.search!.includes(q); el.classList.toggle('disabled', !match); }); + refreshColumns(); + setFocused(0); }); - requestAnimationFrame(() => setTimeout(() => desktopFocus(input), 200)); + requestAnimationFrame(() => setTimeout(() => desktopFocus(filterInput), 200)); } // Backdrop click @@ -393,12 +599,41 @@ export function showTypePicker({ title, items, onPick, filterTabs, onFilterChang if (e.target === overlay) close(); }); - // Escape key + // Keyboard navigation 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); - // Animate in - requestAnimationFrame(() => overlay.classList.add('open')); + // Animate in, prime the column cache, and seed keyboard cursor on first cell. + requestAnimationFrame(() => { + overlay.classList.add('open'); + refreshColumns(); + setFocused(0); + }); } diff --git a/server/src/ledgrab/static/js/core/icons.ts b/server/src/ledgrab/static/js/core/icons.ts index 5bb4ad5..b9923b2 100644 --- a/server/src/ledgrab/static/js/core/icons.ts +++ b/server/src/ledgrab/static/js/core/icons.ts @@ -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 _colorStripTypeIcons = { 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), mapped: _svg(P.mapPin), mapped_zones: _svg(P.mapPin), audio: _svg(P.music), audio_visualization: _svg(P.music), @@ -42,6 +42,7 @@ const _valueSourceTypeIcons = { css_extract: _svg(P.droplets), system_metrics: _svg(P.cpu), game_event: _svg(P.gamepad2), + http: _svg(P.globe), }; const _audioSourceTypeIcons = { capture: _svg(P.volume2), processed: _svg(P.slidersHorizontal) }; const _deviceTypeIcons = { diff --git a/server/src/ledgrab/static/js/core/mini-select.ts b/server/src/ledgrab/static/js/core/mini-select.ts new file mode 100644 index 0000000..8228458 --- /dev/null +++ b/server/src/ledgrab/static/js/core/mini-select.ts @@ -0,0 +1,274 @@ +/** + * MiniSelect — compact, icon-less dropdown that replaces plain ```` 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 ````. + */ + +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 = 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 `` 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 keep working without modification. + this._select.dispatchEvent(new Event('change', { bubbles: true })); + this._close(); + desktopFocus(this._trigger); + } +} + +/** + * Enhance every plain ```. + */ +export function enhanceMiniSelects(root: ParentNode, selector = 'select'): MiniSelect[] { + const out: MiniSelect[] = []; + root.querySelectorAll(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; +} diff --git a/server/src/ledgrab/static/js/global-types.d.ts b/server/src/ledgrab/static/js/global-types.d.ts new file mode 100644 index 0000000..4222ae4 --- /dev/null +++ b/server/src/ledgrab/static/js/global-types.d.ts @@ -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).`` + * 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; + loadPictureSources?: () => Promise | 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; + closeHTTPEndpointModal?: () => Promise; + saveHTTPEndpoint?: () => Promise; + editHTTPEndpoint?: (id: string) => Promise; + cloneHTTPEndpoint?: (id: string) => Promise; + deleteHTTPEndpoint?: (id: string) => Promise; + testHTTPEndpoint?: () => Promise; + 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; + saveHASource?: () => Promise; + editHASource?: (id: string) => Promise; + cloneHASource?: (id: string) => Promise; + deleteHASource?: (id: string) => Promise; + testHASource?: () => Promise; + } +}