fix(tabs): track active tab correctly on close + long-press reorder

currentModuleId was a const that never updated on client-side tab
switches, causing close to either do nothing or switch to the wrong
tab. Now uses tabBar.active as source of truth and picks the nearest
remaining tab on close. Also adds touch long-press (400ms) drag
reorder for mobile tabs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-16 17:37:24 -07:00
parent 383441edf7
commit e0d976ac92
2 changed files with 103 additions and 6 deletions

View File

@ -479,7 +479,7 @@ export function renderShell(opts: ShellOptions): string {
// the next page load reads the existing tabs and adds the new module.
const tabBar = document.querySelector('rstack-tab-bar');
const spaceSlug = '${escapeAttr(spaceSlug)}';
const currentModuleId = '${escapeAttr(moduleId)}';
let currentModuleId = '${escapeAttr(moduleId)}';
const TABS_KEY = 'rspace_tabs_' + spaceSlug;
const moduleList = ${moduleListJSON};
@ -611,6 +611,7 @@ export function renderShell(opts: ShellOptions): string {
// This prevents the visual desync where the tab highlights before content loads.
tabBar.addEventListener('layer-switch', (e) => {
const { layerId, moduleId } = e.detail;
currentModuleId = moduleId;
saveTabs();
// Update settings panel to show config for the newly active module
const sp = document.querySelector('rstack-space-settings');
@ -630,6 +631,7 @@ export function renderShell(opts: ShellOptions): string {
tabBar.addEventListener('layer-add', (e) => {
const { moduleId } = e.detail;
currentModuleId = moduleId;
if (!layers.find(l => l.moduleId === moduleId)) {
layers.push(makeLayer(moduleId, layers.length));
}
@ -652,6 +654,9 @@ export function renderShell(opts: ShellOptions): string {
const { layerId } = e.detail;
const closedLayer = layers.find(l => l.id === layerId);
const closedModuleId = layerId.replace('layer-', '');
const closedIdx = layers.findIndex(l => l.id === layerId);
// Use tabBar.active as source of truth (always updated by TabCache)
const wasActive = layerId === tabBar.getAttribute('active');
tabBar.removeLayer(layerId);
layers = layers.filter(l => l.id !== layerId);
saveTabs();
@ -682,10 +687,13 @@ export function renderShell(opts: ShellOptions): string {
if (app) app.classList.remove('canvas-layout');
tabBar.setAttribute('active', '');
tabBar.setLayers([]);
currentModuleId = '';
history.pushState({ dashboard: true, spaceSlug: spaceSlug }, '', '/' + spaceSlug);
} else if (layerId === 'layer-' + currentModuleId) {
// Closed the active tab — switch to the first remaining
const nextModuleId = layers[0].moduleId;
} else if (wasActive) {
// Closed the active tab — switch to the nearest remaining tab
const nextLayer = layers[Math.min(closedIdx, layers.length - 1)] || layers[0];
const nextModuleId = nextLayer.moduleId;
currentModuleId = nextModuleId;
if (tabCache) {
tabCache.switchTo(nextModuleId).then(ok => {
if (!ok) window.location.href = window.__rspaceNavUrl(spaceSlug, nextModuleId);
@ -720,6 +728,7 @@ export function renderShell(opts: ShellOptions): string {
}
const modId = targetModule || 'rspace';
currentModuleId = modId;
// Add tab if not already present
if (!layers.find(l => l.moduleId === modId)) {
layers.push(makeLayer(modId, layers.length));
@ -802,6 +811,7 @@ export function renderShell(opts: ShellOptions): string {
tabCache.switchTo(moduleId);
return;
}
currentModuleId = moduleId;
// Add tab if not already open
if (!layers.find(l => l.moduleId === moduleId)) {
const newLayer = makeLayer(moduleId, layers.length);

View File

@ -117,6 +117,13 @@ export class RStackTabBar extends HTMLElement {
#escHandler: ((e: KeyboardEvent) => void) | null = null;
// Cleanup for document-level listeners (prevent leak on re-render)
#docCleanup: (() => void) | null = null;
// Touch-drag reorder state (long-press to reorder on mobile)
#touchDragTimer: ReturnType<typeof setTimeout> | null = null;
#touchDragActive = false;
#touchDragLayerId: string | null = null;
#touchDragClone: HTMLElement | null = null;
#touchStartX = 0;
#touchStartY = 0;
// Recent apps: moduleId → last-used timestamp
#recentApps: Map<string, number> = new Map();
@ -941,8 +948,7 @@ export class RStackTabBar extends HTMLElement {
// The shell's event handler calls switchTo() and sets active only after success.
this.#shadow.querySelectorAll<HTMLElement>(".tab").forEach(tab => {
tab.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.classList.contains("tab-close")) return;
if ((e.target as HTMLElement).closest(".tab-close")) return;
const layerId = tab.dataset.layerId!;
const moduleId = tab.dataset.moduleId!;
this.trackRecent(moduleId);
@ -985,6 +991,86 @@ export class RStackTabBar extends HTMLElement {
bubbles: true,
}));
});
// Touch long-press reorder (mobile)
tab.addEventListener("touchstart", (e) => {
if ((e.target as HTMLElement).closest(".tab-close")) return;
const touch = e.touches[0];
this.#touchStartX = touch.clientX;
this.#touchStartY = touch.clientY;
this.#touchDragTimer = setTimeout(() => {
this.#touchDragActive = true;
this.#touchDragLayerId = tab.dataset.layerId!;
tab.classList.add("dragging");
// Haptic feedback if available
if (navigator.vibrate) navigator.vibrate(30);
// Create floating clone
const rect = tab.getBoundingClientRect();
const clone = tab.cloneNode(true) as HTMLElement;
clone.className = "tab active touch-drag-clone";
clone.style.cssText = `position:fixed;left:${rect.left}px;top:${rect.top}px;width:${rect.width}px;z-index:9999;pointer-events:none;opacity:0.85;box-shadow:0 4px 16px rgba(0,0,0,0.4);`;
this.#shadow.appendChild(clone);
this.#touchDragClone = clone;
}, 400);
}, { passive: true });
tab.addEventListener("touchmove", (e) => {
const touch = e.touches[0];
// Cancel long-press if finger moved too far before activation
if (!this.#touchDragActive && this.#touchDragTimer) {
const dx = touch.clientX - this.#touchStartX;
const dy = touch.clientY - this.#touchStartY;
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
clearTimeout(this.#touchDragTimer);
this.#touchDragTimer = null;
}
return;
}
if (!this.#touchDragActive) return;
e.preventDefault();
// Move the floating clone
if (this.#touchDragClone) {
this.#touchDragClone.style.left = `${touch.clientX - this.#touchDragClone.offsetWidth / 2}px`;
this.#touchDragClone.style.top = `${touch.clientY - this.#touchDragClone.offsetHeight - 10}px`;
}
// Highlight the tab under the finger
this.#shadow.querySelectorAll<HTMLElement>(".tab").forEach(t => t.classList.remove("drag-over"));
const elUnder = this.#shadow.elementFromPoint(touch.clientX, touch.clientY) as HTMLElement | null;
const tabUnder = elUnder?.closest?.(".tab") as HTMLElement | null;
if (tabUnder && tabUnder.dataset.layerId !== this.#touchDragLayerId) {
tabUnder.classList.add("drag-over");
}
}, { passive: false });
tab.addEventListener("touchend", (e) => {
if (this.#touchDragTimer) { clearTimeout(this.#touchDragTimer); this.#touchDragTimer = null; }
if (!this.#touchDragActive) return;
e.preventDefault();
// Find drop target
const touch = e.changedTouches[0];
this.#shadow.querySelectorAll<HTMLElement>(".tab").forEach(t => t.classList.remove("drag-over", "dragging"));
const elUnder = this.#shadow.elementFromPoint(touch.clientX, touch.clientY) as HTMLElement | null;
const tabUnder = elUnder?.closest?.(".tab") as HTMLElement | null;
if (tabUnder && tabUnder.dataset.layerId !== this.#touchDragLayerId) {
const targetIdx = this.#layers.findIndex(l => l.id === tabUnder.dataset.layerId);
this.dispatchEvent(new CustomEvent("layer-reorder", {
detail: { layerId: this.#touchDragLayerId, newIndex: targetIdx },
bubbles: true,
}));
}
// Cleanup
if (this.#touchDragClone) { this.#touchDragClone.remove(); this.#touchDragClone = null; }
this.#touchDragActive = false;
this.#touchDragLayerId = null;
});
tab.addEventListener("touchcancel", () => {
if (this.#touchDragTimer) { clearTimeout(this.#touchDragTimer); this.#touchDragTimer = null; }
if (this.#touchDragClone) { this.#touchDragClone.remove(); this.#touchDragClone = null; }
this.#shadow.querySelectorAll<HTMLElement>(".tab").forEach(t => t.classList.remove("drag-over", "dragging"));
this.#touchDragActive = false;
this.#touchDragLayerId = null;
});
});
// Close buttons
@ -1511,6 +1597,7 @@ const STYLES = `
.tab { cursor: grab; }
.tab.dragging { opacity: 0.4; cursor: grabbing; }
.tab.drag-over { box-shadow: inset 2px 0 0 #22d3ee; }
.touch-drag-clone { transition: none; border-radius: 6px; }
/* ── Add button ── */