diff --git a/server/src/ledgrab/static/css/modal.css b/server/src/ledgrab/static/css/modal.css index 805c799..8ca6cd2 100644 --- a/server/src/ledgrab/static/css/modal.css +++ b/server/src/ledgrab/static/css/modal.css @@ -5982,3 +5982,312 @@ body.composite-layer-dragging .composite-layer-drag-handle { .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; +} + diff --git a/server/src/ledgrab/static/js/core/icon-select.ts b/server/src/ledgrab/static/js/core/icon-select.ts index 46ba7a1..2419b5c 100644 --- a/server/src/ledgrab/static/js/core/icon-select.ts +++ b/server/src/ledgrab/static/js/core/icon-select.ts @@ -19,17 +19,42 @@ */ import { desktopFocus } from './ui.ts'; +import { escapeHtml } from './api.ts'; 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() { - document.querySelectorAll(`.${POPUP_CLASS}`).forEach(p => { - (p as HTMLElement).classList.remove('open'); - }); +/** + * Escape a value for use inside a double-quoted HTML attribute. + * `escapeHtml` (text-content escape) does not escape `"`, which leaves a + * 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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } -// Global click-away listener (registered once) +/** All registered IconSelect instances; lets `closeAllIconSelects` reach scroll-listener state. */ +const _registry: Set = 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; function _ensureGlobalListener() { if (_globalListenerAdded) return; @@ -64,6 +89,79 @@ export interface IconSelectOpts { 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 { _select: HTMLSelectElement; _items: IconSelectItem[]; @@ -77,6 +175,7 @@ export class IconSelect { _searchInput: HTMLInputElement | null = null; _scrollHandler: (() => void) | null = null; _scrollTargets: (HTMLElement | Window)[] = []; + _focusedIndex: number = -1; constructor({ target, items, onChange, columns = 2, placeholder = '', searchable = false, searchPlaceholder = 'Filter…' }: IconSelectOpts) { _ensureGlobalListener(); @@ -109,6 +208,8 @@ export class IconSelect { this._trigger = document.createElement('button'); this._trigger.type = 'button'; this._trigger.className = 'icon-select-trigger'; + this._trigger.setAttribute('aria-haspopup', 'listbox'); + this._trigger.setAttribute('aria-expanded', 'false'); this._trigger.addEventListener('click', (e) => { e.stopPropagation(); this._toggle(); @@ -118,7 +219,10 @@ export class IconSelect { // Build popup (portaled to body to avoid overflow clipping) 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._handleKeydown(e)); this._popup.innerHTML = this._buildGrid(); document.body.appendChild(this._popup); @@ -126,15 +230,17 @@ export class IconSelect { // Sync to current select value this._syncTrigger(); + + _registry.add(this); } _bindPopupEvents() { // 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', () => { this.setValue((cell as HTMLElement).dataset.value!, true); - this._popup.classList.remove('open'); - this._removeScrollListener(); + this._closeIfOpen(); }); }); @@ -143,26 +249,68 @@ export class IconSelect { if (this._searchInput) { this._searchInput.addEventListener('input', () => { 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; 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(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() { + // 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 search = (item.label + ' ' + (item.desc || '')).toLowerCase(); - return `
+ return `
${item.icon} - ${item.label} - ${item.desc ? `${item.desc}` : ''} + ${escapeHtml(item.label)} + ${item.desc ? `${escapeHtml(item.desc)}` : ''}
`; }).join(''); const searchHTML = this._searchable - ? `` + ? `` : ''; 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; + } +}