feat(sidebar): resizable width with drag handle + persistence (t-paliad-047)
Adds a 6px col-resize strip on the right edge of the desktop sidebar. Drag updates --sidebar-width on document.documentElement (clamped 180-480px). Mouse + touch handlers; double-click resets to the 240px default. Width persists via localStorage["paliad-sidebar-width"], read on every page load before first paint so layout is stable from frame 1. The handle is opacity-faded on the icon-rail (collapsed) state and hidden entirely under the mobile breakpoint, since the mobile sidebar is an overlay drawer that always uses the fixed --sidebar-expanded width. Pin/unpin behaviour is preserved: pinned state keeps the user's chosen width; unpinning drops to the icon rail; hover-expand restores the chosen width. The hover-collapse mouseleave handler ignores transitions during an active drag so the sidebar doesn't snap shut mid-resize.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user