Frontend improvements: CSS foundations, accessibility, UX enhancements
CSS: Add design token variables (spacing, timing, weights, z-index layers), migrate all hardcoded z-index to named vars, fix light theme contrast for WCAG AA, add skeleton loading cards, mask-composite fallback, card padding. Accessibility: aria-live on toast, aria-label on health dots, sr-only class, graph container keyboard focusable, MQTT password wrapped in form element. UX: Modal auto-focus on open, inline field validation with blur, undo toast with countdown, bulk action progress indicator, API error toast on failure. i18n: Add common.undo, validation.required, bulk.processing, api.error.* keys in EN/RU/ZH. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,8 +18,9 @@ import { initTabIndicator, updateTabIndicator } from './core/tab-indicator.ts';
|
||||
// Layer 2: ui
|
||||
import {
|
||||
toggleHint, lockBody, unlockBody, closeLightbox,
|
||||
showToast, showConfirm, closeConfirmModal,
|
||||
showToast, showUndoToast, showConfirm, closeConfirmModal,
|
||||
openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner,
|
||||
setFieldError, clearFieldError, setupBlurValidation,
|
||||
} from './core/ui.ts';
|
||||
|
||||
// Layer 3: displays, tutorials
|
||||
@@ -86,7 +87,7 @@ import {
|
||||
clonePatternTemplate,
|
||||
} from './features/pattern-templates.ts';
|
||||
import {
|
||||
loadAutomations, openAutomationEditor, closeAutomationEditorModal,
|
||||
loadAutomations, switchAutomationTab, openAutomationEditor, closeAutomationEditorModal,
|
||||
saveAutomationEditor, addAutomationCondition,
|
||||
toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl,
|
||||
} from './features/automations.ts';
|
||||
@@ -208,11 +209,15 @@ Object.assign(window, {
|
||||
unlockBody,
|
||||
closeLightbox,
|
||||
showToast,
|
||||
showUndoToast,
|
||||
showConfirm,
|
||||
closeConfirmModal,
|
||||
openFullImageLightbox,
|
||||
showOverlaySpinner,
|
||||
hideOverlaySpinner,
|
||||
setFieldError,
|
||||
clearFieldError,
|
||||
setupBlurValidation,
|
||||
|
||||
// core / api + i18n
|
||||
t,
|
||||
@@ -365,6 +370,7 @@ Object.assign(window, {
|
||||
|
||||
// automations
|
||||
loadAutomations,
|
||||
switchAutomationTab,
|
||||
openAutomationEditor,
|
||||
closeAutomationEditorModal,
|
||||
saveAutomationEditor,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
|
||||
import { t } from './i18n.ts';
|
||||
import { showToast } from './ui.ts';
|
||||
|
||||
export const API_BASE = '/api/v1';
|
||||
|
||||
@@ -68,6 +69,11 @@ export async function fetchWithAuth(url: string, options: FetchAuthOpts = {}): P
|
||||
await new Promise(r => setTimeout(r, 500 * 2 ** attempt));
|
||||
continue;
|
||||
}
|
||||
// Final attempt failed — show user-facing error
|
||||
const errMsg = (err as Error)?.name === 'AbortError'
|
||||
? t('api.error.timeout')
|
||||
: t('api.error.network');
|
||||
showToast(errMsg, 'error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,11 +106,23 @@ async function _executeAction(actionKey) {
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
// Show progress state on toolbar
|
||||
const el = _toolbarEl;
|
||||
const actionBtns = el?.querySelectorAll('.bulk-action-btn') as NodeListOf<HTMLButtonElement>;
|
||||
actionBtns?.forEach(btn => { btn.disabled = true; });
|
||||
const countEl = el?.querySelector('.bulk-count');
|
||||
const prevCount = countEl?.textContent || '';
|
||||
if (countEl) countEl.textContent = t('bulk.processing') || 'Processing…';
|
||||
|
||||
try {
|
||||
await action.handler(keys);
|
||||
} catch (e) {
|
||||
console.error(`Bulk action "${actionKey}" failed:`, e);
|
||||
}
|
||||
|
||||
// Restore toolbar state (in case exit doesn't happen)
|
||||
actionBtns?.forEach(btn => { btn.disabled = false; });
|
||||
if (countEl) countEl.textContent = prevCount;
|
||||
|
||||
section.exitSelectionMode();
|
||||
}
|
||||
|
||||
@@ -62,6 +62,24 @@ function _getCollapsedMap(): Record<string, boolean> {
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
/** Generate skeleton placeholder cards for loading state. */
|
||||
export function renderSkeletonCards(count = 3, gridClass = 'devices-grid') {
|
||||
let html = '';
|
||||
for (let i = 0; i < count; i++) {
|
||||
const delay = `animation-delay: ${i * 0.15}s`;
|
||||
html += `<div class="skeleton-card">
|
||||
<div class="skeleton-line skeleton-line-title" style="${delay}"></div>
|
||||
<div class="skeleton-line skeleton-line-medium" style="${delay}"></div>
|
||||
<div class="skeleton-line skeleton-line-short" style="${delay}"></div>
|
||||
<div class="skeleton-actions">
|
||||
<div class="skeleton-btn" style="${delay}"></div>
|
||||
<div class="skeleton-btn" style="${delay}"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="${gridClass}">${html}</div>`;
|
||||
}
|
||||
|
||||
export class CardSection {
|
||||
|
||||
sectionKey: string;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { t } from './i18n.ts';
|
||||
import { lockBody, unlockBody, setupBackdropClose, showConfirm, trapFocus, releaseFocus } from './ui.ts';
|
||||
import { lockBody, unlockBody, setupBackdropClose, showConfirm, trapFocus, releaseFocus, isTouchDevice } from './ui.ts';
|
||||
|
||||
export class Modal {
|
||||
static _stack: Modal[] = [];
|
||||
@@ -40,6 +40,15 @@ export class Modal {
|
||||
trapFocus(this.el!);
|
||||
Modal._stack = Modal._stack.filter(m => m !== this);
|
||||
Modal._stack.push(this);
|
||||
// Auto-focus first visible input (skip on touch to avoid virtual keyboard)
|
||||
if (!isTouchDevice()) {
|
||||
requestAnimationFrame(() => {
|
||||
const input = this.el!.querySelector(
|
||||
'.modal-body input:not([type="hidden"]):not([disabled]):not([style*="display:none"]):not([style*="display: none"]), .modal-body select:not([disabled]), .modal-body textarea:not([disabled])'
|
||||
) as HTMLElement | null;
|
||||
if (input && input.offsetParent !== null) input.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
forceClose() {
|
||||
|
||||
@@ -135,6 +135,43 @@ export function showToast(message: string, type = 'info') {
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
let _undoTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* Show a toast with an Undo button. Executes `action` after `delay` ms
|
||||
* unless the user clicks Undo (which calls `undoFn`).
|
||||
*/
|
||||
export function showUndoToast(message: string, action: () => void, undoFn: () => void, delay = 5000) {
|
||||
if (_undoTimer) { clearTimeout(_undoTimer); _undoTimer = null; }
|
||||
|
||||
const toast = document.getElementById('toast')!;
|
||||
toast.className = 'toast info show';
|
||||
toast.style.setProperty('--toast-duration', `${delay}ms`);
|
||||
toast.innerHTML = `<div class="toast-with-action">
|
||||
<span class="toast-message">${message}</span>
|
||||
<button class="toast-undo-btn">${t('common.undo')}</button>
|
||||
</div>
|
||||
<div class="toast-timer"></div>`;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const undoBtn = toast.querySelector('.toast-undo-btn')!;
|
||||
undoBtn.addEventListener('click', () => {
|
||||
cancelled = true;
|
||||
if (_undoTimer) { clearTimeout(_undoTimer); _undoTimer = null; }
|
||||
undoFn();
|
||||
toast.className = 'toast';
|
||||
toast.innerHTML = '';
|
||||
}, { once: true });
|
||||
|
||||
_undoTimer = setTimeout(() => {
|
||||
_undoTimer = null;
|
||||
if (!cancelled) action();
|
||||
toast.className = 'toast';
|
||||
toast.innerHTML = '';
|
||||
}, delay);
|
||||
}
|
||||
|
||||
export function showConfirm(message: string, title: string | null = null) {
|
||||
return new Promise((resolve) => {
|
||||
setConfirmResolve(resolve);
|
||||
@@ -328,6 +365,49 @@ export function updateOverlayPreview(thumbnailSrc: string, stats: any) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inline field validation ──
|
||||
|
||||
/** Mark a field as invalid with an error message. */
|
||||
export function setFieldError(input: HTMLInputElement | HTMLSelectElement, message: string) {
|
||||
input.classList.add('field-invalid');
|
||||
clearFieldError(input); // remove existing
|
||||
if (message) {
|
||||
const msg = document.createElement('span');
|
||||
msg.className = 'field-error-msg';
|
||||
msg.textContent = message;
|
||||
input.parentElement?.appendChild(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear field error state. */
|
||||
export function clearFieldError(input: HTMLInputElement | HTMLSelectElement) {
|
||||
input.classList.remove('field-invalid');
|
||||
const existing = input.parentElement?.querySelector('.field-error-msg');
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
|
||||
/** Validate a required field on blur. Returns true if valid. */
|
||||
export function validateRequired(input: HTMLInputElement | HTMLSelectElement, errorMsg?: string): boolean {
|
||||
const value = input.value.trim();
|
||||
if (!value) {
|
||||
setFieldError(input, errorMsg || t('validation.required'));
|
||||
return false;
|
||||
}
|
||||
clearFieldError(input);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Set up blur validation on required fields within a container. */
|
||||
export function setupBlurValidation(container: HTMLElement) {
|
||||
const fields = container.querySelectorAll('input[required], select[required]') as NodeListOf<HTMLInputElement | HTMLSelectElement>;
|
||||
fields.forEach(field => {
|
||||
field.addEventListener('blur', () => validateRequired(field));
|
||||
field.addEventListener('input', () => {
|
||||
if (field.classList.contains('field-invalid')) clearFieldError(field);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle the thin loading bar on a tab panel during data refresh.
|
||||
* Delays showing the bar by 400ms so quick loads never flash it. */
|
||||
const _refreshTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
||||
|
||||
@@ -587,7 +587,8 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
|
||||
let healthDot = '';
|
||||
if (isLed && state.device_last_checked != null) {
|
||||
const cls = state.device_online ? 'health-online' : 'health-offline';
|
||||
healthDot = `<span class="health-dot ${cls}"></span>`;
|
||||
const statusLabel = state.device_online ? t('device.health.online') : t('device.health.offline');
|
||||
healthDot = `<span class="health-dot ${cls}" role="status" aria-label="${statusLabel}"></span>`;
|
||||
}
|
||||
|
||||
const cStyle = cardColorStyle(target.id);
|
||||
|
||||
@@ -156,7 +156,7 @@ export function createDeviceCard(device: Device & { state?: any }) {
|
||||
content: `
|
||||
<div class="card-header">
|
||||
<div class="card-title" title="${escapeHtml(device.name || device.id)}">
|
||||
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
||||
<span class="health-dot ${healthClass}" title="${healthTitle}" role="status" aria-label="${healthTitle}"></span>
|
||||
<span class="card-title-text">${device.name || device.id}</span>
|
||||
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
|
||||
${healthLabel}
|
||||
|
||||
@@ -914,7 +914,7 @@ function _graphHTML(): string {
|
||||
// Only set size from saved state; position is applied in _initMinimap via anchor logic
|
||||
const mmStyle = mmRect?.width ? `width:${mmRect.width}px;height:${mmRect.height}px;` : '';
|
||||
return `
|
||||
<div class="graph-container">
|
||||
<div class="graph-container" tabindex="0" role="application" aria-label="${t('graph.title')}">
|
||||
<div class="graph-toolbar">
|
||||
<span class="graph-toolbar-drag" title="Drag to move">⠿</span>
|
||||
<button class="btn-icon" onclick="graphFitAll()" title="${t('graph.fit_all')}">
|
||||
|
||||
@@ -715,12 +715,10 @@ export async function loadTargetsTab() {
|
||||
changedTargetIds = new Set([...ledResult.added, ...ledResult.replaced, ...ledResult.removed,
|
||||
...kcResult.added, ...kcResult.replaced, ...kcResult.removed]);
|
||||
|
||||
// Re-render cached LED preview frames onto new canvas elements after reconciliation
|
||||
// Restore LED preview state on replaced cards (panel hidden by default in HTML)
|
||||
for (const id of Array.from(ledResult.replaced) as any[]) {
|
||||
const frame = _ledPreviewLastFrame[id];
|
||||
if (frame && ledPreviewWebSockets[id]) {
|
||||
const canvas = document.getElementById(`led-preview-canvas-${id}`);
|
||||
if (canvas) _renderLedStrip(canvas, frame);
|
||||
if (ledPreviewWebSockets[id]) {
|
||||
_restoreLedPreviewState(id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1009,7 +1007,7 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
|
||||
content: `
|
||||
<div class="card-header">
|
||||
<div class="card-title" title="${escapeHtml(target.name)}">
|
||||
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
||||
<span class="health-dot ${healthClass}" title="${healthTitle}" role="status" aria-label="${healthTitle}"></span>
|
||||
${escapeHtml(target.name)}
|
||||
<span class="target-error-indicator" title="${t('device.metrics.errors')}">${ICON_WARNING}</span>
|
||||
</div>
|
||||
@@ -1075,7 +1073,7 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
|
||||
</button>
|
||||
`}
|
||||
${isProcessing ? `
|
||||
<button class="btn btn-icon ${ledPreviewWebSockets[target.id] ? 'btn-warning' : 'btn-secondary'}" onclick="toggleLedPreview('${target.id}')" title="LED Preview">
|
||||
<button class="btn btn-icon btn-secondary" data-led-preview-btn="${target.id}" onclick="toggleLedPreview('${target.id}')" title="LED Preview">
|
||||
${ICON_LED_PREVIEW}
|
||||
</button>
|
||||
` : ''}
|
||||
@@ -1234,7 +1232,9 @@ const _ledPreviewLastFrame = {};
|
||||
* one canvas per zone with labels. Otherwise, a single canvas.
|
||||
*/
|
||||
function _buildLedPreviewHtml(targetId: any, device: any, bvsId: any, cssSource: any, colorStripSourceMap: any) {
|
||||
const visible = ledPreviewWebSockets[targetId] ? '' : 'none';
|
||||
// Always render hidden — JS toggles visibility. This keeps card HTML stable
|
||||
// so reconciliation doesn't replace the card when preview is toggled.
|
||||
const visible = 'none';
|
||||
const bvsAttr = bvsId ? ' data-has-bvs="1"' : '';
|
||||
|
||||
// Check for per-zone preview
|
||||
@@ -1356,7 +1356,13 @@ function _renderLedStrip(canvas: any, rgbBytes: any) {
|
||||
}
|
||||
|
||||
function connectLedPreviewWS(targetId: any) {
|
||||
disconnectLedPreviewWS(targetId);
|
||||
// Close existing WS without touching DOM (caller manages panel/button state)
|
||||
const oldWs = ledPreviewWebSockets[targetId];
|
||||
if (oldWs) {
|
||||
oldWs.onclose = null;
|
||||
oldWs.close();
|
||||
delete ledPreviewWebSockets[targetId];
|
||||
}
|
||||
|
||||
const key = localStorage.getItem('wled_api_key');
|
||||
if (!key) return;
|
||||
@@ -1440,6 +1446,27 @@ function connectLedPreviewWS(targetId: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function _setPreviewButtonState(targetId: any, active: boolean) {
|
||||
const btn = document.querySelector(`[data-led-preview-btn="${targetId}"]`);
|
||||
if (btn) {
|
||||
btn.classList.toggle('btn-warning', active);
|
||||
btn.classList.toggle('btn-secondary', !active);
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore preview panel visibility, button state, and last frame after card replacement. */
|
||||
function _restoreLedPreviewState(targetId: any) {
|
||||
const panel = document.getElementById(`led-preview-panel-${targetId}`);
|
||||
if (panel) panel.style.display = '';
|
||||
_setPreviewButtonState(targetId, true);
|
||||
// Re-render cached frame onto the new canvas
|
||||
const frame = _ledPreviewLastFrame[targetId];
|
||||
if (frame) {
|
||||
const canvas = panel?.querySelector('.led-preview-canvas');
|
||||
if (canvas) _renderLedStrip(canvas, frame);
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectLedPreviewWS(targetId: any) {
|
||||
const ws = ledPreviewWebSockets[targetId];
|
||||
if (ws) {
|
||||
@@ -1450,6 +1477,7 @@ function disconnectLedPreviewWS(targetId: any) {
|
||||
delete _ledPreviewLastFrame[targetId];
|
||||
const panel = document.getElementById(`led-preview-panel-${targetId}`);
|
||||
if (panel) panel.style.display = 'none';
|
||||
_setPreviewButtonState(targetId, false);
|
||||
}
|
||||
|
||||
export function disconnectAllLedPreviewWS() {
|
||||
@@ -1464,6 +1492,7 @@ export function toggleLedPreview(targetId: any) {
|
||||
disconnectLedPreviewWS(targetId);
|
||||
} else {
|
||||
panel.style.display = '';
|
||||
_setPreviewButtonState(targetId, true);
|
||||
connectLedPreviewWS(targetId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user