feat(sidebar): drag it wider, fold it away, and have it stay that way
The sidebar was 268px and always there. On a wide monitor that wastes the room; on a laptop it takes room the list needs. The width drags from a handle on the border — 7px of target over a 1px line, because a 1px target is a target you miss — and the whole column folds away from a button in the topbar. Both are remembered. In localStorage rather than on the account: this is a property of the screen you are sitting at, not of you. The same person on a laptop and a wide monitor wants different answers, and syncing it would make one of them wrong every time they switch. The handle is also a slider for the keyboard. Arrow keys move it, Home puts it back, and so does a double-click — a drag handle with no keyboard equivalent is a setting some people simply cannot reach. Folding is desktop-only, and the media query says so rather than leaving it to chance: below the breakpoint the sidebar is a drawer that opens over the page, and a collapsed drawer would open to nothing. Clamped to 200–480px in the store, so a stored value from an older build cannot make it unusable either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
committed by
Kristian Wind
co-authored by
Claude Opus 5
parent
c4e9e1519b
commit
8c748c2a88
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { app, sidebar } from '$lib/stores.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -42,6 +42,32 @@
|
||||
);
|
||||
let current = $derived($page.url.pathname);
|
||||
|
||||
// --- resizing -------------------------------------------------------------------
|
||||
|
||||
let resizing = $state(false);
|
||||
|
||||
/**
|
||||
* Pointer events rather than mouse events, so a trackpad, a mouse and a stylus
|
||||
* all work. Capturing the pointer means the drag keeps following even when it
|
||||
* leaves the handle, which at 4px wide it does constantly.
|
||||
*/
|
||||
function startResize(event) {
|
||||
event.preventDefault();
|
||||
resizing = true;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function onResize(event) {
|
||||
if (!resizing) return;
|
||||
sidebar.setWidth(event.clientX);
|
||||
}
|
||||
|
||||
function endResize(event) {
|
||||
if (!resizing) return;
|
||||
resizing = false;
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
}
|
||||
|
||||
// --- reordering ---------------------------------------------------------------
|
||||
|
||||
let draggingId = $state(null);
|
||||
@@ -241,6 +267,29 @@
|
||||
<span class="signout">Log ud</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Also a slider for the keyboard: arrow keys move it, and Home puts it back.
|
||||
A drag handle with no keyboard equivalent is a setting some people simply
|
||||
cannot reach. -->
|
||||
<div
|
||||
class="resize"
|
||||
class:resizing
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Bredde på sidebjælken"
|
||||
tabindex="0"
|
||||
onpointerdown={startResize}
|
||||
onpointermove={onResize}
|
||||
onpointerup={endResize}
|
||||
onpointercancel={endResize}
|
||||
ondblclick={() => sidebar.reset()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'ArrowLeft') sidebar.setWidth(sidebar.width - 16);
|
||||
else if (e.key === 'ArrowRight') sidebar.setWidth(sidebar.width + 16);
|
||||
else if (e.key === 'Home') sidebar.reset();
|
||||
else return;
|
||||
e.preventDefault();
|
||||
}}
|
||||
></div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
@@ -255,6 +304,45 @@
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
padding-left: max(var(--s3), env(safe-area-inset-left));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Sits on the border, wider than it looks: a 1px target is a target you miss.
|
||||
The visible line stays the border; this only widens what the pointer hits. */
|
||||
.resize {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -3px;
|
||||
width: 7px;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 3px;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.resize:hover::after,
|
||||
.resize:focus-visible::after,
|
||||
.resize.resizing::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.resize:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
/* The drawer has a fixed width and no room to drag against. */
|
||||
.resize {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.brand {
|
||||
|
||||
@@ -286,6 +286,58 @@ class AppState {
|
||||
|
||||
export const app = new AppState();
|
||||
|
||||
/**
|
||||
* How wide the sidebar is, and whether it is showing at all.
|
||||
*
|
||||
* Kept in localStorage rather than on the account: it is a property of the
|
||||
* screen you are sitting at, not of you. The same person on a laptop and on a
|
||||
* wide monitor wants different answers, and syncing it would make one of those
|
||||
* two wrong every time they switch.
|
||||
*/
|
||||
const SIDEBAR_MIN = 200;
|
||||
const SIDEBAR_MAX = 480;
|
||||
const SIDEBAR_DEFAULT = 268;
|
||||
|
||||
function storedNumber(key, fallback) {
|
||||
if (typeof localStorage === 'undefined') return fallback;
|
||||
const raw = Number(localStorage.getItem(key));
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
||||
}
|
||||
|
||||
class SidebarLayout {
|
||||
width = $state(storedNumber('verdande:sidebar-width', SIDEBAR_DEFAULT));
|
||||
collapsed = $state(
|
||||
typeof localStorage !== 'undefined' && localStorage.getItem('verdande:sidebar-collapsed') === '1'
|
||||
);
|
||||
|
||||
setWidth(px) {
|
||||
// Clamped here rather than in the drag handler, so every caller gets the
|
||||
// same rule and a stored value from an older build cannot make the
|
||||
// sidebar unusably narrow.
|
||||
this.width = Math.round(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, px)));
|
||||
this.#save('verdande:sidebar-width', String(this.width));
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.collapsed = !this.collapsed;
|
||||
this.#save('verdande:sidebar-collapsed', this.collapsed ? '1' : '0');
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.setWidth(SIDEBAR_DEFAULT);
|
||||
}
|
||||
|
||||
#save(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const sidebar = new SidebarLayout();
|
||||
|
||||
/** Theme, kept in localStorage and applied to the document element. */
|
||||
export const theme = {
|
||||
get current() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
import { app, theme } from '$lib/stores.svelte.js';
|
||||
import { app, theme, sidebar } from '$lib/stores.svelte.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
@@ -66,11 +66,20 @@
|
||||
{:else if !app.user}
|
||||
<SignIn />
|
||||
{:else}
|
||||
<div class="shell" class:sidebar-open={sidebarOpen}>
|
||||
<div
|
||||
class="shell"
|
||||
class:sidebar-open={sidebarOpen}
|
||||
class:sidebar-collapsed={sidebar.collapsed}
|
||||
style="--sidebar-width: {sidebar.width}px"
|
||||
>
|
||||
<Sidebar onnavigate={() => (sidebarOpen = false)} />
|
||||
|
||||
<main>
|
||||
<header class="topbar">
|
||||
<!-- Two controls, not one: on a phone the sidebar is a drawer that
|
||||
opens over the page, on a desktop it is a column that folds away.
|
||||
Same words, different actions, so each is shown only where it
|
||||
applies. -->
|
||||
<button
|
||||
class="menu"
|
||||
onclick={() => (sidebarOpen = !sidebarOpen)}
|
||||
@@ -82,6 +91,19 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="fold"
|
||||
onclick={() => sidebar.toggle()}
|
||||
aria-label={sidebar.collapsed ? 'Vis sidebjælken' : 'Skjul sidebjælken'}
|
||||
aria-pressed={sidebar.collapsed}
|
||||
title={sidebar.collapsed ? 'Vis sidebjælken' : 'Skjul sidebjælken'}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<path d="M9 4v16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="search" onclick={() => (paletteOpen = true)}>
|
||||
<span>Søg</span>
|
||||
<kbd>⌘K</kbd>
|
||||
@@ -173,7 +195,32 @@
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.fold {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
place-items: center;
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-faint);
|
||||
transition: color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.fold:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Folded away rather than merely narrow: width 0 with the border gone, so
|
||||
nothing of it is left to catch the eye. */
|
||||
.shell.sidebar-collapsed :global(.sidebar) {
|
||||
width: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
border-right: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menu svg,
|
||||
.fold svg,
|
||||
.theme svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -277,6 +324,11 @@
|
||||
display: grid;
|
||||
}
|
||||
|
||||
/* The drawer is the whole answer here; folding a drawer means nothing. */
|
||||
.fold {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrim {
|
||||
display: block;
|
||||
}
|
||||
@@ -292,5 +344,15 @@
|
||||
.shell.sidebar-open :global(.sidebar) {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* A collapsed desktop sidebar must not follow you to a phone, where the
|
||||
drawer would then open to nothing. */
|
||||
.shell.sidebar-collapsed :global(.sidebar) {
|
||||
width: var(--sidebar-width);
|
||||
padding-left: max(var(--s3), env(safe-area-inset-left));
|
||||
padding-right: var(--s3);
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user