diff --git a/web/src/lib/navigation/Tabs/OTabs.vue b/web/src/lib/navigation/Tabs/OTabs.vue index 7824a12bd9..64ec94a30c 100644 --- a/web/src/lib/navigation/Tabs/OTabs.vue +++ b/web/src/lib/navigation/Tabs/OTabs.vue @@ -99,11 +99,74 @@ function onTabDrop(e: DragEvent): void { const from = draggingName.value ?? e.dataTransfer?.getData("text/plain") ?? null; const to = dropTargetName.value; if (from != null && to != null && from !== to) { + // FLIP: sample the current tab positions BEFORE the parent applies the + // reorder, then slide each tab from its old slot to its new one once the + // DOM has patched — so the drop settles with motion instead of a jump. + captureFlipFirst(); emit("reorder", { from, to, before: dropBefore.value }); + nextTick(() => playFlip()); } clearDrag(); } +// ── FLIP reorder animation ───────────────────────────────────────────────── +// The parent owns the list, so OTabs can't reorder the DOM itself — it only +// animates whatever move the parent applies in response to `reorder`. "First" +// rects are captured just before we emit; "Last" rects after Vue patches the +// DOM on the next tick. Each moved tab is offset back to its old x with no +// transition, then released to slide to identity. Purely visual; if the parent +// declines the move (no DOM change) every delta is 0 and this is a no-op. +const flipFirst = new Map(); + +function tabButtons(): HTMLElement[] { + const list = tablistRef.value; + if (!list) return []; + return Array.from(list.querySelectorAll("[data-otab-name]")); +} + +function captureFlipFirst(): void { + flipFirst.clear(); + for (const el of tabButtons()) { + const name = el.dataset.otabName; + if (name != null) flipFirst.set(name, el.getBoundingClientRect().left); + } +} + +function playFlip(): void { + if (flipFirst.size === 0) return; + const moved: HTMLElement[] = []; + for (const el of tabButtons()) { + const name = el.dataset.otabName; + const first = name != null ? flipFirst.get(name) : undefined; + if (first == null) continue; + const dx = first - el.getBoundingClientRect().left; + if (!dx) continue; + el.style.transition = "none"; + el.style.transform = `translateX(${dx}px)`; + moved.push(el); + } + flipFirst.clear(); + if (moved.length === 0) return; + // Commit the inverted starting transforms before releasing them. + void tablistRef.value?.offsetWidth; + requestAnimationFrame(() => { + for (const el of moved) { + el.style.transition = "transform 250ms cubic-bezier(0.2, 0, 0, 1)"; + el.style.transform = ""; + } + }); +} + +function onTabTransitionEnd(e: TransitionEvent): void { + // Clear the inline FLIP styles once the slide finishes so nothing lingers on + // the tab (and a later reorder starts from a clean transform). + if (e.propertyName !== "transform") return; + const el = (e.target as HTMLElement | null)?.closest("[data-otab-name]"); + if (!el) return; + el.style.transition = ""; + el.style.transform = ""; +} + function onTabDragEnd(): void { clearDrag(); } @@ -283,6 +346,7 @@ const alignClasses: Record, string> = { @dragover="onTabDragOver" @drop="onTabDrop" @dragend="onTabDragEnd" + @transitionend="onTabTransitionEnd" > @@ -328,6 +392,7 @@ const alignClasses: Record, string> = { @dragover="onTabDragOver" @drop="onTabDrop" @dragend="onTabDragEnd" + @transitionend="onTabTransitionEnd" >