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:
parent
383441edf7
commit
e0d976ac92
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ── */
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue