Merge: Resizable sidebar width — drag handle + persistence (t-paliad-047)

This commit is contained in:
m
2026-04-26 15:27:39 +02:00
4 changed files with 186 additions and 3 deletions

View File

@@ -792,6 +792,8 @@ const translations: Record<Lang, Record<string, string>> = {
"palette.footer.open": "Öffnen",
"palette.footer.close": "Schließen",
"sidebar.resize.title": "Breite anpassen — ziehen, Doppelklick setzt zurück",
// Settings page (t-paliad-022)
"einstellungen.title": "Einstellungen \u2014 Paliad",
"einstellungen.heading": "Einstellungen",
@@ -1953,6 +1955,8 @@ const translations: Record<Lang, Record<string, string>> = {
"palette.footer.open": "Open",
"palette.footer.close": "Close",
"sidebar.resize.title": "Resize \u2014 drag, double-click to reset",
// Settings page (t-paliad-022)
"einstellungen.title": "Settings \u2014 Paliad",
"einstellungen.heading": "Settings",

View File

@@ -5,6 +5,10 @@ import { initGlobalSearch } from "./search";
import { getChangelogSeen } from "./changelog-seen";
const PIN_KEY = "paliad-sidebar-pinned";
const LEGACY_PIN_KEY = "patholo-sidebar-pinned";
const WIDTH_KEY = "paliad-sidebar-width";
const SIDEBAR_WIDTH_MIN = 180;
const SIDEBAR_WIDTH_MAX = 480;
const SIDEBAR_WIDTH_DEFAULT = 240;
// toggleMobileSidebar opens or closes the slide-out drawer. Exposed so the
// BottomNav menu slot can call it without duplicating the open/close
@@ -25,6 +29,24 @@ export function toggleMobileSidebar(): void {
}
}
// readStoredWidth returns the user's persisted sidebar width in px, falling
// back to SIDEBAR_WIDTH_DEFAULT when missing, malformed, or outside the
// supported clamp range. Stored values are written by initSidebarResize on
// dragend; the read happens on every page load (initial CSS-var apply).
function readStoredWidth(): number {
const raw = localStorage.getItem(WIDTH_KEY);
if (raw === null) return SIDEBAR_WIDTH_DEFAULT;
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n < SIDEBAR_WIDTH_MIN || n > SIDEBAR_WIDTH_MAX) {
return SIDEBAR_WIDTH_DEFAULT;
}
return n;
}
function applySidebarWidth(px: number): void {
document.documentElement.style.setProperty("--sidebar-width", `${px}px`);
}
// migrateLegacyPinKey copies the pre-rebrand pin state into the new key on
// first load and removes the stale entry. Drop this fallback once the rename
// grace period is over.
@@ -39,11 +61,16 @@ function migrateLegacyPinKey(): void {
export function initSidebar() {
migrateLegacyPinKey();
// Apply the persisted width before any sidebar paint so the layout is
// stable from frame 1. Safe to call before the .sidebar element exists —
// the CSS var lives on document.documentElement.
applySidebarWidth(readStoredWidth());
initInviteModal();
initGlobalSearch();
initChangelogBadge();
const sidebar = document.querySelector<HTMLElement>(".sidebar");
if (!sidebar) return;
initSidebarResize(sidebar);
const pinBtn = sidebar.querySelector<HTMLButtonElement>(".sidebar-pin");
const hamburger = document.querySelector<HTMLButtonElement>(".sidebar-hamburger");
@@ -74,6 +101,10 @@ export function initSidebar() {
clearTimeout(hoverTimer);
hoverTimer = null;
}
// Mid-resize drags briefly pull the cursor off the sidebar — keep
// the sidebar expanded so the user can finish the drag. The resizing
// class is cleared on dragend; the next mouseleave will collapse.
if (sidebar.classList.contains("resizing")) return;
sidebar.classList.remove("expanded");
});
@@ -145,6 +176,99 @@ export function initSidebar() {
});
}
// initSidebarResize wires the right-edge drag handle: mousedown/touchstart
// captures the starting cursor position and current width, mousemove/touchmove
// applies the delta as --sidebar-width (clamped), mouseup/touchend persists
// the final value to localStorage. Double-click on the handle resets to the
// default width. The .resizing class on the sidebar suppresses both the
// width transition (avoid jitter while dragging) and the hover-collapse
// (the cursor leaves the sidebar mid-drag).
function initSidebarResize(sidebar: HTMLElement): void {
const handle = sidebar.querySelector<HTMLElement>(".sidebar-resize-handle");
if (!handle) return;
let dragging = false;
let startX = 0;
let startWidth = 0;
function applyDelta(clientX: number): void {
const delta = clientX - startX;
let next = startWidth + delta;
if (next < SIDEBAR_WIDTH_MIN) next = SIDEBAR_WIDTH_MIN;
if (next > SIDEBAR_WIDTH_MAX) next = SIDEBAR_WIDTH_MAX;
applySidebarWidth(next);
}
function readCurrentWidth(): number {
const raw = getComputedStyle(document.documentElement)
.getPropertyValue("--sidebar-width")
.trim();
const n = parseInt(raw, 10);
return Number.isFinite(n) ? n : SIDEBAR_WIDTH_DEFAULT;
}
function endDrag(): void {
if (!dragging) return;
dragging = false;
sidebar.classList.remove("resizing");
document.body.classList.remove("sidebar-resizing");
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", endDrag);
document.removeEventListener("touchmove", onTouchMove);
document.removeEventListener("touchend", endDrag);
document.removeEventListener("touchcancel", endDrag);
localStorage.setItem(WIDTH_KEY, String(readCurrentWidth()));
}
function onMouseMove(e: MouseEvent): void {
applyDelta(e.clientX);
}
function onTouchMove(e: TouchEvent): void {
if (e.touches.length === 0) return;
applyDelta(e.touches[0].clientX);
// preventDefault stops the browser from interpreting the drag as a
// page scroll. touch-action: none on the handle covers most browsers,
// but Safari still needs the explicit call.
if (e.cancelable) e.preventDefault();
}
function startDrag(clientX: number): void {
dragging = true;
startX = clientX;
// Use the rendered sidebar width — robust against any future state
// (pinned vs hover-expanded) where the CSS var might not be the
// single source of truth.
startWidth = sidebar.getBoundingClientRect().width;
sidebar.classList.add("resizing");
document.body.classList.add("sidebar-resizing");
}
handle.addEventListener("mousedown", (e) => {
// Only primary button. Right/middle clicks would otherwise stick the
// sidebar in resizing state until a real mouseup arrives.
if (e.button !== 0) return;
e.preventDefault();
startDrag(e.clientX);
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", endDrag);
});
handle.addEventListener("touchstart", (e) => {
if (e.touches.length === 0) return;
startDrag(e.touches[0].clientX);
document.addEventListener("touchmove", onTouchMove, { passive: false });
document.addEventListener("touchend", endDrag);
document.addEventListener("touchcancel", endDrag);
}, { passive: true });
handle.addEventListener("dblclick", (e) => {
e.preventDefault();
applySidebarWidth(SIDEBAR_WIDTH_DEFAULT);
localStorage.setItem(WIDTH_KEY, String(SIDEBAR_WIDTH_DEFAULT));
});
}
// Changelog badge — fetches the count of entries newer than the locally
// stored "last seen" stamp and renders a dot + number on the Neuigkeiten
// link. Skipped on the changelog page itself because changelog.ts stamps

View File

@@ -161,6 +161,13 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
<span className="sidebar-label" data-i18n="nav.logout">Abmelden</span>
</a>
</div>
{/* Drag-strip on the right edge — sidebar.ts wires mouse/touch
handlers and persists the chosen width to localStorage.
CSS hides it when the sidebar is collapsed/icon-rail or on mobile. */}
<div className="sidebar-resize-handle" role="separator" aria-orientation="vertical"
aria-label="Resize sidebar"
title="Breite anpassen" data-i18n-title="sidebar.resize.title" />
</aside>
<button className="sidebar-hamburger" type="button" aria-label="Menu">

View File

@@ -18,6 +18,12 @@
--max-width: 1080px;
--sidebar-collapsed: 64px;
--sidebar-expanded: 240px;
/* User-adjustable width for the expanded/pinned desktop sidebar.
Defaults to --sidebar-expanded; sidebar.ts overrides on
document.documentElement when the user drags the resize handle
and persists the value to localStorage. Mobile drawer keeps the
fixed --sidebar-expanded width. */
--sidebar-width: 240px;
--bottom-nav-height: 56px;
}
@@ -493,7 +499,13 @@ main {
.sidebar.expanded,
.sidebar.pinned {
width: var(--sidebar-expanded);
width: var(--sidebar-width);
}
.sidebar.resizing,
.sidebar.resizing .sidebar-label,
.has-sidebar.sidebar-resizing {
transition: none !important;
}
.sidebar-header {
@@ -556,6 +568,38 @@ main {
color: var(--color-accent);
}
/* Vertical drag-strip on the sidebar's right edge. Only meaningful when
the sidebar is wide enough to show labels (expanded or pinned), so
collapsed/icon-rail state hides it. Positioned slightly off-edge for
easier hit; pointer-events stay enabled only when visible. */
.sidebar-resize-handle {
position: absolute;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
z-index: 41;
background: transparent;
opacity: 0;
pointer-events: none;
transition: opacity 150ms ease, background 150ms ease;
user-select: none;
touch-action: none;
}
.sidebar.expanded .sidebar-resize-handle,
.sidebar.pinned .sidebar-resize-handle {
opacity: 1;
pointer-events: auto;
}
.sidebar-resize-handle:hover,
.sidebar.resizing .sidebar-resize-handle {
background: var(--color-accent);
opacity: 0.5;
}
.sidebar-nav {
display: flex;
flex-direction: column;
@@ -697,7 +741,7 @@ main {
}
.has-sidebar.sidebar-pinned {
padding-left: var(--sidebar-expanded);
padding-left: var(--sidebar-width);
}
.has-sidebar .tool-results {
@@ -1565,6 +1609,10 @@ input[type="range"]::-moz-range-thumb {
display: none;
}
.sidebar-resize-handle {
display: none;
}
.sidebar-hamburger {
display: flex;
}
@@ -5686,7 +5734,7 @@ input[type="range"]::-moz-range-thumb {
position: fixed;
top: 6.75rem;
left: 0.5rem;
width: calc(var(--sidebar-expanded) - 1rem);
width: calc(var(--sidebar-width) - 1rem);
max-width: 420px;
max-height: calc(100vh - 8rem);
overflow-y: auto;