fix(widgets): harden minimize transitionend with fallback and property filter

- Filter transitionend by propertyName=opacity to prevent double-fire
- Add 350ms fallback setTimeout for prefers-reduced-motion / zero-duration
- Initialize _minimizing: false in create() for clean state
- Dispatch events after save() for consistent state in listeners

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 20:13:47 +02:00
parent b92ea5a1a4
commit 30df93a4cc
+22 -7
View File
@@ -52,7 +52,7 @@ const WidgetManager = {
const el = this._buildDOM(state); const el = this._buildDOM(state);
document.body.appendChild(el); document.body.appendChild(el);
this._widgets.set(id, { el, type, state }); this._widgets.set(id, { el, type, state, _minimizing: false });
this._initDrag(el); this._initDrag(el);
this._initResize(el); this._initResize(el);
this.bringToFront(id); this.bringToFront(id);
@@ -170,7 +170,9 @@ const WidgetManager = {
}, },
/** /**
* Widget minimieren (aus DOM verstecken, bleibt im Notebook) * Widget minimieren (aus DOM verstecken, bleibt im Notebook).
* Nutzt transitionend statt setTimeout — _minimizing Flag verhindert Race Condition
* mit openWidget(). Fallback-Timer fuer prefers-reduced-motion / fehlende Transition.
* @param {string} id * @param {string} id
*/ */
async minimize(id) { async minimize(id) {
@@ -180,17 +182,30 @@ const WidgetManager = {
entry._minimizing = true; entry._minimizing = true;
entry.el.classList.add('widget-minimized'); entry.el.classList.add('widget-minimized');
entry.el.addEventListener('transitionend', function onEnd(e) { const MINIMIZE_FALLBACK_MS = 350;
if (e.target !== entry.el) return;
function onEnd(e) {
if (e.target !== entry.el || e.propertyName !== 'opacity') return;
clearTimeout(fallbackTimer);
entry.el.removeEventListener('transitionend', onEnd); entry.el.removeEventListener('transitionend', onEnd);
if (entry._minimizing) { if (entry._minimizing) {
entry.el.style.display = 'none'; entry.el.style.display = 'none';
} }
entry._minimizing = false; entry._minimizing = false;
}); }
entry.el.addEventListener('transitionend', onEnd);
const fallbackTimer = setTimeout(() => {
entry.el.removeEventListener('transitionend', onEnd);
if (entry._minimizing) {
entry.el.style.display = 'none';
entry._minimizing = false;
}
}, MINIMIZE_FALLBACK_MS);
this._emitter.dispatchEvent(new CustomEvent('widget:minimize', { detail: { id } }));
await this.save(); await this.save();
this._emitter.dispatchEvent(new CustomEvent('widget:minimize', { detail: { id } }));
}, },
/** /**
@@ -207,8 +222,8 @@ const WidgetManager = {
entry.el.classList.remove('widget-minimized'); entry.el.classList.remove('widget-minimized');
}); });
this.bringToFront(id); this.bringToFront(id);
this._emitter.dispatchEvent(new CustomEvent('widget:open', { detail: { id } }));
await this.save(); await this.save();
this._emitter.dispatchEvent(new CustomEvent('widget:open', { detail: { id } }));
}, },
/** /**