feat(repo-tabs): add drag-and-drop reordering for repo tabs

Add pointer-driven drag-and-drop reordering for repository tabs. A
drag state tracks source and target, supports auto-scrolling, and shows
a live animated preview while dragging; accidental clicks are suppressed.
Reorders commit on pointerup and can be cancelled via Escape, blur, or
pointercancel, and reordering is disabled while the app is busy. The
new order is persisted through a callback to the main app.

- Implement pointer handlers, drag lifecycle, and click suppression
- Add transform-based preview, transitional styling, and reduced-motion support
- Wire reorder callback to persist the updated tab order
This commit is contained in:
2026-09-11 14:10:05 +02:00
parent eb4346fce2
commit 54972f24b8
2 changed files with 92 additions and 4 deletions
+78 -4
View File
@@ -20,8 +20,67 @@
export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {};
export let onContextMenu: (path: string, event: MouseEvent) => void = () => {};
export let onAdd: () => void | Promise<void> = () => {};
export let onReorder: (path: string, targetPath: string, after: boolean) => void = () => {};
let navigation: HTMLElement;
let drag: { path: string; pointerId: number; startX: number; startScroll: number; source: number; target: number; width: number; centers: number[]; element: HTMLElement } | null = null;
let dragging = false;
let offset = 0;
let suppressClick = false;
function startDrag(event: PointerEvent, path: string) {
if (isBusy || event.button !== 0 || !event.isPrimary) return;
const element = event.currentTarget as HTMLElement;
const tabs = Array.from(navigation.querySelectorAll<HTMLElement>(".repository-tab"));
const source = repoTabs.findIndex(tab => tab.path === path);
drag = { path, pointerId: event.pointerId, startX: event.clientX,
startScroll: navigation.scrollLeft, source, target: source,
width: tabs[source].getBoundingClientRect().width + 4,
centers: tabs.map(tab => { const rect = tab.getBoundingClientRect(); return rect.left + rect.width / 2; }), element };
suppressClick = false;
element.setPointerCapture(event.pointerId);
}
function moveDrag(event: PointerEvent) {
if (!drag || event.pointerId !== drag.pointerId) return;
if (isBusy) { finishDrag(false); return; }
if (!dragging && Math.abs(event.clientX - drag.startX) < 5) return;
dragging = true;
suppressClick = true;
const bounds = navigation.getBoundingClientRect();
if (event.clientX < bounds.left + 35) navigation.scrollLeft -= 15;
if (event.clientX > bounds.right - 35) navigation.scrollLeft += 15;
offset = event.clientX - drag.startX + navigation.scrollLeft - drag.startScroll;
const center = drag.centers[drag.source] + offset;
let target = drag.source;
while (target < drag.centers.length - 1 && center > drag.centers[target + 1]) target++;
while (target > 0 && center < drag.centers[target - 1]) target--;
drag = { ...drag, target };
}
function finishDrag(commit: boolean) {
const current = drag;
if (!current) return;
drag = null;
if (current.element.hasPointerCapture(current.pointerId)) current.element.releasePointerCapture(current.pointerId);
if (commit && dragging && !isBusy && current.target !== current.source) {
onReorder(current.path, repoTabs[current.target].path, current.target > current.source);
}
dragging = false;
offset = 0;
}
function tabOffset(index: number, current: typeof drag, moving: boolean, displacement: number) {
if (!current || !moving) return 0;
if (index === current.source) return displacement;
if (current.source < index && index <= current.target) return -current.width;
if (current.target <= index && index < current.source) return current.width;
return 0;
}
</script>
<svelte:window onpointermove={moveDrag} onpointerup={(event) => { if (event.pointerId === drag?.pointerId) finishDrag(true); }} onpointercancel={() => finishDrag(false)} onblur={() => finishDrag(false)} onkeydown={(event) => { if (event.key === "Escape") finishDrag(false); }}/>
<header class="workspace-navigation">
<nav class="global-navigation" aria-label={language === "de" ? "Hauptnavigation" : "Main navigation"}>
<button type="button" class:active={activeView === "management"} aria-current={activeView === "management" ? "page" : undefined} disabled={isBusy} onclick={onOpenManagement}><House size={17}/><span>Dashboard</span></button>
@@ -30,10 +89,15 @@
<button type="button" class:active={activeView === "issues"} aria-current={activeView === "issues" ? "page" : undefined} disabled={isBusy} onclick={onOpenIssues}><Columns3 size={17}/><span>Issues &amp; Boards</span></button>
</nav>
{#if activeView === "repository"}
<nav class="repository-navigation" aria-label={language === "de" ? "Geöffnete Repositories" : "Open repositories"}>
{#each repoTabs as repo (repo.path)}
<div class="repository-tab" class:active={isActive(repo.path)} role="presentation" oncontextmenu={(event) => onContextMenu(repo.path, event)}>
<button class="repository-select" type="button" onclick={() => onSelect(repo.path)} disabled={isBusy} title={repo.path} aria-current={isActive(repo.path) ? "page" : undefined}><Folder size={16}/><span>{repo.name}</span></button>
<nav bind:this={navigation} class="repository-navigation" class:reordering={dragging} aria-label={language === "de" ? "Geöffnete Repositories" : "Open repositories"}>
{#each repoTabs as repo, index (repo.path)}
<div class="repository-tab" class:active={isActive(repo.path)}
class:dragging={dragging && drag?.path === repo.path}
style:transform={`translateX(${tabOffset(index, drag, dragging, offset)}px)`}
role="presentation" oncontextmenu={(event) => onContextMenu(repo.path, event)}>
<button class="repository-select" type="button" onpointerdown={(event) => startDrag(event, repo.path)}
onlostpointercapture={() => { if (drag) finishDrag(false); }}
onclick={(event) => { if (suppressClick) { event.preventDefault(); suppressClick = false; return; } onSelect(repo.path); }} disabled={isBusy} title={repo.path} aria-current={isActive(repo.path) ? "page" : undefined}><Folder size={16}/><span>{repo.name}</span></button>
<button class="repository-close" type="button" onclick={(event) => onClose(repo.path, event)} disabled={isBusy} aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}><X size={13}/></button>
</div>
{/each}
@@ -57,6 +121,16 @@
.repository-tab{display:flex;flex:0 0 auto;align-items:center;min-width:100px;max-width:250px;border:1px solid var(--color-border);border-bottom:0;border-radius:5px 5px 0 0;background:var(--app-bg)}
.repository-tab.active{background:var(--color-surface-raised);border-color:var(--color-border-input);color:var(--color-ink)}
.repository-tab:hover{background:var(--color-surface-hover)}
.repository-tab{position:relative;will-change:transform}
/* Animate the preview only. On drop, the new DOM order replaces the
transforms in the same render, so resetting them must not animate. */
.reordering .repository-tab{transition:transform 160ms ease}
.reordering .repository-tab.dragging{z-index:2;transition:none;background:var(--color-surface-raised);border-color:var(--color-accent);box-shadow:0 2px 12px #0005}
.repository-navigation.reordering,.reordering .repository-select{cursor:grabbing}
.repository-select{touch-action:pan-y;user-select:none}
@media(prefers-reduced-motion:reduce){.reordering .repository-tab{transition:none}}
.repository-select:not(:disabled){cursor:grab}
.reordering .repository-select:not(:disabled){cursor:grabbing}
.repository-select{display:flex;flex:1;min-width:0;align-items:center;gap:7px;min-height:29px;padding:0 9px;font-size:12px;text-align:left}
.repository-select span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.repository-select>:global(svg){flex-shrink:0}