@adia-ai/web-modules 0.7.27 → 0.7.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -72,7 +72,23 @@ class EditorSidebar extends UIElement {
|
|
|
72
72
|
#storageKey = null;
|
|
73
73
|
#onPointerDown = null;
|
|
74
74
|
#onPointerUp = null;
|
|
75
|
+
#onPaneTransitionEnd = null;
|
|
75
76
|
#pendingCollapseRaf = null;
|
|
77
|
+
// GH #223: an explicit expand() kicks off pane-ui's own width transition
|
|
78
|
+
// (editor-shell.bespoke.css). Early ResizeObserver samples mid-transition
|
|
79
|
+
// are legitimately still below SNAP_THRESHOLD while animating TOWARD a
|
|
80
|
+
// large target — #syncCollapsed() can't tell that apart from a genuine
|
|
81
|
+
// drag-to-collapse settle, and setting [collapsed] back to true re-matches
|
|
82
|
+
// the `!important` collapsed-width CSS rule, which snaps the pane straight
|
|
83
|
+
// back to 48px and PERMANENTLY interrupts the transition (no further
|
|
84
|
+
// resize ever occurs to self-correct — expand()/toggle() become no-ops).
|
|
85
|
+
// This flag suppresses collapse-inference for the duration of an
|
|
86
|
+
// in-flight explicit expand(); cleared by `transitionend` (primary) or a
|
|
87
|
+
// bounded fallback timer sized to the actual computed transition
|
|
88
|
+
// duration (defensive — an interrupted transition doesn't always fire
|
|
89
|
+
// `transitionend` in every browser).
|
|
90
|
+
#expandInFlight = false;
|
|
91
|
+
#expandSettleTimer = null;
|
|
76
92
|
|
|
77
93
|
connected() {
|
|
78
94
|
this.#pane = this.querySelector('pane-ui');
|
|
@@ -131,6 +147,14 @@ class EditorSidebar extends UIElement {
|
|
|
131
147
|
this.#persistWidth();
|
|
132
148
|
};
|
|
133
149
|
document.addEventListener('pointerup', this.#onPointerUp);
|
|
150
|
+
|
|
151
|
+
// §GH-223: the authoritative "explicit transition has settled" signal.
|
|
152
|
+
this.#onPaneTransitionEnd = (e) => {
|
|
153
|
+
if (e.target !== this.#pane || e.propertyName !== 'width') return;
|
|
154
|
+
this.#clearExpandInFlight();
|
|
155
|
+
this.#syncCollapsed();
|
|
156
|
+
};
|
|
157
|
+
this.#pane.addEventListener('transitionend', this.#onPaneTransitionEnd);
|
|
134
158
|
}
|
|
135
159
|
|
|
136
160
|
disconnected() {
|
|
@@ -138,6 +162,10 @@ class EditorSidebar extends UIElement {
|
|
|
138
162
|
cancelAnimationFrame(this.#pendingCollapseRaf);
|
|
139
163
|
this.#pendingCollapseRaf = null;
|
|
140
164
|
}
|
|
165
|
+
if (this.#expandSettleTimer !== null) {
|
|
166
|
+
clearTimeout(this.#expandSettleTimer);
|
|
167
|
+
this.#expandSettleTimer = null;
|
|
168
|
+
}
|
|
141
169
|
this.#ro?.disconnect();
|
|
142
170
|
this.#ro = null;
|
|
143
171
|
if (this.#onPointerDown && this.#pane) {
|
|
@@ -148,6 +176,10 @@ class EditorSidebar extends UIElement {
|
|
|
148
176
|
document.removeEventListener('pointerup', this.#onPointerUp);
|
|
149
177
|
this.#onPointerUp = null;
|
|
150
178
|
}
|
|
179
|
+
if (this.#onPaneTransitionEnd && this.#pane) {
|
|
180
|
+
this.#pane.removeEventListener('transitionend', this.#onPaneTransitionEnd);
|
|
181
|
+
this.#onPaneTransitionEnd = null;
|
|
182
|
+
}
|
|
151
183
|
}
|
|
152
184
|
|
|
153
185
|
// ── Public API ──
|
|
@@ -178,10 +210,44 @@ class EditorSidebar extends UIElement {
|
|
|
178
210
|
this.collapsed = false;
|
|
179
211
|
this.#persistWidth();
|
|
180
212
|
this.#emitToggle();
|
|
213
|
+
this.#markExpandInFlight();
|
|
181
214
|
}
|
|
182
215
|
|
|
183
216
|
// ── Internal ──
|
|
184
217
|
|
|
218
|
+
// §GH-223: suppress ResizeObserver-driven collapse-inference for the
|
|
219
|
+
// duration of this explicit expand()'s own CSS transition — see the
|
|
220
|
+
// #expandInFlight field comment for the full failure mode. Sized to the
|
|
221
|
+
// pane's ACTUAL computed transition-duration (not a hardcoded guess) so
|
|
222
|
+
// a customized `--editor-sidebar-duration` is honored; skipped entirely
|
|
223
|
+
// when the computed duration is 0 (no transition to race against —
|
|
224
|
+
// ResizeObserver's first sample already lands at the final width).
|
|
225
|
+
#markExpandInFlight() {
|
|
226
|
+
if (this.#expandSettleTimer !== null) {
|
|
227
|
+
clearTimeout(this.#expandSettleTimer);
|
|
228
|
+
this.#expandSettleTimer = null;
|
|
229
|
+
}
|
|
230
|
+
const durationStr = getComputedStyle(this.#pane).transitionDuration.split(',')[0].trim();
|
|
231
|
+
const durationMs = (parseFloat(durationStr) || 0) * (durationStr.endsWith('ms') ? 1 : 1000);
|
|
232
|
+
if (durationMs <= 0) return; // no transition — nothing to race
|
|
233
|
+
this.#expandInFlight = true;
|
|
234
|
+
// Fallback only — transitionend is the primary settle signal (fires
|
|
235
|
+
// first in the normal case). +50ms buffer for timer/paint jitter.
|
|
236
|
+
this.#expandSettleTimer = setTimeout(() => {
|
|
237
|
+
this.#expandSettleTimer = null;
|
|
238
|
+
this.#clearExpandInFlight();
|
|
239
|
+
this.#syncCollapsed();
|
|
240
|
+
}, durationMs + 50);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#clearExpandInFlight() {
|
|
244
|
+
this.#expandInFlight = false;
|
|
245
|
+
if (this.#expandSettleTimer !== null) {
|
|
246
|
+
clearTimeout(this.#expandSettleTimer);
|
|
247
|
+
this.#expandSettleTimer = null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
185
251
|
#syncCollapsed() {
|
|
186
252
|
if (!this.#pane) return;
|
|
187
253
|
if (getComputedStyle(this).display === 'none') return;
|
|
@@ -198,6 +264,14 @@ class EditorSidebar extends UIElement {
|
|
|
198
264
|
return;
|
|
199
265
|
}
|
|
200
266
|
|
|
267
|
+
// §GH-223: an explicit expand() is still mid-transition — this small
|
|
268
|
+
// sample is the pane animating THROUGH the sub-threshold range toward
|
|
269
|
+
// its (above-threshold) target, not a genuine settle. Trust the
|
|
270
|
+
// transitionend/timer handler for the authoritative final check;
|
|
271
|
+
// inferring collapse=true here is exactly the bug (the resulting
|
|
272
|
+
// `!important` CSS snap permanently interrupts the transition).
|
|
273
|
+
if (this.#expandInFlight) return;
|
|
274
|
+
|
|
201
275
|
// §FB-50: small sample — may be a CSS-grid collapse transient during
|
|
202
276
|
// the wide→narrow display-flip (pane shrinks before display:none
|
|
203
277
|
// propagates to getComputedStyle). Defer one animation frame; if
|
|
@@ -209,6 +283,7 @@ class EditorSidebar extends UIElement {
|
|
|
209
283
|
this.#pendingCollapseRaf = null;
|
|
210
284
|
if (!this.#pane) return;
|
|
211
285
|
if (getComputedStyle(this).display === 'none') return;
|
|
286
|
+
if (this.#expandInFlight) return;
|
|
212
287
|
const r = this.#pane.getBoundingClientRect();
|
|
213
288
|
if (r.width > 0 && r.width <= SNAP_THRESHOLD) {
|
|
214
289
|
this.collapsed = true;
|
|
@@ -207,6 +207,153 @@ describe('editor-sidebar', () => {
|
|
|
207
207
|
globalThis.getComputedStyle = origGCS;
|
|
208
208
|
});
|
|
209
209
|
|
|
210
|
+
// ── GH #223: expand() permanently wedges when the width transition is enabled ──
|
|
211
|
+
// Root cause: pane-ui's real CSS transition (editor-shell.bespoke.css) animates
|
|
212
|
+
// width from 48px toward the restore target; early ResizeObserver samples
|
|
213
|
+
// mid-transition are legitimately still <= SNAP_THRESHOLD, and the old
|
|
214
|
+
// #syncCollapsed() couldn't tell that apart from a genuine drag-to-collapse
|
|
215
|
+
// settle — flipping [collapsed] back to true mid-flight re-matches the
|
|
216
|
+
// `!important` collapsed-width CSS rule, which snaps the pane back to 48px
|
|
217
|
+
// and permanently interrupts the transition (no further resize ever occurs
|
|
218
|
+
// to self-correct). These tests mock a nonzero `transitionDuration` on the
|
|
219
|
+
// pane (jsdom/happy-dom has no real CSS loaded) to exercise the suppression.
|
|
220
|
+
describe('expand() transition race (GH #223)', () => {
|
|
221
|
+
function withPaneTransitionDuration(pane, durationStr) {
|
|
222
|
+
const origGCS = globalThis.getComputedStyle;
|
|
223
|
+
globalThis.getComputedStyle = (el, ps) => {
|
|
224
|
+
if (el === pane) return { ...origGCS(el, ps), transitionDuration: durationStr };
|
|
225
|
+
return origGCS(el, ps);
|
|
226
|
+
};
|
|
227
|
+
return () => { globalThis.getComputedStyle = origGCS; };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
it('does not latch [collapsed] on a small mid-transition RO sample while an explicit expand() is in flight', async () => {
|
|
231
|
+
let roCallback;
|
|
232
|
+
const savedRO = globalThis.ResizeObserver;
|
|
233
|
+
globalThis.ResizeObserver = class {
|
|
234
|
+
constructor(cb) { roCallback = cb; }
|
|
235
|
+
observe() {} unobserve() {} disconnect() {}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
|
|
239
|
+
const pane = sb.querySelector('pane-ui');
|
|
240
|
+
const restoreGCS = withPaneTransitionDuration(pane, '0.18s');
|
|
241
|
+
pane.style.width = '48px';
|
|
242
|
+
sb.collapsed = true;
|
|
243
|
+
|
|
244
|
+
sb.expand(); // sets pane width to the restore target + marks expand-in-flight
|
|
245
|
+
|
|
246
|
+
// Mid-transition: the observer samples an intermediate, still-small width.
|
|
247
|
+
pane.style.width = '77px';
|
|
248
|
+
if (roCallback) roCallback([{ target: pane }]);
|
|
249
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
250
|
+
await tick();
|
|
251
|
+
|
|
252
|
+
// Must NOT have snapped back to collapsed — this is exactly the wedge bug.
|
|
253
|
+
expect(sb.collapsed).toBe(false);
|
|
254
|
+
expect(sb.hasAttribute('collapsed')).toBe(false);
|
|
255
|
+
|
|
256
|
+
restoreGCS();
|
|
257
|
+
globalThis.ResizeObserver = savedRO;
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('clears expand-in-flight and re-syncs on transitionend, landing on the correct final state', async () => {
|
|
261
|
+
let roCallback;
|
|
262
|
+
const savedRO = globalThis.ResizeObserver;
|
|
263
|
+
globalThis.ResizeObserver = class {
|
|
264
|
+
constructor(cb) { roCallback = cb; }
|
|
265
|
+
observe() {} unobserve() {} disconnect() {}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
|
|
269
|
+
const pane = sb.querySelector('pane-ui');
|
|
270
|
+
const restoreGCS = withPaneTransitionDuration(pane, '0.18s');
|
|
271
|
+
pane.style.width = '48px';
|
|
272
|
+
sb.collapsed = true;
|
|
273
|
+
|
|
274
|
+
sb.expand();
|
|
275
|
+
pane.style.width = '240px'; // transition has now settled at its target
|
|
276
|
+
// `propertyName` is a TransitionEvent field, not a plain Event
|
|
277
|
+
// constructor option (the constructor silently drops unknown keys) —
|
|
278
|
+
// assign it as an own property after construction instead.
|
|
279
|
+
const transitionEnd = new Event('transitionend', { bubbles: false });
|
|
280
|
+
transitionEnd.propertyName = 'width';
|
|
281
|
+
pane.dispatchEvent(transitionEnd);
|
|
282
|
+
await tick();
|
|
283
|
+
|
|
284
|
+
expect(sb.collapsed).toBe(false);
|
|
285
|
+
expect(sb.hasAttribute('collapsed')).toBe(false);
|
|
286
|
+
|
|
287
|
+
// With expand-in-flight cleared, a genuinely small sample (e.g. a
|
|
288
|
+
// later user drag-to-collapse) must resume being trusted normally.
|
|
289
|
+
pane.style.width = '48px';
|
|
290
|
+
if (roCallback) roCallback([{ target: pane }]);
|
|
291
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
292
|
+
await tick();
|
|
293
|
+
expect(sb.collapsed).toBe(true);
|
|
294
|
+
|
|
295
|
+
restoreGCS();
|
|
296
|
+
globalThis.ResizeObserver = savedRO;
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('falls back to a bounded timer sized to the computed duration when transitionend never fires', async () => {
|
|
300
|
+
vi.useFakeTimers();
|
|
301
|
+
let roCallback;
|
|
302
|
+
const savedRO = globalThis.ResizeObserver;
|
|
303
|
+
globalThis.ResizeObserver = class {
|
|
304
|
+
constructor(cb) { roCallback = cb; }
|
|
305
|
+
observe() {} unobserve() {} disconnect() {}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
|
|
309
|
+
const pane = sb.querySelector('pane-ui');
|
|
310
|
+
const restoreGCS = withPaneTransitionDuration(pane, '0.18s');
|
|
311
|
+
pane.style.width = '48px';
|
|
312
|
+
sb.collapsed = true;
|
|
313
|
+
|
|
314
|
+
sb.expand();
|
|
315
|
+
pane.style.width = '77px'; // stuck reporting a small value; no transitionend ever arrives
|
|
316
|
+
if (roCallback) roCallback([{ target: pane }]);
|
|
317
|
+
await vi.advanceTimersByTimeAsync(50); // rAF-equivalent settle window (fake timers)
|
|
318
|
+
|
|
319
|
+
// Still suppressed — the 180ms+50ms safety timer hasn't elapsed yet.
|
|
320
|
+
expect(sb.collapsed).toBe(false);
|
|
321
|
+
|
|
322
|
+
pane.style.width = '240px'; // by the time the safety timer fires, transition is done
|
|
323
|
+
await vi.advanceTimersByTimeAsync(300); // past the 180+50ms fallback
|
|
324
|
+
expect(sb.collapsed).toBe(false); // final re-sync sees the real (large) width
|
|
325
|
+
|
|
326
|
+
restoreGCS();
|
|
327
|
+
globalThis.ResizeObserver = savedRO;
|
|
328
|
+
vi.useRealTimers();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('skips expand-in-flight suppression entirely when the computed transition duration is 0 (no transition to race)', async () => {
|
|
332
|
+
let roCallback;
|
|
333
|
+
const savedRO = globalThis.ResizeObserver;
|
|
334
|
+
globalThis.ResizeObserver = class {
|
|
335
|
+
constructor(cb) { roCallback = cb; }
|
|
336
|
+
observe() {} unobserve() {} disconnect() {}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
|
|
340
|
+
const pane = sb.querySelector('pane-ui');
|
|
341
|
+
// No transitionDuration mock — jsdom/happy-dom defaults to '0s'.
|
|
342
|
+
pane.style.width = '48px';
|
|
343
|
+
sb.collapsed = true;
|
|
344
|
+
|
|
345
|
+
sb.expand();
|
|
346
|
+
// With a 0-duration transition, the width jump is instantaneous — the
|
|
347
|
+
// observer's first sample already lands at the final (large) width.
|
|
348
|
+
pane.style.width = '240px';
|
|
349
|
+
if (roCallback) roCallback([{ target: pane }]);
|
|
350
|
+
await tick();
|
|
351
|
+
|
|
352
|
+
expect(sb.collapsed).toBe(false);
|
|
353
|
+
globalThis.ResizeObserver = savedRO;
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
210
357
|
it('handles missing inner <pane-ui> gracefully (no crash)', () => {
|
|
211
358
|
const sb = mount('<editor-sidebar slot="leading"></editor-sidebar>');
|
|
212
359
|
// Should not throw
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/web-modules",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.28",
|
|
4
4
|
"description": "AdiaUI composite custom elements \u2014 shell, chat, editor, runtime clusters built from @adia-ai/web-components primitives. Subpath exports per cluster.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|