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:
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.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<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">▾</span>`;
|
`<span class="icon-select-trigger-arrow">▾</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">▾</span>`;
|
`<span class="icon-select-trigger-arrow">▾</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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">▾</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
@@ -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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user