mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 06:45:09 +00:00
fix(web): harden, polish and optimize the web UI per the Sept 2026 audit (#568)
* fix(web): harden, polish and optimize the web UI per the September 2026 audit Works through docs/archive/WEB_UI_AUDIT_2026-09.md (health 8/20). Implementation integrity (P0) - app.css now defines every utility class the templates and JS use, including .hidden, so the ~145 JS show/hide toggles work. Button reset, and base component rules (.btn, .form-control) wrapped in :where() so utility classes on the same element win. New static-audit test fails when a used utility class has no rule. Accessibility - Focus rings render (the old ring rule referenced undefined variables); one :focus-visible outline everywhere; skip link; labelled nav landmarks. - Shared dialog helper (js/utils/dialog.js): role/aria-modal, focus trap, Escape, focus return, applied to every modal. - Named icon-only buttons and labelled ~70 form fields. - Toasts announced once; errors persist >= 10s; one showNotification. - Captive WiFi page: live region, timeouts, dark mode, 16px inputs. Performance (Pi Zero 2 W) - SSE streams and tab timers pause when hidden or off-tab; the display stream only runs while a preview is visible. app-shell.js deferred. - Widget scripts served as one versioned bundle (/assets/widgets.js): 52 -> 21 script tags, 66 -> 35 requests on first load. - Stdlib gzip fallback when flask-compress is missing: first-load JS/CSS 1358 KB -> 291 KB on the wire. SSE untouched. Theming and responsive - File managers, form fields and Fonts upload on theme tokens; bare inputs themed in dark mode; no more white surfaces. - No horizontal overflow at 375px on any tab; 44px touch targets on coarse pointers; reduced-motion respected; header title truncates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): clear Codacy findings on #568 - json-file-manager: focus-trap releases kept in a Map (no dynamic property access or delete; no value-returning forEach callback) - notification / schedule-picker: style and day-label lookups via Map - app.js: move the pending-queue assignment out of the expression - diff_viewer / error_handler: named function declarations instead of arrow consts No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: check the OAuth widget ships in the widget bundle base.html no longer tags widget scripts one by one; they load through /assets/widgets.js. Assert the page requests the bundle and the bundle contains google-oauth.js, which is what the test was protecting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): address review feedback on #568 - widget bundle version fingerprints every file (name, mtime_ns, size) - gzip fallback appends Accept-Encoding to an existing Vary header - dialog helper: releasing a non-top dialog no longer moves focus out of the dialog the user is in - labels: file-upload targets its file input; fallback config fields get label for/id pairs; native color input has a fallback name - utility audit also reads class names inside bound :class expressions Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): give the native color-picker input an accessible name CodeRabbit flagged this on PR #568 as an outside-diff finding (never posted inline, so it was missed in the round of fixes that addressed the other 6 review comments). The <input type="color"> only carried a title attribute; screen readers don't reliably announce title, and there's no other label naming the control when showHexInput is false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): clear Codacy findings in app-shell.js - drop the unused catch binding on the SSE JSON parse - move the pending-notification queue assignment out of the expression No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): contain plugin widgets/ dir and bound style-editor retries From CodeRabbit review on #568 (code that arrived with the main merge): - serve_plugin_widget resolves widgets/ with resolve_under before resolving the manifest script under it, so a symlinked widgets directory can't become the containment base (CWE-22). New test. - style-editor init stops polling after ~10s when the widget never registers and leaves the plain fallback fields in place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -415,7 +415,8 @@
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
||||
removeBtn.onclick = function() { window.removeArrayTableRow(this); };
|
||||
removeBtn.innerHTML = '<i class="fas fa-trash"></i>';
|
||||
removeBtn.setAttribute('aria-label', 'Remove row');
|
||||
removeBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>';
|
||||
actionsCell.appendChild(removeBtn);
|
||||
|
||||
if (hasAdvanced) {
|
||||
@@ -424,7 +425,8 @@
|
||||
editBtn.className = 'text-blue-500 hover:text-blue-700 px-2 py-1 ml-1';
|
||||
editBtn.title = 'Edit advanced properties (layout, style…)';
|
||||
editBtn.onclick = function() { window.openArrayTableRowEditor(this); };
|
||||
editBtn.innerHTML = '<i class="fas fa-sliders-h"></i>';
|
||||
editBtn.setAttribute('aria-label', 'Edit advanced properties');
|
||||
editBtn.innerHTML = '<i class="fas fa-sliders-h" aria-hidden="true"></i>';
|
||||
actionsCell.appendChild(editBtn);
|
||||
}
|
||||
|
||||
@@ -446,9 +448,8 @@
|
||||
if (!advancedCell) return;
|
||||
|
||||
const schema = JSON.parse(advancedCell.dataset.propSchema || '{}');
|
||||
// Close any existing modal
|
||||
const existing = document.getElementById('array-row-editor-modal');
|
||||
if (existing) existing.remove();
|
||||
// Close any existing modal (also releases its focus trap)
|
||||
window.closeArrayTableRowEditor();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'array-row-editor-modal';
|
||||
@@ -463,9 +464,10 @@
|
||||
// Header
|
||||
safeSetHTML(dialog, `
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200">
|
||||
<h3 class="text-base font-semibold text-gray-900">Advanced Properties</h3>
|
||||
<h3 id="array-row-editor-title" class="text-base font-semibold text-gray-900">Advanced Properties</h3>
|
||||
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
||||
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||
aria-label="Close advanced properties"
|
||||
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times" aria-hidden="true"></i></button>
|
||||
</div>`);
|
||||
|
||||
const body = document.createElement('div');
|
||||
@@ -558,11 +560,27 @@
|
||||
dialog.appendChild(footer);
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
if (window.LEDDialog) {
|
||||
rowEditorRelease = window.LEDDialog.trap(dialog, {
|
||||
labelledBy: 'array-row-editor-title',
|
||||
initialFocus: '[data-modal-prop]',
|
||||
onEscape: window.closeArrayTableRowEditor
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Focus-trap release for the open row editor (see utils/dialog.js).
|
||||
let rowEditorRelease = null;
|
||||
|
||||
window.closeArrayTableRowEditor = function() {
|
||||
const modal = document.getElementById('array-row-editor-modal');
|
||||
if (modal) modal.remove();
|
||||
if (rowEditorRelease) {
|
||||
const release = rowEditorRelease;
|
||||
rowEditorRelease = null;
|
||||
release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -107,6 +107,8 @@
|
||||
html += `
|
||||
<input type="color"
|
||||
id="${fieldId}_color"
|
||||
title="Choose color"
|
||||
aria-label="Choose color"
|
||||
value="${currentValue}"
|
||||
${disabled ? 'disabled' : ''}
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('color-picker').onColorChange('${fieldId}')"
|
||||
@@ -122,6 +124,7 @@
|
||||
id="${fieldId}_hex"
|
||||
value="${currentValue.substring(1)}"
|
||||
maxlength="6"
|
||||
aria-label="Hex color code"
|
||||
${disabled ? 'disabled' : ''}
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('color-picker').onHexChange('${fieldId}')"
|
||||
oninput="window.LEDMatrixWidgets.getHandlers('color-picker').onHexInput('${fieldId}')"
|
||||
@@ -161,7 +164,8 @@
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('color-picker').onPresetClick('${fieldId}', this.dataset.color)"
|
||||
class="w-6 h-6 rounded border border-gray-300 hover:scale-110 transition-transform ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
|
||||
style="background-color: ${escapeHtml(normalized)};"
|
||||
title="${escapeHtml(normalized)}">
|
||||
title="${escapeHtml(normalized)}"
|
||||
aria-label="Use color ${escapeHtml(normalized)}">
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('date-picker').onChange('${fieldId}')"
|
||||
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black pr-10">
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<i class="fas fa-calendar-alt text-gray-400"></i>
|
||||
<i class="fas fa-calendar-alt text-gray-400" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -96,8 +96,8 @@
|
||||
id="${fieldId}_clear"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('date-picker').onClear('${fieldId}')"
|
||||
class="ml-2 inline-flex items-center px-2 py-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
|
||||
title="Clear">
|
||||
<i class="fas fa-times"></i>
|
||||
title="Clear" aria-label="Clear date">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -102,8 +102,8 @@
|
||||
</div>`;
|
||||
html += `<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('file-upload-single').onClear('${fieldId}')"
|
||||
class="flex-shrink-0 text-red-400 hover:text-red-600 p-1" title="Remove image">
|
||||
<i class="fas fa-times"></i>
|
||||
class="flex-shrink-0 text-red-400 hover:text-red-600 p-1" title="Remove image" aria-label="Remove image">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>`;
|
||||
html += '</div>';
|
||||
|
||||
|
||||
@@ -840,7 +840,7 @@
|
||||
<!-- Schedule Mode -->
|
||||
<div id="schedule_options_${sanitizedId}" class="space-y-4" style="display: ${schedule.enabled ? 'block' : 'none'};">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
|
||||
<label for="schedule_mode_${sanitizedId}" class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
|
||||
<select id="schedule_mode_${sanitizedId}"
|
||||
data-field-id="${escapeHtml(fieldId)}"
|
||||
data-image-id="${sanitizedId}"
|
||||
@@ -855,8 +855,8 @@
|
||||
<!-- Time Range Mode -->
|
||||
<div id="time_range_${sanitizedId}" class="grid grid-cols-2 gap-4" style="display: ${schedule.mode === 'time_range' ? 'grid' : 'none'};">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
|
||||
<input type="time"
|
||||
<label for="schedule_start_${sanitizedId}" class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
|
||||
<input type="time"
|
||||
id="schedule_start_${sanitizedId}"
|
||||
data-field-id="${escapeHtml(fieldId)}"
|
||||
data-image-id="${sanitizedId}"
|
||||
@@ -865,8 +865,8 @@
|
||||
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
|
||||
<input type="time"
|
||||
<label for="schedule_end_${sanitizedId}" class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
|
||||
<input type="time"
|
||||
id="schedule_end_${sanitizedId}"
|
||||
data-field-id="${escapeHtml(fieldId)}"
|
||||
data-image-id="${sanitizedId}"
|
||||
@@ -900,6 +900,7 @@
|
||||
<div class="grid grid-cols-2 gap-2 ml-5" id="day_times_${day}_${sanitizedId}" style="display: ${dayConfig.enabled ? 'grid' : 'none'};">
|
||||
<input type="time"
|
||||
id="day_${day}_start_${sanitizedId}"
|
||||
aria-label="${day} start time"
|
||||
data-field-id="${escapeHtml(fieldId)}"
|
||||
data-image-id="${sanitizedId}"
|
||||
data-image-idx="${imageIdx}"
|
||||
@@ -909,6 +910,7 @@
|
||||
${!dayConfig.enabled ? 'disabled' : ''}>
|
||||
<input type="time"
|
||||
id="day_${day}_end_${sanitizedId}"
|
||||
aria-label="${day} end time"
|
||||
data-field-id="${escapeHtml(fieldId)}"
|
||||
data-image-id="${sanitizedId}"
|
||||
data-image-idx="${imageIdx}"
|
||||
|
||||
@@ -57,6 +57,8 @@
|
||||
this._editFile = null;
|
||||
this._deleteFile = null;
|
||||
this._keyHandler = this._onKey.bind(this);
|
||||
// Focus-trap release functions for open modals (utils/dialog.js)
|
||||
this._releases = new Map();
|
||||
|
||||
this._inject();
|
||||
this._bind();
|
||||
@@ -67,9 +69,30 @@
|
||||
|
||||
_destroy() {
|
||||
document.removeEventListener('keydown', this._keyHandler);
|
||||
for (const key of Array.from(this._releases.keys())) this._release(key);
|
||||
this.el._jfmInstance = null;
|
||||
}
|
||||
|
||||
// Open a modal's focus trap. Escape is routed here, so _onKey no
|
||||
// longer handles it.
|
||||
_trap(key, modal, titleId, initialFocus, onClose) {
|
||||
if (!modal || !window.LEDDialog) return;
|
||||
const box = modal.querySelector('.jfm-modal-box');
|
||||
this._releases.set(key, window.LEDDialog.trap(box, {
|
||||
labelledBy: titleId,
|
||||
initialFocus: initialFocus || null,
|
||||
onEscape: onClose
|
||||
}));
|
||||
}
|
||||
|
||||
_release(key) {
|
||||
const release = this._releases.get(key);
|
||||
if (release) {
|
||||
this._releases.delete(key);
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
// ── DOM Injection ────────────────────────────────────────────────────
|
||||
|
||||
_inject() {
|
||||
@@ -108,21 +131,22 @@
|
||||
</div>` : ''}
|
||||
|
||||
<!-- ── Edit modal ─────────────────────────────────────── -->
|
||||
<div class="jfm-modal" id="${u}-edit-modal" role="dialog" aria-modal="true" hidden>
|
||||
<div class="jfm-modal" id="${u}-edit-modal" hidden>
|
||||
<div class="jfm-modal-box jfm-modal-wide">
|
||||
<div class="jfm-modal-head">
|
||||
<span id="${u}-edit-title" class="jfm-modal-title">Edit file</span>
|
||||
<div class="jfm-modal-tools">
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="fmt">Format</button>
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="validate">Validate</button>
|
||||
<button type="button" class="jfm-close-btn" data-jfm="close-edit" aria-label="Close">×</button>
|
||||
<button type="button" class="jfm-close-btn" data-jfm="close-edit" aria-label="Close editor">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="${u}-edit-err" class="jfm-err-bar" hidden></div>
|
||||
<div id="${u}-edit-err" class="jfm-err-bar" role="alert" hidden></div>
|
||||
<textarea id="${u}-editor" class="jfm-editor"
|
||||
spellcheck="false" autocomplete="off"
|
||||
autocorrect="off" autocapitalize="off"
|
||||
aria-label="JSON editor"></textarea>
|
||||
aria-label="JSON editor" aria-describedby="${u}-editor-hint"></textarea>
|
||||
<span id="${u}-editor-hint" hidden>Tab inserts two spaces. Shift+Tab moves focus back to the toolbar. Ctrl+S saves.</span>
|
||||
<div class="jfm-modal-foot">
|
||||
<span id="${u}-charcount" class="jfm-stat"></span>
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-edit">Cancel</button>
|
||||
@@ -133,10 +157,10 @@
|
||||
|
||||
<!-- ── Delete modal ───────────────────────────────────── -->
|
||||
${hasDelete ? `
|
||||
<div class="jfm-modal" id="${u}-del-modal" role="dialog" aria-modal="true" hidden>
|
||||
<div class="jfm-modal" id="${u}-del-modal" hidden>
|
||||
<div class="jfm-modal-box">
|
||||
<div class="jfm-modal-head">
|
||||
<span class="jfm-modal-title">Delete file</span>
|
||||
<span id="${u}-del-title" class="jfm-modal-title">Delete file</span>
|
||||
<button type="button" class="jfm-close-btn" data-jfm="close-del" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="jfm-modal-body">
|
||||
@@ -144,7 +168,7 @@
|
||||
<p class="jfm-muted">This permanently removes the file and its entry from the plugin configuration.</p>
|
||||
</div>
|
||||
<div class="jfm-modal-foot">
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-del">Cancel</button>
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-del" id="${u}-del-cancel">Cancel</button>
|
||||
<button type="button" class="jfm-btn jfm-btn-danger" data-jfm="confirm-del" id="${u}-del-btn">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,10 +176,10 @@
|
||||
|
||||
<!-- ── Create modal ───────────────────────────────────── -->
|
||||
${hasCreate ? `
|
||||
<div class="jfm-modal" id="${u}-create-modal" role="dialog" aria-modal="true" hidden>
|
||||
<div class="jfm-modal" id="${u}-create-modal" hidden>
|
||||
<div class="jfm-modal-box">
|
||||
<div class="jfm-modal-head">
|
||||
<span class="jfm-modal-title">Create new file</span>
|
||||
<span id="${u}-create-title" class="jfm-modal-title">Create new file</span>
|
||||
<button type="button" class="jfm-close-btn" data-jfm="close-create" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="jfm-modal-body">
|
||||
@@ -189,93 +213,106 @@
|
||||
}
|
||||
|
||||
_css(u) {
|
||||
// Colors come from the app theme tokens (app.css :root / [data-theme="dark"])
|
||||
// with the original light values as fallbacks for pages without app.css.
|
||||
// Shades the tokens don't cover are local variables with a dark override.
|
||||
return `<style>
|
||||
#${u}{font-family:inherit;color:#111827;}
|
||||
#${u}{--jfm-border-strong:#d1d5db;--jfm-border-hover:#9ca3af;--jfm-subtle:#f3f4f6;--jfm-accent-soft:#93c5fd;--jfm-danger-border:#fecaca;--jfm-danger-text:#991b1b;--jfm-text-muted:#6b7280;}
|
||||
[data-theme="dark"] #${u}{--jfm-border-strong:#4b5563;--jfm-border-hover:#6b7280;--jfm-subtle:#374151;--jfm-accent-soft:#1e40af;--jfm-danger-border:#991b1b;--jfm-danger-text:#fecaca;--jfm-text-muted:#9ca3af;}
|
||||
#${u}{font-family:inherit;color:var(--color-text-primary,#111827);}
|
||||
#${u} *{box-sizing:border-box;}
|
||||
|
||||
/* Header */
|
||||
#${u} .jfm-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.875rem;gap:.5rem;}
|
||||
#${u} .jfm-header-left{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;}
|
||||
#${u} .jfm-title{font-size:.9375rem;font-weight:600;color:#111827;}
|
||||
#${u} .jfm-dir{font-size:.75rem;color:#6b7280;background:#f3f4f6;padding:.125rem .375rem;border-radius:.25rem;font-family:monospace;}
|
||||
#${u} .jfm-title{font-size:.9375rem;font-weight:600;color:var(--color-text-primary,#111827);}
|
||||
#${u} .jfm-dir{font-size:.75rem;color:var(--jfm-text-muted);background:var(--jfm-subtle);padding:.125rem .375rem;border-radius:.25rem;font-family:monospace;}
|
||||
#${u} .jfm-header-right{display:flex;gap:.375rem;align-items:center;flex-shrink:0;}
|
||||
|
||||
/* Buttons */
|
||||
#${u} .jfm-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.4375rem .875rem;border-radius:.375rem;border:1px solid #d1d5db;background:#fff;color:#374151;font-size:.875rem;font-weight:500;cursor:pointer;transition:background .12s,border-color .12s,opacity .12s;line-height:1.25;}
|
||||
#${u} .jfm-btn:hover:not(:disabled){background:#f9fafb;border-color:#9ca3af;}
|
||||
#${u} .jfm-btn:focus-visible{outline:2px solid #3b82f6;outline-offset:1px;}
|
||||
#${u} .jfm-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.4375rem .875rem;border-radius:.375rem;border:1px solid var(--jfm-border-strong);background:var(--color-surface,#fff);color:var(--color-text-secondary,#374151);font-size:.875rem;font-weight:500;cursor:pointer;transition:background .12s,border-color .12s,opacity .12s;line-height:1.25;}
|
||||
#${u} .jfm-btn:hover:not(:disabled){background:var(--color-background,#f9fafb);border-color:var(--jfm-border-hover);}
|
||||
#${u} .jfm-btn:focus-visible,#${u} .jfm-close-btn:focus-visible{outline:2px solid var(--color-primary,#3b82f6);outline-offset:1px;}
|
||||
#${u} .jfm-btn:disabled{opacity:.5;cursor:not-allowed;}
|
||||
#${u} .jfm-btn-sm{padding:.3125rem .625rem;font-size:.8125rem;}
|
||||
#${u} .jfm-btn-primary{background:#3b82f6;border-color:#3b82f6;color:#fff;}
|
||||
#${u} .jfm-btn-primary:hover:not(:disabled){background:#2563eb;border-color:#2563eb;}
|
||||
#${u} .jfm-btn-danger{background:#ef4444;border-color:#ef4444;color:#fff;}
|
||||
#${u} .jfm-btn-danger:hover:not(:disabled){background:#dc2626;border-color:#dc2626;}
|
||||
#${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:#6b7280;}
|
||||
#${u} .jfm-btn-ghost:hover:not(:disabled){background:#f3f4f6;color:#374151;}
|
||||
#${u} .jfm-close-btn{display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:none;background:none;color:#9ca3af;font-size:1.25rem;cursor:pointer;border-radius:.25rem;padding:0;line-height:1;}
|
||||
#${u} .jfm-close-btn:hover{background:#f3f4f6;color:#374151;}
|
||||
#${u} .jfm-btn-primary{background:var(--color-primary,#3b82f6);border-color:var(--color-primary,#3b82f6);color:#fff;}
|
||||
#${u} .jfm-btn-primary:hover:not(:disabled){background:var(--color-primary-hover,#2563eb);border-color:var(--color-primary-hover,#2563eb);}
|
||||
#${u} .jfm-btn-danger{background:#dc2626;border-color:#dc2626;color:#fff;}
|
||||
#${u} .jfm-btn-danger:hover:not(:disabled){background:#b91c1c;border-color:#b91c1c;}
|
||||
#${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:var(--jfm-text-muted);}
|
||||
#${u} .jfm-btn-ghost:hover:not(:disabled){background:var(--jfm-subtle);color:var(--color-text-secondary,#374151);}
|
||||
#${u} .jfm-close-btn{display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:none;background:none;color:var(--jfm-text-muted);font-size:1.25rem;cursor:pointer;border-radius:.25rem;padding:0;line-height:1;}
|
||||
#${u} .jfm-close-btn:hover{background:var(--jfm-subtle);color:var(--color-text-secondary,#374151);}
|
||||
|
||||
/* File list */
|
||||
#${u} .jfm-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.625rem;margin-bottom:1rem;min-height:5rem;}
|
||||
#${u} .jfm-loading{grid-column:1/-1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:#6b7280;font-size:.875rem;}
|
||||
#${u} .jfm-empty{grid-column:1/-1;text-align:center;padding:2.5rem 1rem;color:#9ca3af;}
|
||||
#${u} .jfm-loading{grid-column:1/-1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:var(--jfm-text-muted);font-size:.875rem;}
|
||||
#${u} .jfm-empty{grid-column:1/-1;text-align:center;padding:2.5rem 1rem;color:var(--jfm-text-muted);}
|
||||
#${u} .jfm-empty-icon{font-size:2.25rem;margin-bottom:.625rem;}
|
||||
#${u} .jfm-empty-title{font-weight:600;color:#374151;margin:0 0 .25rem;}
|
||||
#${u} .jfm-empty-title{font-weight:600;color:var(--color-text-secondary,#374151);margin:0 0 .25rem;}
|
||||
#${u} .jfm-empty-sub{font-size:.875rem;margin:0;}
|
||||
|
||||
/* File cards */
|
||||
#${u} .jfm-card{border:1px solid #e5e7eb;border-radius:.5rem;padding:.875rem;background:#fff;display:flex;flex-direction:column;gap:.5rem;transition:border-color .15s,box-shadow .15s;}
|
||||
#${u} .jfm-card:hover{border-color:#93c5fd;box-shadow:0 2px 8px rgba(59,130,246,.1);}
|
||||
#${u} .jfm-card{border:1px solid var(--color-border,#e5e7eb);border-radius:.5rem;padding:.875rem;background:var(--color-surface,#fff);display:flex;flex-direction:column;gap:.5rem;transition:border-color .15s,box-shadow .15s;}
|
||||
#${u} .jfm-card:hover{border-color:var(--jfm-accent-soft);box-shadow:var(--shadow-md,0 2px 8px rgba(59,130,246,.1));}
|
||||
#${u} .jfm-card.jfm-off{opacity:.6;}
|
||||
#${u} .jfm-card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;}
|
||||
#${u} .jfm-card-name{font-weight:600;font-size:.9375rem;word-break:break-word;color:#111827;flex:1;}
|
||||
#${u} .jfm-card-meta{font-size:.75rem;color:#6b7280;display:flex;flex-direction:column;gap:.125rem;line-height:1.5;}
|
||||
#${u} .jfm-card-actions{display:flex;gap:.375rem;padding-top:.5rem;border-top:1px solid #f3f4f6;margin-top:.125rem;}
|
||||
#${u} .jfm-card-name{font-weight:600;font-size:.9375rem;word-break:break-word;color:var(--color-text-primary,#111827);flex:1;}
|
||||
#${u} .jfm-card-meta{font-size:.75rem;color:var(--jfm-text-muted);display:flex;flex-direction:column;gap:.125rem;line-height:1.5;}
|
||||
#${u} .jfm-card-actions{display:flex;gap:.375rem;padding-top:.5rem;border-top:1px solid var(--color-border-light,#f3f4f6);margin-top:.125rem;}
|
||||
#${u} .jfm-card-actions .jfm-btn{flex:1;justify-content:center;}
|
||||
#${u} .jfm-card-actions .jfm-del{flex:0 0 auto;}
|
||||
|
||||
/* Toggle */
|
||||
#${u} .jfm-toggle{display:flex;align-items:center;gap:.3125rem;font-size:.75rem;color:#6b7280;white-space:nowrap;flex-shrink:0;}
|
||||
#${u} .jfm-toggle{display:flex;align-items:center;gap:.3125rem;font-size:.75rem;color:var(--jfm-text-muted);white-space:nowrap;flex-shrink:0;}
|
||||
#${u} .jfm-toggle input[type=checkbox]{width:.9375rem;height:.9375rem;cursor:pointer;accent-color:#22c55e;margin:0;}
|
||||
|
||||
/* Upload zone */
|
||||
#${u} .jfm-upload-wrap{margin-top:.25rem;}
|
||||
#${u} input[type=file]#${u}-fileinput{position:absolute;left:-9999px;width:1px;height:1px;opacity:0;}
|
||||
#${u} .jfm-dropzone{border:2px dashed #d1d5db;border-radius:.5rem;padding:1.25rem 1rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:#f9fafb;user-select:none;}
|
||||
#${u} .jfm-dropzone:hover,#${u} .jfm-dropzone:focus-visible,#${u} .jfm-dropzone.jfm-over{border-color:#3b82f6;background:#eff6ff;border-style:solid;outline:none;}
|
||||
#${u} .jfm-dropzone{border:2px dashed var(--jfm-border-strong);border-radius:.5rem;padding:1.25rem 1rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:var(--color-background,#f9fafb);user-select:none;}
|
||||
#${u} .jfm-dropzone:hover,#${u} .jfm-dropzone:focus-visible,#${u} .jfm-dropzone.jfm-over{border-color:var(--color-primary,#3b82f6);background:var(--color-info-bg,#eff6ff);border-style:solid;outline:none;}
|
||||
#${u} .jfm-drop-icon{font-size:1.75rem;display:block;margin-bottom:.375rem;}
|
||||
#${u} .jfm-drop-primary{font-size:.875rem;color:#374151;margin:0 0 .25rem;}
|
||||
#${u} .jfm-drop-hint{font-size:.75rem;color:#9ca3af;margin:0;}
|
||||
#${u} .jfm-drop-primary{font-size:.875rem;color:var(--color-text-secondary,#374151);margin:0 0 .25rem;}
|
||||
#${u} .jfm-drop-hint{font-size:.75rem;color:var(--jfm-text-muted);margin:0;}
|
||||
|
||||
/* Modals */
|
||||
#${u} .jfm-modal{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;padding:1rem;backdrop-filter:blur(1px);}
|
||||
#${u} .jfm-modal[hidden]{display:none;}
|
||||
#${u} .jfm-modal-box{background:#fff;border-radius:.5rem;box-shadow:0 20px 40px rgba(0,0,0,.15);display:flex;flex-direction:column;width:100%;max-width:440px;max-height:92vh;}
|
||||
#${u} .jfm-modal-box{background:var(--color-surface,#fff);color:var(--color-text-primary,#111827);border-radius:.5rem;box-shadow:0 20px 40px rgba(0,0,0,.15);display:flex;flex-direction:column;width:100%;max-width:440px;max-height:92vh;}
|
||||
#${u} .jfm-modal-box:focus{outline:none;}
|
||||
#${u} .jfm-modal-wide{max-width:880px;}
|
||||
#${u} .jfm-modal-head{display:flex;justify-content:space-between;align-items:center;padding:.875rem 1.125rem;border-bottom:1px solid #e5e7eb;flex-shrink:0;gap:.5rem;}
|
||||
#${u} .jfm-modal-title{font-weight:600;font-size:.9375rem;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
#${u} .jfm-modal-head{display:flex;justify-content:space-between;align-items:center;padding:.875rem 1.125rem;border-bottom:1px solid var(--color-border,#e5e7eb);flex-shrink:0;gap:.5rem;}
|
||||
#${u} .jfm-modal-title{font-weight:600;font-size:.9375rem;color:var(--color-text-primary,#111827);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
#${u} .jfm-modal-tools{display:flex;gap:.25rem;align-items:center;flex-shrink:0;}
|
||||
#${u} .jfm-modal-body{padding:1.125rem;overflow-y:auto;flex:1;}
|
||||
#${u} .jfm-modal-foot{display:flex;gap:.5rem;justify-content:flex-end;align-items:center;padding:.75rem 1.125rem;border-top:1px solid #e5e7eb;flex-shrink:0;background:#f9fafb;border-radius:0 0 .5rem .5rem;}
|
||||
#${u} .jfm-stat{margin-right:auto;font-size:.75rem;color:#9ca3af;font-variant-numeric:tabular-nums;}
|
||||
#${u} .jfm-modal-foot{display:flex;gap:.5rem;justify-content:flex-end;align-items:center;padding:.75rem 1.125rem;border-top:1px solid var(--color-border,#e5e7eb);flex-shrink:0;background:var(--color-background,#f9fafb);border-radius:0 0 .5rem .5rem;}
|
||||
#${u} .jfm-stat{margin-right:auto;font-size:.75rem;color:var(--jfm-text-muted);font-variant-numeric:tabular-nums;}
|
||||
|
||||
/* JSON editor */
|
||||
#${u} .jfm-editor{display:block;width:100%;min-height:400px;height:58vh;max-height:64vh;resize:vertical;font-family:'Courier New',Consolas,ui-monospace,monospace;font-size:.8rem;line-height:1.55;padding:.75rem 1rem;border:none;border-radius:0;outline:none;white-space:pre;overflow:auto;color:#1e293b;background:#fafafa;tab-size:2;}
|
||||
#${u} .jfm-err-bar{background:#fef2f2;border-bottom:1px solid #fecaca;color:#991b1b;font-size:.8125rem;padding:.5rem 1.125rem;flex-shrink:0;line-height:1.4;}
|
||||
#${u} .jfm-editor{display:block;width:100%;min-height:400px;height:58vh;max-height:64vh;resize:vertical;font-family:'Courier New',Consolas,ui-monospace,monospace;font-size:.8rem;line-height:1.55;padding:.75rem 1rem;border:none;border-radius:0;outline:none;white-space:pre;overflow:auto;color:var(--color-text-primary,#1e293b);background:var(--color-background,#fafafa);tab-size:2;}
|
||||
#${u} .jfm-editor:focus-visible{box-shadow:inset 0 0 0 2px var(--color-primary,#3b82f6);}
|
||||
#${u} .jfm-err-bar{background:var(--color-error-bg,#fef2f2);border-bottom:1px solid var(--jfm-danger-border);color:var(--jfm-danger-text);font-size:.8125rem;padding:.5rem 1.125rem;flex-shrink:0;line-height:1.4;}
|
||||
#${u} .jfm-err-bar[hidden]{display:none;}
|
||||
|
||||
/* Create form */
|
||||
#${u} .jfm-field{margin-bottom:.875rem;}
|
||||
#${u} .jfm-field:last-child{margin-bottom:0;}
|
||||
#${u} .jfm-field label{display:block;font-size:.875rem;font-weight:500;color:#374151;margin-bottom:.3125rem;}
|
||||
#${u} .jfm-field input{width:100%;padding:.4375rem .75rem;border:1px solid #d1d5db;border-radius:.375rem;font-size:.875rem;color:#111827;background:#fff;}
|
||||
#${u} .jfm-field input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.12);}
|
||||
#${u} .jfm-hint{display:block;font-size:.75rem;color:#9ca3af;margin-top:.25rem;}
|
||||
#${u} .jfm-muted{font-size:.875rem;color:#6b7280;margin-top:.375rem;}
|
||||
#${u} .jfm-field label{display:block;font-size:.875rem;font-weight:500;color:var(--color-text-secondary,#374151);margin-bottom:.3125rem;}
|
||||
#${u} .jfm-field input{width:100%;padding:.4375rem .75rem;border:1px solid var(--jfm-border-strong);border-radius:.375rem;font-size:.875rem;color:var(--color-text-primary,#111827);background:var(--color-surface,#fff);}
|
||||
#${u} .jfm-field input:focus{outline:none;border-color:var(--color-primary,#3b82f6);box-shadow:0 0 0 3px rgba(59,130,246,.25);}
|
||||
#${u} .jfm-hint{display:block;font-size:.75rem;color:var(--jfm-text-muted);margin-top:.25rem;}
|
||||
#${u} .jfm-muted{font-size:.875rem;color:var(--jfm-text-muted);margin-top:.375rem;}
|
||||
|
||||
/* Spinner */
|
||||
#${u} .jfm-spin{display:inline-block;width:.9rem;height:.9rem;border:2px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:jfm-spin-${u} .6s linear infinite;vertical-align:middle;}
|
||||
#${u} .jfm-spin{display:inline-block;width:.9rem;height:.9rem;border:2px solid var(--color-border,#e5e7eb);border-top-color:var(--color-primary,#3b82f6);border-radius:50%;animation:jfm-spin-${u} .6s linear infinite;vertical-align:middle;}
|
||||
@keyframes jfm-spin-${u}{to{transform:rotate(360deg);}}
|
||||
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
#${u} .jfm-btn,#${u} .jfm-card,#${u} .jfm-dropzone{transition:none;}
|
||||
#${u} .jfm-spin{animation-duration:1.5s;}
|
||||
#${u} .jfm-modal{backdrop-filter:none;}
|
||||
}
|
||||
</style>`;
|
||||
}
|
||||
|
||||
@@ -319,7 +356,9 @@
|
||||
if (this._editorEl) {
|
||||
this._editorEl.addEventListener('input', () => this._updateStat());
|
||||
this._editorEl.addEventListener('keydown', e => {
|
||||
if (e.key === 'Tab') {
|
||||
// Plain Tab indents; Shift+Tab is left alone so keyboard
|
||||
// users can always move focus out of the editor.
|
||||
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
const s = this._editorEl.selectionStart;
|
||||
const end = this._editorEl.selectionEnd;
|
||||
@@ -336,15 +375,8 @@
|
||||
}
|
||||
|
||||
_onKey(e) {
|
||||
// Escape is handled by the dialog focus trap (see _trap).
|
||||
const editOpen = this._editModal && !this._editModal.hidden;
|
||||
const delOpen = this._delModal && !this._delModal.hidden;
|
||||
const createOpen = this._createModal && !this._createModal.hidden;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (editOpen) { this._closeEdit(); return; }
|
||||
if (delOpen) { this._closeDel(); return; }
|
||||
if (createOpen) { this._closeCreate(); return; }
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's' && editOpen) {
|
||||
e.preventDefault();
|
||||
this._doSave();
|
||||
@@ -488,6 +520,7 @@
|
||||
this._editorEl.value = 'Loading…';
|
||||
this._updateStat();
|
||||
this._editModal.hidden = false;
|
||||
this._trap('edit', this._editModal, `${this._uid}-edit-title`, this._editorEl, () => this._closeEdit());
|
||||
|
||||
try {
|
||||
const data = await this._api('get', { filename });
|
||||
@@ -498,7 +531,7 @@
|
||||
this._editorEl.setSelectionRange(0, 0);
|
||||
this._editorEl.scrollTop = 0;
|
||||
} catch (err) {
|
||||
this._showErr('Failed to load file: ' + err.message);
|
||||
this._showErr(`Couldn't load this file (${err.message}). Close the editor and try again.`);
|
||||
this._editorEl.value = '';
|
||||
}
|
||||
}
|
||||
@@ -507,6 +540,7 @@
|
||||
if (this._editModal) this._editModal.hidden = true;
|
||||
this._editFile = null;
|
||||
this._clearErr();
|
||||
this._release('edit');
|
||||
}
|
||||
|
||||
_formatJson() {
|
||||
@@ -562,12 +596,18 @@
|
||||
this._deleteFile = filename;
|
||||
const el = document.getElementById(`${this._uid}-del-name`);
|
||||
if (el) el.textContent = filename;
|
||||
if (this._delModal) this._delModal.hidden = false;
|
||||
if (this._delModal) {
|
||||
this._delModal.hidden = false;
|
||||
// Destructive dialog: start on Cancel.
|
||||
this._trap('del', this._delModal, `${this._uid}-del-title`,
|
||||
document.getElementById(`${this._uid}-del-cancel`), () => this._closeDel());
|
||||
}
|
||||
}
|
||||
|
||||
_closeDel() {
|
||||
if (this._delModal) this._delModal.hidden = true;
|
||||
this._deleteFile = null;
|
||||
this._release('del');
|
||||
}
|
||||
|
||||
async _doDelete() {
|
||||
@@ -597,11 +637,13 @@
|
||||
});
|
||||
this._createModal.hidden = false;
|
||||
const first = this.createFields[0];
|
||||
if (first) document.getElementById(`${this._uid}-cf-${first.key}`)?.focus();
|
||||
const firstInput = first ? document.getElementById(`${this._uid}-cf-${first.key}`) : null;
|
||||
this._trap('create', this._createModal, `${this._uid}-create-title`, firstInput, () => this._closeCreate());
|
||||
}
|
||||
|
||||
_closeCreate() {
|
||||
if (this._createModal) this._createModal.hidden = true;
|
||||
this._release('create');
|
||||
}
|
||||
|
||||
async _doCreate() {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
* LEDMatrix Notification Widget
|
||||
*
|
||||
* Global notification/toast system for displaying messages to users.
|
||||
* Consolidates all notification functionality into a single widget.
|
||||
* This is the single implementation: the early fallbacks in app-shell.js,
|
||||
* app.js and partials/fonts.html only queue messages until this widget has
|
||||
* loaded (see window.__pendingNotifications) and then delegate to it.
|
||||
*
|
||||
* Usage:
|
||||
* window.showNotification('Message here', 'success');
|
||||
@@ -11,6 +13,16 @@
|
||||
*
|
||||
* Types: success, error, warning, info (default)
|
||||
*
|
||||
* Accessibility:
|
||||
* - Toasts are announced through two persistent, visually hidden live
|
||||
* regions: errors use an assertive region (role="alert"), everything else
|
||||
* a polite one (role="status"). The visible toast is not itself a live
|
||||
* region, so each message is announced exactly once.
|
||||
* - Errors stay on screen for at least ERROR_MIN_DURATION and always have a
|
||||
* dismiss button.
|
||||
* - Auto-dismiss pauses while the pointer is over a toast or focus is in it.
|
||||
* - Slide animations are skipped under prefers-reduced-motion.
|
||||
*
|
||||
* @module NotificationWidget
|
||||
*/
|
||||
|
||||
@@ -26,35 +38,51 @@
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
containerId: 'notifications',
|
||||
politeRegionId: 'notifications-live-polite',
|
||||
assertiveRegionId: 'notifications-live-assertive',
|
||||
defaultDuration: 4000,
|
||||
warningDuration: 6000,
|
||||
errorMinDuration: 10000,
|
||||
fadeOutDuration: 300,
|
||||
maxNotifications: 5,
|
||||
position: 'top-right' // top-right, top-left, bottom-right, bottom-left
|
||||
};
|
||||
|
||||
// Type-specific styling
|
||||
// Type-specific styling. `bg` class names are kept for anyone styling
|
||||
// against them; `color` is applied inline so white text meets 4.5:1
|
||||
// contrast even where the utility classes are missing or lighter.
|
||||
const TYPE_STYLES = {
|
||||
success: {
|
||||
bg: 'bg-green-500',
|
||||
color: '#047857',
|
||||
icon: 'fa-check-circle',
|
||||
label: 'Success'
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-red-500',
|
||||
color: '#b91c1c',
|
||||
icon: 'fa-exclamation-circle',
|
||||
label: 'Error'
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-yellow-500',
|
||||
color: '#b45309',
|
||||
icon: 'fa-exclamation-triangle',
|
||||
label: 'Warning'
|
||||
},
|
||||
info: {
|
||||
bg: 'bg-blue-500',
|
||||
color: '#1d4ed8',
|
||||
icon: 'fa-info-circle',
|
||||
label: 'Info'
|
||||
}
|
||||
};
|
||||
// Lookups go through a Map so an arbitrary type string can't reach
|
||||
// Object.prototype properties.
|
||||
const STYLE_BY_TYPE = new Map(Object.entries(TYPE_STYLES));
|
||||
|
||||
const VISUALLY_HIDDEN = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;' +
|
||||
'overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;';
|
||||
|
||||
// Track active notifications
|
||||
let activeNotifications = [];
|
||||
@@ -62,8 +90,14 @@
|
||||
// keyed by notification id (cleaned up on dismiss). A Map rather than a
|
||||
// plain object so ids can never collide with prototype properties.
|
||||
const actionCallbacks = new Map();
|
||||
// Auto-dismiss timers, keyed by notification id: { timer, remaining, startedAt }
|
||||
const timers = new Map();
|
||||
let notificationCounter = 0;
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the notifications container
|
||||
* @returns {HTMLElement} Container element
|
||||
@@ -75,14 +109,55 @@
|
||||
container = document.createElement('div');
|
||||
container.id = CONFIG.containerId;
|
||||
container.className = 'fixed top-4 right-4 z-50 space-y-2 pointer-events-none';
|
||||
container.setAttribute('aria-live', 'polite');
|
||||
container.setAttribute('aria-label', 'Notifications');
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
if (!container.dataset.ledNotifications) {
|
||||
container.dataset.ledNotifications = 'true';
|
||||
// Announcements go through the dedicated live regions below; a
|
||||
// live container would announce every toast a second time.
|
||||
container.removeAttribute('aria-live');
|
||||
container.setAttribute('role', 'region');
|
||||
container.setAttribute('aria-label', 'Notifications');
|
||||
// Let clicks pass through the empty parts of the stack even when
|
||||
// the utility classes are not defined.
|
||||
container.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
getLiveRegion(CONFIG.politeRegionId, 'status', 'polite');
|
||||
getLiveRegion(CONFIG.assertiveRegionId, 'alert', 'assertive');
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function getLiveRegion(id, role, politeness) {
|
||||
let region = document.getElementById(id);
|
||||
if (!region) {
|
||||
region = document.createElement('div');
|
||||
region.id = id;
|
||||
region.setAttribute('role', role);
|
||||
region.setAttribute('aria-live', politeness);
|
||||
region.setAttribute('aria-atomic', 'true');
|
||||
region.style.cssText = VISUALLY_HIDDEN;
|
||||
document.body.appendChild(region);
|
||||
}
|
||||
return region;
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce a message to screen readers once.
|
||||
*/
|
||||
function announce(text, assertive) {
|
||||
const region = getLiveRegion(
|
||||
assertive ? CONFIG.assertiveRegionId : CONFIG.politeRegionId,
|
||||
assertive ? 'alert' : 'status',
|
||||
assertive ? 'assertive' : 'polite'
|
||||
);
|
||||
// Clear first so repeating the same message is announced again.
|
||||
region.textContent = '';
|
||||
setTimeout(() => { region.textContent = text; }, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @param {string} text - Text to escape
|
||||
@@ -94,16 +169,55 @@
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function clearTimer(notificationId) {
|
||||
const t = timers.get(notificationId);
|
||||
if (t && t.timer) clearTimeout(t.timer);
|
||||
timers.delete(notificationId);
|
||||
}
|
||||
|
||||
function startTimer(notificationId, ms) {
|
||||
const t = timers.get(notificationId) || {};
|
||||
if (t.timer) clearTimeout(t.timer);
|
||||
t.remaining = ms;
|
||||
t.startedAt = Date.now();
|
||||
t.timer = setTimeout(() => removeNotification(notificationId), ms);
|
||||
timers.set(notificationId, t);
|
||||
}
|
||||
|
||||
function pauseTimer(notificationId) {
|
||||
const t = timers.get(notificationId);
|
||||
if (!t || !t.timer) return;
|
||||
clearTimeout(t.timer);
|
||||
t.timer = null;
|
||||
t.remaining = Math.max(0, t.remaining - (Date.now() - t.startedAt));
|
||||
}
|
||||
|
||||
function resumeTimer(notificationId) {
|
||||
const t = timers.get(notificationId);
|
||||
if (!t || t.timer) return;
|
||||
// Give the reader a moment after they move away.
|
||||
startTimer(notificationId, Math.max(t.remaining, 2000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a notification by ID
|
||||
* @param {string} notificationId - Notification ID
|
||||
* @param {boolean} immediate - Skip fade animation
|
||||
*/
|
||||
function removeNotification(notificationId, immediate = false) {
|
||||
clearTimer(notificationId);
|
||||
const notification = document.getElementById(notificationId);
|
||||
|
||||
// Remove from tracking array
|
||||
activeNotifications = activeNotifications.filter(id => id !== notificationId);
|
||||
actionCallbacks.delete(notificationId);
|
||||
|
||||
if (!notification) return;
|
||||
|
||||
if (immediate) {
|
||||
// If focus is inside the toast, don't strand it on a removed node.
|
||||
const hadFocus = notification.contains(document.activeElement);
|
||||
|
||||
if (immediate || prefersReducedMotion()) {
|
||||
notification.remove();
|
||||
} else {
|
||||
notification.style.transition = `opacity ${CONFIG.fadeOutDuration}ms, transform ${CONFIG.fadeOutDuration}ms`;
|
||||
@@ -115,9 +229,16 @@
|
||||
}, CONFIG.fadeOutDuration);
|
||||
}
|
||||
|
||||
// Remove from tracking array
|
||||
activeNotifications = activeNotifications.filter(id => id !== notificationId);
|
||||
actionCallbacks.delete(notificationId);
|
||||
if (hadFocus) {
|
||||
const next = activeNotifications.length
|
||||
? document.querySelector(`#${activeNotifications[activeNotifications.length - 1]} button`)
|
||||
: null;
|
||||
if (next) {
|
||||
next.focus();
|
||||
} else if (document.activeElement && typeof document.activeElement.blur === 'function') {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,15 +252,26 @@
|
||||
if (typeof options === 'string') {
|
||||
options = { type: options };
|
||||
}
|
||||
options = options || {};
|
||||
|
||||
const type = options.type || 'info';
|
||||
const duration = options.duration !== undefined ? options.duration : CONFIG.defaultDuration;
|
||||
const type = STYLE_BY_TYPE.has(options.type) ? options.type : 'info';
|
||||
const isError = type === 'error';
|
||||
let duration = options.duration !== undefined
|
||||
? Number(options.duration)
|
||||
: (isError ? CONFIG.errorMinDuration
|
||||
: type === 'warning' ? CONFIG.warningDuration : CONFIG.defaultDuration);
|
||||
// Errors must stay long enough to read; 0 still means "until dismissed".
|
||||
if (isError && duration > 0 && duration < CONFIG.errorMinDuration) {
|
||||
duration = CONFIG.errorMinDuration;
|
||||
}
|
||||
const showIcon = options.showIcon !== false;
|
||||
const dismissible = options.dismissible !== false;
|
||||
// Errors always get a dismiss button.
|
||||
const dismissible = isError || duration <= 0 || options.dismissible !== false;
|
||||
|
||||
const style = TYPE_STYLES[type] || TYPE_STYLES.info;
|
||||
const style = STYLE_BY_TYPE.get(type);
|
||||
const container = getContainer();
|
||||
const notificationId = `notification_${++notificationCounter}`;
|
||||
const reduceMotion = prefersReducedMotion();
|
||||
|
||||
// Enforce max notifications limit
|
||||
while (activeNotifications.length >= CONFIG.maxNotifications) {
|
||||
@@ -150,18 +282,25 @@
|
||||
const notification = document.createElement('div');
|
||||
notification.id = notificationId;
|
||||
notification.className = `${style.bg} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 pointer-events-auto transform transition-all duration-300 ease-out`;
|
||||
notification.style.opacity = '0';
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
notification.setAttribute('role', 'alert');
|
||||
notification.style.backgroundColor = style.color;
|
||||
notification.style.color = '#ffffff';
|
||||
notification.style.pointerEvents = 'auto';
|
||||
notification.dataset.type = type;
|
||||
if (reduceMotion) {
|
||||
notification.style.transition = 'none';
|
||||
} else {
|
||||
notification.style.opacity = '0';
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
}
|
||||
|
||||
// Build content
|
||||
let html = '';
|
||||
|
||||
if (showIcon) {
|
||||
html += `<i class="fas ${style.icon} flex-shrink-0"></i>`;
|
||||
html += `<i class="fas ${style.icon} flex-shrink-0" aria-hidden="true"></i>`;
|
||||
}
|
||||
|
||||
html += `<span class="flex-1 text-sm">${escapeHtml(message)}</span>`;
|
||||
html += `<span class="flex-1 text-sm"><span style="${VISUALLY_HIDDEN}">${style.label}: </span>${escapeHtml(message)}</span>`;
|
||||
|
||||
// Optional inline action button (e.g. "Restart Now" on a restart nudge).
|
||||
// The callback is stored by id and invoked via triggerAction, which
|
||||
@@ -171,7 +310,8 @@
|
||||
html += `
|
||||
<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.get('notification').triggerAction('${notificationId}')"
|
||||
class="flex-shrink-0 ml-2 px-3 py-1 text-xs font-semibold rounded-md bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors duration-150">
|
||||
class="flex-shrink-0 ml-2 px-3 py-1 text-xs font-semibold rounded-md bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors duration-150"
|
||||
style="background:rgba(255,255,255,.2);color:inherit;border:0;">
|
||||
${escapeHtml(options.actionLabel)}
|
||||
</button>
|
||||
`;
|
||||
@@ -182,8 +322,9 @@
|
||||
<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.get('notification').dismiss('${notificationId}')"
|
||||
class="flex-shrink-0 ml-2 w-5 h-5 flex items-center justify-center rounded-full opacity-70 hover:opacity-100 hover:bg-white hover:bg-opacity-20 transition-all duration-150"
|
||||
aria-label="Dismiss notification">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
style="width:1.75rem;height:1.75rem;display:inline-flex;align-items:center;justify-content:center;background:transparent;color:inherit;border:0;border-radius:9999px;cursor:pointer;"
|
||||
aria-label="Dismiss ${style.label.toLowerCase()} notification">
|
||||
<svg class="w-3 h-3" width="12" height="12" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -194,17 +335,29 @@
|
||||
container.appendChild(notification);
|
||||
activeNotifications.push(notificationId);
|
||||
|
||||
// Trigger animation (need to wait for DOM update)
|
||||
requestAnimationFrame(() => {
|
||||
notification.style.opacity = '1';
|
||||
notification.style.transform = 'translateX(0)';
|
||||
});
|
||||
announce(`${style.label}: ${String(message)}`, isError);
|
||||
|
||||
// Auto-dismiss (0 = no auto-dismiss)
|
||||
if (!reduceMotion) {
|
||||
// Trigger animation (need to wait for DOM update)
|
||||
requestAnimationFrame(() => {
|
||||
notification.style.opacity = '1';
|
||||
notification.style.transform = 'translateX(0)';
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-dismiss (0 = no auto-dismiss), paused while hovered or focused
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeNotification(notificationId);
|
||||
}, duration);
|
||||
startTimer(notificationId, duration);
|
||||
notification.addEventListener('mouseenter', () => pauseTimer(notificationId));
|
||||
notification.addEventListener('mouseleave', () => {
|
||||
if (!notification.contains(document.activeElement)) resumeTimer(notificationId);
|
||||
});
|
||||
notification.addEventListener('focusin', () => pauseTimer(notificationId));
|
||||
notification.addEventListener('focusout', (e) => {
|
||||
if (!notification.contains(e.relatedTarget) && !notification.matches(':hover')) {
|
||||
resumeTimer(notificationId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Log for debugging
|
||||
@@ -224,16 +377,17 @@
|
||||
// Register the widget
|
||||
window.LEDMatrixWidgets.register('notification', {
|
||||
name: 'Notification Widget',
|
||||
version: '1.0.0',
|
||||
version: '1.1.0',
|
||||
|
||||
/**
|
||||
* Show a notification
|
||||
* @param {string} message - Message to display
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {string} options.type - Notification type: success, error, warning, info
|
||||
* @param {number} options.duration - Auto-dismiss duration in ms (0 = no auto-dismiss)
|
||||
* @param {number} options.duration - Auto-dismiss duration in ms (0 = no auto-dismiss).
|
||||
* Errors are kept for at least 10s.
|
||||
* @param {boolean} options.showIcon - Show type icon (default: true)
|
||||
* @param {boolean} options.dismissible - Show dismiss button (default: true)
|
||||
* @param {boolean} options.dismissible - Show dismiss button (default: true; always true for errors)
|
||||
* @returns {string} Notification ID
|
||||
*/
|
||||
show: showNotification,
|
||||
@@ -295,15 +449,28 @@
|
||||
// Global shorthand function (backwards compatible with existing code).
|
||||
// Accepts either the legacy type string or a full options object
|
||||
// ({ type, duration, actionLabel, onAction, ... }).
|
||||
// This always replaces the early fallbacks, whatever order they loaded in.
|
||||
window.showNotification = function(message, type = 'info') {
|
||||
return showNotification(message, typeof type === 'string' ? { type: type } : (type || {}));
|
||||
};
|
||||
window.showNotification.__ledNotificationWidget = true;
|
||||
|
||||
// Initialize container on load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', getContainer);
|
||||
} else {
|
||||
function flushPending() {
|
||||
getContainer();
|
||||
const pending = window.__pendingNotifications;
|
||||
window.__pendingNotifications = [];
|
||||
if (Array.isArray(pending)) {
|
||||
pending.forEach(args => {
|
||||
try { window.showNotification(args[0], args[1]); } catch (e) { console.error(e); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize container (and show anything queued before we loaded)
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', flushPending);
|
||||
} else {
|
||||
flushPending();
|
||||
}
|
||||
|
||||
console.log('[NotificationWidget] Notification widget registered');
|
||||
|
||||
@@ -101,8 +101,9 @@
|
||||
html += `
|
||||
<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('number-input').onDecrement('${fieldId}')"
|
||||
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-r-0 border-gray-300 hover:bg-gray-200 ${prefix ? '' : 'rounded-l-md'}">
|
||||
<i class="fas fa-minus text-xs"></i>
|
||||
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-r-0 border-gray-300 hover:bg-gray-200 ${prefix ? '' : 'rounded-l-md'}"
|
||||
aria-label="Decrease" aria-controls="${fieldId}_input">
|
||||
<i class="fas fa-minus text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
@@ -128,8 +129,9 @@
|
||||
html += `
|
||||
<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('number-input').onIncrement('${fieldId}')"
|
||||
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-l-0 border-gray-300 hover:bg-gray-200 ${suffix ? '' : 'rounded-r-md'}">
|
||||
<i class="fas fa-plus text-xs"></i>
|
||||
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-l-0 border-gray-300 hover:bg-gray-200 ${suffix ? '' : 'rounded-r-md'}"
|
||||
aria-label="Increase" aria-controls="${fieldId}_input">
|
||||
<i class="fas fa-plus text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -142,8 +142,9 @@
|
||||
id="${fieldId}_toggle"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('password-input').onToggle('${fieldId}')"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
|
||||
title="Show/hide password">
|
||||
<i id="${fieldId}_icon" class="fas fa-eye"></i>
|
||||
title="Show/hide password"
|
||||
aria-label="Show password" aria-controls="${fieldId}_input">
|
||||
<i id="${fieldId}_icon" class="fas fa-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
@@ -297,16 +298,19 @@
|
||||
const safeId = sanitizeId(fieldId);
|
||||
const input = document.getElementById(`${safeId}_input`);
|
||||
const icon = document.getElementById(`${safeId}_icon`);
|
||||
const toggleBtn = document.getElementById(`${safeId}_toggle`);
|
||||
|
||||
if (input && icon) {
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.classList.remove('fa-eye');
|
||||
icon.classList.add('fa-eye-slash');
|
||||
if (toggleBtn) toggleBtn.setAttribute('aria-label', 'Hide password');
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.classList.remove('fa-eye-slash');
|
||||
icon.classList.add('fa-eye');
|
||||
if (toggleBtn) toggleBtn.setAttribute('aria-label', 'Show password');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,108 +56,141 @@
|
||||
if (!document.getElementById('pfm-styles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'pfm-styles';
|
||||
// Colors come from the app theme tokens (app.css :root / [data-theme="dark"]),
|
||||
// with the original light values as fallbacks for pages without app.css.
|
||||
// The few shades the tokens don't cover are widget-local variables with
|
||||
// a dark-theme override.
|
||||
style.textContent = `
|
||||
.pfm-root { font-family: inherit; }
|
||||
.pfm-root, .pfm-overlay {
|
||||
--pfm-border-strong:#d1d5db; --pfm-subtle:#f3f4f6; --pfm-subtle-hover:#e5e7eb;
|
||||
--pfm-danger-border:#fecaca; --pfm-danger-text:#991b1b; --pfm-today:#fef9c3;
|
||||
--pfm-text-muted:#6b7280;
|
||||
}
|
||||
[data-theme="dark"] .pfm-root, [data-theme="dark"] .pfm-overlay {
|
||||
--pfm-border-strong:#4b5563; --pfm-subtle:#374151; --pfm-subtle-hover:#4b5563;
|
||||
--pfm-danger-border:#991b1b; --pfm-danger-text:#fecaca; --pfm-today:#422006;
|
||||
--pfm-text-muted:#9ca3af;
|
||||
}
|
||||
.pfm-root { font-family: inherit; color:var(--color-text-primary,#111827); }
|
||||
.pfm-header { display:flex; align-items:center; justify-content:space-between;
|
||||
margin-bottom:.75rem; }
|
||||
.pfm-title { font-size:1rem; font-weight:600; color:#111827; }
|
||||
.pfm-dir { font-size:.75rem; color:#6b7280; margin-top:.125rem; }
|
||||
.pfm-upload { border:2px dashed #d1d5db; border-radius:.5rem; padding:1.25rem;
|
||||
.pfm-title { font-size:1rem; font-weight:600; color:var(--color-text-primary,#111827); }
|
||||
.pfm-dir { font-size:.75rem; color:var(--pfm-text-muted); margin-top:.125rem; }
|
||||
.pfm-upload { border:2px dashed var(--pfm-border-strong); border-radius:.5rem; padding:1.25rem;
|
||||
text-align:center; cursor:pointer; transition:border-color .15s,background .15s; }
|
||||
.pfm-upload:hover,.pfm-upload.dragover { border-color:#3b82f6; background:#eff6ff; }
|
||||
.pfm-upload p { font-size:.875rem; color:#4b5563; margin:.25rem 0 0; }
|
||||
.pfm-upload small { font-size:.75rem; color:#9ca3af; }
|
||||
.pfm-upload:hover,.pfm-upload.dragover { border-color:var(--color-primary,#3b82f6); background:var(--color-info-bg,#eff6ff); }
|
||||
.pfm-upload p { font-size:.875rem; color:var(--color-text-tertiary,#4b5563); margin:.25rem 0 0; }
|
||||
.pfm-upload small { font-size:.75rem; color:var(--pfm-text-muted); }
|
||||
.pfm-upload-icon { font-size:1.5rem; color:var(--pfm-text-muted); }
|
||||
.pfm-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr));
|
||||
gap:.75rem; margin-top:.75rem; }
|
||||
.pfm-card { border:1px solid #e5e7eb; border-radius:.5rem; padding:.875rem;
|
||||
background:#fff; transition:box-shadow .15s; }
|
||||
.pfm-card:hover { box-shadow:0 1px 4px rgba(0,0,0,.1); }
|
||||
.pfm-card { border:1px solid var(--color-border,#e5e7eb); border-radius:.5rem; padding:.875rem;
|
||||
background:var(--color-surface,#fff); transition:box-shadow .15s; }
|
||||
.pfm-card:hover { box-shadow:var(--shadow-md,0 1px 4px rgba(0,0,0,.1)); }
|
||||
.pfm-card.disabled { opacity:.55; }
|
||||
.pfm-card-top { display:flex; align-items:center; justify-content:space-between;
|
||||
margin-bottom:.5rem; }
|
||||
.pfm-card-icon { width:2rem; height:2rem; background:#f3f4f6; border-radius:.375rem;
|
||||
.pfm-card-icon { width:2rem; height:2rem; background:var(--pfm-subtle); border-radius:.375rem;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
color:#6b7280; font-size:1rem; }
|
||||
.pfm-card-name { font-weight:600; color:#111827; font-size:.875rem; margin:.375rem 0 .125rem; }
|
||||
.pfm-card-meta { font-size:.75rem; color:#6b7280; line-height:1.5; }
|
||||
color:var(--pfm-text-muted); font-size:1rem; }
|
||||
.pfm-card-name { font-weight:600; color:var(--color-text-primary,#111827); font-size:.875rem; margin:.375rem 0 .125rem; }
|
||||
.pfm-card-meta { font-size:.75rem; color:var(--pfm-text-muted); line-height:1.5; }
|
||||
.pfm-card-actions { display:flex; gap:.375rem; margin-top:.625rem; }
|
||||
.pfm-btn { display:inline-flex; align-items:center; gap:.25rem; padding:.375rem .75rem;
|
||||
border-radius:.375rem; font-size:.8125rem; font-weight:500;
|
||||
border:none; cursor:pointer; transition:background .15s; }
|
||||
.pfm-btn-primary { background:#2563eb; color:#fff; flex:1; justify-content:center; }
|
||||
.pfm-btn-primary:hover { background:#1d4ed8; }
|
||||
.pfm-btn:focus-visible { outline:2px solid var(--color-primary,#2563eb); outline-offset:2px; }
|
||||
.pfm-btn:disabled { opacity:.5; cursor:not-allowed; }
|
||||
.pfm-btn-primary { background:var(--color-primary,#2563eb); color:#fff; flex:1; justify-content:center; }
|
||||
.pfm-btn-primary:hover { background:var(--color-primary-hover,#1d4ed8); }
|
||||
/* Solid fills with white text: fixed shades read well on both themes. */
|
||||
.pfm-btn-danger { background:#dc2626; color:#fff; }
|
||||
.pfm-btn-danger:hover { background:#b91c1c; }
|
||||
.pfm-btn-secondary { background:#f3f4f6; color:#374151; border:1px solid #d1d5db; }
|
||||
.pfm-btn-secondary:hover { background:#e5e7eb; }
|
||||
.pfm-btn-secondary { background:var(--pfm-subtle); color:var(--color-text-secondary,#374151); border:1px solid var(--pfm-border-strong); }
|
||||
.pfm-btn-secondary:hover { background:var(--pfm-subtle-hover); }
|
||||
.pfm-btn-sm { padding:.25rem .5rem; font-size:.75rem; }
|
||||
.pfm-btn-create { background:#059669; color:#fff; }
|
||||
.pfm-btn-create:hover { background:#047857; }
|
||||
.pfm-toggle-wrap { display:flex; align-items:center; gap:.375rem; }
|
||||
.pfm-toggle-label { font-size:.75rem; color:#6b7280; }
|
||||
.pfm-toggle-label { font-size:.75rem; color:var(--pfm-text-muted); }
|
||||
.pfm-toggle-cb { position:relative; display:inline-block; width:2rem; height:1.125rem; }
|
||||
.pfm-toggle-cb input { opacity:0; width:0; height:0; }
|
||||
.pfm-toggle-slider { position:absolute; inset:0; background:#d1d5db; border-radius:9999px;
|
||||
.pfm-toggle-slider { position:absolute; inset:0; background:var(--pfm-border-strong); border-radius:9999px;
|
||||
cursor:pointer; transition:background .2s; }
|
||||
.pfm-toggle-slider:before { content:''; position:absolute; height:.75rem; width:.75rem;
|
||||
left:.1875rem; bottom:.1875rem; background:#fff;
|
||||
border-radius:50%; transition:transform .2s; }
|
||||
.pfm-toggle-cb input:checked + .pfm-toggle-slider { background:#10b981; }
|
||||
.pfm-toggle-cb input:checked + .pfm-toggle-slider:before { transform:translateX(.875rem); }
|
||||
.pfm-empty { text-align:center; padding:2rem; color:#9ca3af; }
|
||||
.pfm-toggle-cb input:focus-visible + .pfm-toggle-slider { outline:2px solid var(--color-primary,#2563eb); outline-offset:2px; }
|
||||
.pfm-empty { text-align:center; padding:2rem; color:var(--pfm-text-muted); }
|
||||
.pfm-empty i { font-size:2rem; margin-bottom:.5rem; display:block; }
|
||||
.pfm-error-text { color:var(--color-error,#dc2626); }
|
||||
|
||||
/* Modal */
|
||||
.pfm-overlay { position:fixed; inset:0; background:rgba(0,0,0,.5);
|
||||
display:flex; align-items:flex-start; justify-content:center;
|
||||
z-index:9999; padding:2rem 1rem; overflow-y:auto; }
|
||||
.pfm-modal { background:#fff; border-radius:.75rem; width:100%; max-width:56rem;
|
||||
.pfm-modal { background:var(--color-surface,#fff); color:var(--color-text-primary,#111827);
|
||||
border-radius:.75rem; width:100%; max-width:56rem;
|
||||
box-shadow:0 20px 50px rgba(0,0,0,.3); margin:auto; }
|
||||
.pfm-modal:focus { outline:none; }
|
||||
.pfm-modal-header { display:flex; align-items:center; justify-content:space-between;
|
||||
padding:1rem 1.25rem; border-bottom:1px solid #e5e7eb; }
|
||||
.pfm-modal-title { font-size:1rem; font-weight:600; color:#111827; }
|
||||
padding:1rem 1.25rem; border-bottom:1px solid var(--color-border,#e5e7eb); }
|
||||
.pfm-modal-title { font-size:1rem; font-weight:600; color:var(--color-text-primary,#111827); }
|
||||
.pfm-modal-body { padding:1.25rem; overflow-y:auto; max-height:70vh; }
|
||||
.pfm-modal-footer { display:flex; justify-content:flex-end; gap:.5rem;
|
||||
padding:.875rem 1.25rem; border-top:1px solid #e5e7eb;
|
||||
background:#f9fafb; border-radius:0 0 .75rem .75rem; }
|
||||
padding:.875rem 1.25rem; border-top:1px solid var(--color-border,#e5e7eb);
|
||||
background:var(--color-background,#f9fafb); border-radius:0 0 .75rem .75rem; }
|
||||
|
||||
/* Entry table */
|
||||
.pfm-table-wrap { overflow-x:auto; }
|
||||
.pfm-table { width:100%; border-collapse:collapse; font-size:.8125rem; }
|
||||
.pfm-table th { background:#f9fafb; text-align:left; padding:.5rem .625rem;
|
||||
font-weight:600; color:#374151; border-bottom:1px solid #e5e7eb;
|
||||
.pfm-table th { background:var(--color-background,#f9fafb); text-align:left; padding:.5rem .625rem;
|
||||
font-weight:600; color:var(--color-text-secondary,#374151); border-bottom:1px solid var(--color-border,#e5e7eb);
|
||||
white-space:nowrap; position:sticky; top:0; }
|
||||
.pfm-table td { padding:.375rem .625rem; border-bottom:1px solid #f3f4f6;
|
||||
.pfm-table td { padding:.375rem .625rem; border-bottom:1px solid var(--color-border-light,#f3f4f6);
|
||||
vertical-align:top; }
|
||||
.pfm-table tr.today-row td { background:#fef9c3; }
|
||||
.pfm-table tr.today-row td { background:var(--pfm-today); }
|
||||
.pfm-table td input, .pfm-table td textarea {
|
||||
width:100%; border:1px solid #d1d5db; border-radius:.25rem;
|
||||
width:100%; border:1px solid var(--pfm-border-strong); border-radius:.25rem;
|
||||
padding:.25rem .375rem; font-size:.8125rem; font-family:inherit;
|
||||
resize:vertical; background:#fff; }
|
||||
resize:vertical; background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
|
||||
.pfm-table td input:focus, .pfm-table td textarea:focus {
|
||||
outline:none; border-color:#3b82f6; }
|
||||
outline:none; border-color:var(--color-primary,#3b82f6); box-shadow:0 0 0 2px var(--color-primary,#3b82f6); }
|
||||
.pfm-day-col { width:3rem; text-align:center; font-weight:600;
|
||||
color:#6b7280; white-space:nowrap; }
|
||||
color:var(--pfm-text-muted); white-space:nowrap; }
|
||||
.pfm-table-info { font-size:.75rem; color:var(--pfm-text-muted); margin-bottom:.375rem; }
|
||||
.pfm-pagination { display:flex; align-items:center; justify-content:space-between;
|
||||
margin-top:.75rem; font-size:.8125rem; color:#6b7280; }
|
||||
margin-top:.75rem; font-size:.8125rem; color:var(--pfm-text-muted); }
|
||||
.pfm-page-jump { display:flex; align-items:center; gap:.375rem; font-size:.8125rem; }
|
||||
.pfm-page-jump input { width:3.5rem; padding:.25rem .375rem; border:1px solid #d1d5db;
|
||||
border-radius:.25rem; text-align:center; }
|
||||
.pfm-page-jump input { width:3.5rem; padding:.25rem .375rem; border:1px solid var(--pfm-border-strong);
|
||||
border-radius:.25rem; text-align:center;
|
||||
background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
|
||||
.pfm-json-ta { width:100%; font-family:monospace; font-size:.75rem; border:1px solid var(--pfm-border-strong);
|
||||
border-radius:.375rem; padding:.5rem;
|
||||
background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
|
||||
.pfm-json-err { color:var(--color-error,#dc2626); font-size:.75rem; margin-top:.25rem; }
|
||||
|
||||
/* Form in create modal */
|
||||
.pfm-field { margin-bottom:.875rem; }
|
||||
.pfm-field label { display:block; font-size:.875rem; font-weight:500;
|
||||
color:#374151; margin-bottom:.25rem; }
|
||||
.pfm-field input { width:100%; padding:.4rem .625rem; border:1px solid #d1d5db;
|
||||
border-radius:.375rem; font-size:.875rem; }
|
||||
.pfm-field input:focus { outline:none; border-color:#3b82f6; }
|
||||
.pfm-field-hint { font-size:.75rem; color:#9ca3af; margin-top:.2rem; }
|
||||
.pfm-field-error { font-size:.75rem; color:#dc2626; margin-top:.2rem; }
|
||||
color:var(--color-text-secondary,#374151); margin-bottom:.25rem; }
|
||||
.pfm-field input { width:100%; padding:.4rem .625rem; border:1px solid var(--pfm-border-strong);
|
||||
border-radius:.375rem; font-size:.875rem;
|
||||
background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
|
||||
.pfm-field input:focus { outline:none; border-color:var(--color-primary,#3b82f6); box-shadow:0 0 0 2px var(--color-primary,#3b82f6); }
|
||||
.pfm-field-hint { font-size:.75rem; color:var(--pfm-text-muted); margin-top:.2rem; }
|
||||
.pfm-field-error { font-size:.75rem; color:var(--color-error,#dc2626); margin-top:.2rem; }
|
||||
|
||||
/* Delete danger box */
|
||||
.pfm-danger-box { background:#fef2f2; border:1px solid #fecaca;
|
||||
.pfm-danger-box { background:var(--color-error-bg,#fef2f2); border:1px solid var(--pfm-danger-border);
|
||||
border-radius:.5rem; padding:.875rem; font-size:.875rem;
|
||||
color:#991b1b; }
|
||||
color:var(--pfm-danger-text); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pfm-upload, .pfm-card, .pfm-btn, .pfm-toggle-slider, .pfm-toggle-slider:before { transition:none; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -356,7 +389,8 @@
|
||||
delBtn.dataset.pfmAction = 'delete';
|
||||
delBtn.dataset.pfmField = fieldId;
|
||||
delBtn.dataset.pfmFile = f.filename;
|
||||
delBtn.innerHTML = '<i class="fas fa-trash"></i>'; // static
|
||||
delBtn.setAttribute('aria-label', 'Delete ' + f.filename);
|
||||
delBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>'; // static
|
||||
actions.appendChild(delBtn);
|
||||
}
|
||||
card.appendChild(actions);
|
||||
@@ -375,18 +409,18 @@
|
||||
modal.className = 'pfm-modal';
|
||||
safeSetHTML(modal, `
|
||||
<div class="pfm-modal-header">
|
||||
<span class="pfm-modal-title"><i class="fas fa-edit mr-2"></i>${escHtml(filename)}</span>
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_modal_close">
|
||||
<i class="fas fa-times"></i>
|
||||
<span class="pfm-modal-title" id="${escHtml(fieldId)}_modal_title"><i class="fas fa-edit mr-2" aria-hidden="true"></i>${escHtml(filename)}</span>
|
||||
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_modal_close" aria-label="Close">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body" id="${escHtml(fieldId)}_edit_body">
|
||||
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
|
||||
<div class="pfm-empty"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i>Loading…</div>
|
||||
</div>
|
||||
<div class="pfm-modal-footer">
|
||||
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_modal_cancel">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
|
||||
<i class="fas fa-save mr-1"></i>Save
|
||||
<button type="button" class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_modal_cancel">Cancel</button>
|
||||
<button type="button" class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
|
||||
<i class="fas fa-save mr-1" aria-hidden="true"></i>Save
|
||||
</button>
|
||||
</div>`);
|
||||
overlay.appendChild(modal);
|
||||
@@ -394,11 +428,12 @@
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_modal_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_modal_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_save_btn`).addEventListener('click', () => window._pfmSave(fieldId, filename));
|
||||
trapModal(fieldId, modal, `${fieldId}_modal_title`);
|
||||
|
||||
const data = await callAction(st.pluginId, st.actions.get, { filename }).catch(() => null);
|
||||
const body = document.getElementById(`${fieldId}_edit_body`);
|
||||
if (!data || data.status !== 'success' || !body) {
|
||||
if (body) safeSetHTML(body, '<div class="pfm-empty" style="color:#dc2626">Failed to load file.</div>');
|
||||
if (body) safeSetHTML(body, `<div class="pfm-empty pfm-error-text">Couldn't load ${escHtml(filename)}. Close this window and try again.</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -413,10 +448,10 @@
|
||||
// Textarea path: _editData stays null; save() reads from the <textarea>
|
||||
st._editData = null;
|
||||
safeSetHTML(body, `
|
||||
<textarea id="${escHtml(fieldId)}_json_ta" rows="20"
|
||||
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;"
|
||||
<textarea id="${escHtml(fieldId)}_json_ta" rows="20" class="pfm-json-ta"
|
||||
aria-label="File contents (JSON)" aria-describedby="${escHtml(fieldId)}_json_err"
|
||||
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
|
||||
<div id="${escHtml(fieldId)}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`);
|
||||
<div id="${escHtml(fieldId)}_json_err" class="pfm-json-err" role="alert"></div>`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -461,7 +496,7 @@
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
|
||||
safeSetHTML(container, `
|
||||
<div class="pfm-table-info" style="font-size:.75rem;color:#6b7280;margin-bottom:.375rem;">
|
||||
<div class="pfm-table-info">
|
||||
${total} entries total
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" style="margin-left:.5rem"
|
||||
onclick="(function(){const targetPage=Math.ceil(${todayDoy}/${perPage});window._pfmTablePage('${fieldId}',targetPage);setTimeout(function(){const row=document.querySelector('tr[data-day=\\'${todayDoy}\\']');if(row)row.scrollIntoView({block:'center'});},60);})()">
|
||||
@@ -579,9 +614,9 @@
|
||||
modal.style.maxWidth = '28rem';
|
||||
safeSetHTML(modal, `
|
||||
<div class="pfm-modal-header">
|
||||
<span class="pfm-modal-title"><i class="fas fa-trash mr-2"></i>Delete File</span>
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_del_close">
|
||||
<i class="fas fa-times"></i>
|
||||
<span class="pfm-modal-title" id="${escHtml(fieldId)}_del_title"><i class="fas fa-trash mr-2" aria-hidden="true"></i>Delete File</span>
|
||||
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_del_close" aria-label="Close">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body">
|
||||
@@ -591,15 +626,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="pfm-modal-footer">
|
||||
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_del_cancel">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
|
||||
<i class="fas fa-trash mr-1"></i>Delete
|
||||
<button type="button" class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_del_cancel">Cancel</button>
|
||||
<button type="button" class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
|
||||
<i class="fas fa-trash mr-1" aria-hidden="true"></i>Delete
|
||||
</button>
|
||||
</div>`);
|
||||
overlay.appendChild(modal);
|
||||
const cancelBtn = modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`);
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
cancelBtn.addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_confirm`).addEventListener('click', () => window._pfmConfirmDelete(fieldId, filename));
|
||||
// Destructive dialog: start on Cancel.
|
||||
trapModal(fieldId, modal, `${fieldId}_del_title`, cancelBtn);
|
||||
};
|
||||
|
||||
window._pfmConfirmDelete = async function (fieldId, filename) {
|
||||
@@ -611,7 +649,7 @@
|
||||
window._pfmCloseModal(fieldId);
|
||||
await loadFiles(fieldId);
|
||||
} else {
|
||||
notify('Delete failed: ' + (result.message || ''), 'error');
|
||||
notify("Couldn't delete the file: " + (result.message || 'unknown error') + '. Try again.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -626,13 +664,13 @@
|
||||
modal.style.maxWidth = '32rem';
|
||||
safeSetHTML(modal, `
|
||||
<div class="pfm-modal-header">
|
||||
<span class="pfm-modal-title"><i class="fas fa-plus-circle mr-2"></i>Create New File</span>
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_cre_close">
|
||||
<i class="fas fa-times"></i>
|
||||
<span class="pfm-modal-title" id="${escHtml(fieldId)}_cre_title"><i class="fas fa-plus-circle mr-2" aria-hidden="true"></i>Create New File</span>
|
||||
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_cre_close" aria-label="Close">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body">
|
||||
<div id="${escHtml(fieldId)}_create_err" class="pfm-field-error" style="margin-bottom:.5rem;"></div>
|
||||
<div id="${escHtml(fieldId)}_create_err" class="pfm-field-error" role="alert" style="margin-bottom:.5rem;"></div>
|
||||
${fields.map(f => `
|
||||
<div class="pfm-field">
|
||||
<label for="${escHtml(fieldId)}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label>
|
||||
@@ -643,16 +681,16 @@
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="pfm-modal-footer">
|
||||
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_cre_cancel">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-create" id="${escHtml(fieldId)}_create_btn">
|
||||
<i class="fas fa-plus mr-1"></i>Create
|
||||
<button type="button" class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_cre_cancel">Cancel</button>
|
||||
<button type="button" class="pfm-btn pfm-btn-create" id="${escHtml(fieldId)}_create_btn">
|
||||
<i class="fas fa-plus mr-1" aria-hidden="true"></i>Create
|
||||
</button>
|
||||
</div>
|
||||
</div>`);
|
||||
overlay.appendChild(modal);
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_cre_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_cre_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_create_btn`).addEventListener('click', () => window._pfmConfirmCreate(fieldId));
|
||||
trapModal(fieldId, modal, `${fieldId}_cre_title`, modal.querySelector('.pfm-field input'));
|
||||
};
|
||||
|
||||
window._pfmConfirmCreate = async function (fieldId) {
|
||||
@@ -739,11 +777,28 @@
|
||||
return overlay;
|
||||
}
|
||||
|
||||
// Dialog semantics + focus trap for an open modal (utils/dialog.js).
|
||||
// Every close path goes through _pfmCloseModal, which releases it.
|
||||
function trapModal(fieldId, modal, titleId, initialFocus) {
|
||||
if (!window.LEDDialog) return;
|
||||
const st = getState(fieldId);
|
||||
st._releaseDialog = window.LEDDialog.trap(modal, {
|
||||
labelledBy: titleId,
|
||||
initialFocus: initialFocus || null,
|
||||
onEscape: () => window._pfmCloseModal(fieldId)
|
||||
});
|
||||
}
|
||||
|
||||
window._pfmCloseModal = function (fieldId) {
|
||||
const st = getState(fieldId);
|
||||
if (st.currentModal) { st.currentModal.remove(); st.currentModal = null; }
|
||||
st._editData = null;
|
||||
st._editFilename = null;
|
||||
if (st._releaseDialog) {
|
||||
const release = st._releaseDialog;
|
||||
st._releaseDialog = null;
|
||||
release();
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Widget registration ──────────────────────────────────────────────────
|
||||
@@ -793,7 +848,7 @@
|
||||
<input type="file" id="${fieldId}_file_input" accept=".json"
|
||||
style="display:none"
|
||||
onchange="if(this.files[0])window._pfmUpload('${fieldId}',this.files[0]);this.value=''">
|
||||
<i class="fas fa-cloud-upload-alt" style="font-size:1.5rem;color:#9ca3af;"></i>
|
||||
<i class="fas fa-cloud-upload-alt pfm-upload-icon" aria-hidden="true"></i>
|
||||
<p>Drag and drop or click to upload</p>
|
||||
<small>${escHtml(st.uploadHint)}</small>
|
||||
</div>` : ''}
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
saturday: 'Saturday',
|
||||
sunday: 'Sunday'
|
||||
};
|
||||
const DAY_LABEL_MAP = new Map(Object.entries(DAY_LABELS));
|
||||
|
||||
// Use BaseWidget utilities if available
|
||||
const base = window.BaseWidget ? new window.BaseWidget('SchedulePicker', '1.0.0') : null;
|
||||
@@ -283,17 +284,19 @@
|
||||
// Render each day row
|
||||
DAYS.forEach(day => {
|
||||
const dayConfig = schedule.days[day];
|
||||
const dayLabel = escapeHtml(DAY_LABEL_MAP.get(day) || day);
|
||||
const disabled = !dayConfig.enabled;
|
||||
const disabledClass = disabled ? 'bg-gray-100' : '';
|
||||
|
||||
html += `
|
||||
<tr class="hover:bg-gray-50" id="${fieldId}_row_${day}">
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<span class="text-sm font-medium text-gray-900">${escapeHtml(DAY_LABELS[day])}</span>
|
||||
<span class="text-sm font-medium text-gray-900">${dayLabel}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<input type="checkbox"
|
||||
id="${fieldId}_${day}_enabled"
|
||||
aria-label="${dayLabel} enabled"
|
||||
${dayConfig.enabled ? 'checked' : ''}
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayEnabledChange('${fieldId}', '${day}', this.checked)"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
@@ -301,6 +304,7 @@
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<input type="time"
|
||||
id="${fieldId}_${day}_start"
|
||||
aria-label="${dayLabel} start time"
|
||||
value="${escapeHtml(dayConfig.start_time)}"
|
||||
${disabled ? 'disabled' : ''}
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayTimeChange('${fieldId}', '${day}')"
|
||||
@@ -309,6 +313,7 @@
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<input type="time"
|
||||
id="${fieldId}_${day}_end"
|
||||
aria-label="${dayLabel} end time"
|
||||
value="${escapeHtml(dayConfig.end_time)}"
|
||||
${disabled ? 'disabled' : ''}
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayTimeChange('${fieldId}', '${day}')"
|
||||
|
||||
@@ -121,8 +121,8 @@
|
||||
id="${fieldId}_clear"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('text-input').onClear('${fieldId}')"
|
||||
class="inline-flex items-center px-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
|
||||
title="Clear">
|
||||
<i class="fas fa-times"></i>
|
||||
title="Clear" aria-label="Clear">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('time-picker').onChange('${fieldId}')"
|
||||
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black pr-10">
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<i class="fas fa-clock text-gray-400"></i>
|
||||
<i class="fas fa-clock text-gray-400" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -101,8 +101,8 @@
|
||||
id="${fieldId}_clear"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('time-picker').onClear('${fieldId}')"
|
||||
class="ml-2 inline-flex items-center px-2 py-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
|
||||
title="Clear">
|
||||
<i class="fas fa-times"></i>
|
||||
title="Clear" aria-label="Clear time">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user