feat(sidebar): add section menu to toggle left-sidebar panels

Add a floating SidebarSectionMenu component and wiring in App.svelte to let users show/hide individual left-sidebar panels (worktrees, tags, stashes, files). Visibility is tracked in new sidebarVisibility state and persisted to localStorage under SIDEBAR_VISIBILITY_KEY. The menu opens via contextmenu on the left sidebar and returns focus to the invoking element when closed.

- Panels and resize handles now respect visibility (buildLeftSidebarRows, sidebarHandleVisible, expandedSidebarPanels).
- Branch panel remains always visible and cannot be toggled; stored preferences only apply to the other panels.
- Safe fallbacks: localStorage errors are ignored so the feature still works without persistence; menu includes keyboard navigation and appropriate ARIA roles.
This commit is contained in:
2026-09-18 20:05:19 +02:00
parent 985e812209
commit e6797bdb81
2 changed files with 150 additions and 9 deletions
@@ -0,0 +1,79 @@
<script lang="ts">
import { onMount } from "svelte";
import { Check, PanelLeft, GitFork, Tags, Archive, FolderTree } from "@lucide/svelte";
let { x, y, language, sections, onToggle, onClose }: {
x: number; y: number; language: "de" | "en";
sections: { id: string; label: string; visible: boolean }[];
onToggle: (id: string) => void;
onClose: () => void;
} = $props();
let menu: HTMLDivElement;
let viewport = $state({ width: window.innerWidth, height: window.innerHeight });
let menuWidth = $state(284);
let menuHeight = $state(272);
const details: Record<string, { icon: typeof Tags; de: string; en: string }> = {
worktree: { icon: GitFork, de: "Parallele Arbeitsverzeichnisse", en: "Parallel working directories" },
tags: { icon: Tags, de: "Markierte Versionen", en: "Tagged versions" },
stash: { icon: Archive, de: "Zwischengespeicherte Änderungen", en: "Saved changes" },
explorer: { icon: FolderTree, de: "Dateien im Repository", en: "Repository files" },
};
const visibleCount = $derived(sections.filter(section => section.visible).length);
const left = $derived(Math.max(8, Math.min(x, viewport.width - menuWidth - 8)));
const top = $derived(Math.max(8, Math.min(y, viewport.height - menuHeight - 8)));
onMount(() => { menu.focus(); });
function keyboard(event: KeyboardEvent) {
const buttons = [...menu.querySelectorAll<HTMLButtonElement>("button")];
const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
event.preventDefault();
const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : (index < 0 ? (event.key === "ArrowDown" ? 0 : buttons.length - 1) : (index + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length);
buttons[next]?.focus();
} else if (event.key === "Escape" || event.key === "Tab") {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); }
onClose();
}
}
</script>
<svelte:window
onpointerdown={(event) => { if (!menu.contains(event.target as Node)) onClose(); }}
onresize={() => { viewport = { width: window.innerWidth, height: window.innerHeight }; }}
/>
<div bind:this={menu} bind:clientWidth={menuWidth} bind:clientHeight={menuHeight} class="sidebar-section-menu" style:left="{left}px" style:top="{top}px" role="menu" tabindex="-1"
aria-label={language === "de" ? "Sidebar-Bereiche" : "Sidebar sections"} onkeydown={keyboard}
oncontextmenu={(event) => { event.preventDefault(); event.stopPropagation(); }}>
<div class="menu-heading">
<span class="heading-icon" aria-hidden="true"><PanelLeft size={18} strokeWidth={1.7} /></span>
<div class="heading-copy"><span class="eyebrow">Sidebar</span><strong>{language === "de" ? "Bereiche anzeigen" : "Show sections"}</strong></div>
<span class="section-count" aria-hidden="true">{visibleCount}<span>/{sections.length}</span></span>
</div>
<div class="menu-items" role="group">
{#each sections as section (section.id)}
{@const detail = details[section.id]}
<button type="button" role="menuitemcheckbox" aria-label={section.label} aria-checked={section.visible} onclick={() => onToggle(section.id)}>
<span class="section-icon" aria-hidden="true">{#if detail}<detail.icon size={17} strokeWidth={1.65} />{/if}</span>
<span class="section-copy"><strong>{section.label}</strong>{#if detail}<small>{language === "de" ? detail.de : detail.en}</small>{/if}</span>
<span class="check" class:checked={section.visible} aria-hidden="true">{#if section.visible}<Check size={12} strokeWidth={2.3} />{/if}</span>
</button>
{/each}
</div>
</div>
<style>
.sidebar-section-menu{position:fixed;z-index:10000;box-sizing:border-box;width:284px;max-width:calc(100vw - 16px);max-height:calc(100vh - 16px);overflow-y:auto;padding:6px;border:1px solid color-mix(in srgb,var(--color-accent) 18%,var(--color-border));border-radius:10px;background:var(--app-dialog-bg);box-shadow:0 12px 36px #0004,0 2px 8px #0002;color:var(--color-ink);font:12px/1.4 var(--font-sans);outline:none}
.menu-heading{display:flex;align-items:center;gap:10px;padding:10px 9px 13px;margin-bottom:5px;border-bottom:1px solid var(--color-border-subtle)}
.heading-icon{display:grid;place-items:center;flex:0 0 34px;height:34px;border:1px solid color-mix(in srgb,var(--color-accent) 24%,transparent);border-radius:7px;background:color-mix(in srgb,var(--color-accent) 9%,transparent);color:var(--color-accent)}
.heading-copy{display:grid;gap:2px;flex:1;min-width:0}.eyebrow{font-size:9px;font-weight:650;letter-spacing:.09em;text-transform:uppercase;color:var(--color-ink-muted)}.heading-copy strong{font-size:12px;font-weight:650}
.section-count{padding:3px 6px;border:1px solid var(--color-border-subtle);border-radius:5px;background:var(--color-surface);color:var(--color-ink-muted);font-size:10px;font-variant-numeric:tabular-nums}.section-count span{color:var(--color-ink-faint);margin-left:2px}
.menu-items{display:grid;gap:2px}
.sidebar-section-menu button{display:flex;align-items:center;justify-content:flex-start;gap:11px;width:100%;min-height:48px;padding:8px 10px;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--color-ink);font:inherit;text-align:left;cursor:pointer;box-shadow:none;transition:background .12s,border-color .12s}
.sidebar-section-menu button:hover{background:var(--color-surface-hover)}
.sidebar-section-menu button:focus-visible{outline:none;border-color:color-mix(in srgb,var(--color-accent) 55%,transparent);background:color-mix(in srgb,var(--color-accent) 8%,var(--app-dialog-bg))}
.section-icon{display:grid;place-items:center;width:20px;flex-shrink:0;color:var(--color-ink-muted)}
.section-copy{display:grid;gap:2px;min-width:0;flex:1}.section-copy strong{font-size:12px;font-weight:600;line-height:1.3}.section-copy small{font-size:10px;font-weight:400;line-height:1.4;color:var(--color-ink-muted)}
.check{display:grid;place-items:center;width:16px;height:16px;flex-shrink:0;border:1px solid var(--color-border-input);border-radius:4px;background:var(--app-input-bg);color:var(--color-accent)}
.check.checked{border-color:color-mix(in srgb,var(--color-accent) 45%,transparent);background:color-mix(in srgb,var(--color-accent) 12%,transparent)}
@media(prefers-reduced-motion:reduce){.sidebar-section-menu button{transition:none}}
</style>