mirror of
https://github.com/we-promise/sure.git
synced 2026-07-12 21:05:20 +00:00
* feat(layout): make the accounts and assistant sidebars resizable
Add draggable dividers on the left (accounts) and right (assistant)
sidebars. Width is driven by a `--{side}-sidebar-width` CSS variable on
the layout root and persisted per-device in localStorage; it composes
with the existing show/hide toggle (collapse still wins via `w-0`,
reopening restores the last width).
Clamping keeps each sidebar within its bounds (min 240px, max 480/560px)
and guarantees the center column never drops below 400px, so the
dashboard stays legible no matter how wide a sidebar is dragged.
- sidebar_resize_controller.js: pointer drag, keyboard nudge
(arrows / Home), double-click reset, localStorage persistence
- utils/sidebar_resize.js: pure, unit-tested clamp helper
- layouts/shared/_sidebar_resize_handle.html.erb: accessible
separator handle (role=separator, aria-label, focusable)
- test/javascript/utils/sidebar_resize_test.mjs: clamp logic tests
* fix(sidebar-resize): free a collapsed sidebar's space and guard localStorage read
- #clamp now uses the opposite sidebar's rendered width (0 when collapsed)
instead of its stored CSS variable, so closing one sidebar lets the other
use the freed space (addresses Codex review).
- #applyStoredWidth wraps the localStorage read in try/catch and falls back
to the default, so connect() can't bail in storage-blocked browsers
(addresses CodeRabbit review).
199 lines
5.7 KiB
JavaScript
199 lines
5.7 KiB
JavaScript
import { Controller } from "@hotwired/stimulus";
|
|
import {
|
|
SIDEBAR_ABS_MAX,
|
|
SIDEBAR_DEFAULTS,
|
|
clampSidebarWidth,
|
|
} from "utils/sidebar_resize";
|
|
|
|
// Drives the draggable dividers on the left (accounts) and right (assistant)
|
|
// sidebars. The chosen width is stored as a CSS variable on the root layout
|
|
// element (consumed via `max-width: var(--left-sidebar-width)`) and persisted
|
|
// per-device in localStorage. Width changes compose with the show/hide toggle
|
|
// owned by app_layout_controller — collapsing wins via `w-0`, reopening
|
|
// restores the last width.
|
|
//
|
|
// Connects to data-controller="sidebar-resize"
|
|
export default class extends Controller {
|
|
static targets = ["leftSidebar", "rightSidebar"];
|
|
|
|
static STORAGE_KEYS = {
|
|
left: "sure.sidebarWidth.left",
|
|
right: "sure.sidebarWidth.right",
|
|
};
|
|
// Keyboard nudge step (px) for the focusable divider handles.
|
|
static KEY_STEP = 16;
|
|
|
|
#drag = null;
|
|
#onMove = null;
|
|
#onUp = null;
|
|
|
|
connect() {
|
|
this.#applyStoredWidth("left");
|
|
this.#applyStoredWidth("right");
|
|
}
|
|
|
|
disconnect() {
|
|
this.#teardownListeners();
|
|
}
|
|
|
|
startLeft(event) {
|
|
this.#startResize(event, "left");
|
|
}
|
|
|
|
startRight(event) {
|
|
this.#startResize(event, "right");
|
|
}
|
|
|
|
// Arrow keys nudge the focused divider; Home resets to the default width.
|
|
keyLeft(event) {
|
|
this.#keyResize(event, "left");
|
|
}
|
|
|
|
keyRight(event) {
|
|
this.#keyResize(event, "right");
|
|
}
|
|
|
|
// Double-clicking a divider restores its default width.
|
|
resetLeft() {
|
|
this.#commitWidth("left", SIDEBAR_DEFAULTS.left);
|
|
}
|
|
|
|
resetRight() {
|
|
this.#commitWidth("right", SIDEBAR_DEFAULTS.right);
|
|
}
|
|
|
|
#startResize(event, side) {
|
|
const sidebar = this.#sidebar(side);
|
|
if (!sidebar) return;
|
|
|
|
event.preventDefault();
|
|
this.#drag = { side, sidebar };
|
|
|
|
// Suppress the open/close transition so the panel tracks the pointer 1:1.
|
|
sidebar.style.transition = "none";
|
|
document.body.style.userSelect = "none";
|
|
document.body.style.cursor = "col-resize";
|
|
|
|
this.#onMove = (moveEvent) => this.#resize(moveEvent);
|
|
this.#onUp = () => this.#endResize();
|
|
window.addEventListener("pointermove", this.#onMove);
|
|
window.addEventListener("pointerup", this.#onUp, { once: true });
|
|
}
|
|
|
|
#resize(event) {
|
|
if (!this.#drag) return;
|
|
|
|
const { side, sidebar } = this.#drag;
|
|
const rect = sidebar.getBoundingClientRect();
|
|
// Left handle sits on the sidebar's right edge; right handle on its left.
|
|
const raw =
|
|
side === "left" ? event.clientX - rect.left : rect.right - event.clientX;
|
|
|
|
this.#setWidth(side, this.#clamp(side, raw));
|
|
}
|
|
|
|
#endResize() {
|
|
if (!this.#drag) return;
|
|
|
|
const { side, sidebar } = this.#drag;
|
|
sidebar.style.transition = "";
|
|
document.body.style.userSelect = "";
|
|
document.body.style.cursor = "";
|
|
this.#teardownListeners();
|
|
|
|
this.#persist(side, this.#currentWidth(side));
|
|
this.#drag = null;
|
|
}
|
|
|
|
#keyResize(event, side) {
|
|
// A left sidebar grows as its right edge moves right; a right sidebar grows
|
|
// as its left edge moves left. Mirror the arrow direction per side so the
|
|
// key matches the visual edge movement.
|
|
const dir = side === "left" ? 1 : -1;
|
|
const step = this.constructor.KEY_STEP;
|
|
let next;
|
|
if (event.key === "ArrowRight") {
|
|
next = this.#currentWidth(side) + dir * step;
|
|
} else if (event.key === "ArrowLeft") {
|
|
next = this.#currentWidth(side) - dir * step;
|
|
} else if (event.key === "Home") {
|
|
next = SIDEBAR_DEFAULTS[side];
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
this.#commitWidth(side, next);
|
|
}
|
|
|
|
#commitWidth(side, rawWidth) {
|
|
const width = this.#clamp(side, rawWidth);
|
|
this.#setWidth(side, width);
|
|
this.#persist(side, width);
|
|
}
|
|
|
|
#applyStoredWidth(side) {
|
|
let stored = Number.NaN;
|
|
try {
|
|
stored = Number.parseInt(
|
|
localStorage.getItem(this.constructor.STORAGE_KEYS[side]),
|
|
10,
|
|
);
|
|
} catch (_e) {
|
|
// Storage blocked (private mode / locked down) — fall back to default.
|
|
}
|
|
const requested = Number.isFinite(stored) ? stored : SIDEBAR_DEFAULTS[side];
|
|
this.#setWidth(side, this.#clamp(side, requested));
|
|
}
|
|
|
|
#clamp(side, rawWidth) {
|
|
const opposite = this.#sidebar(side === "left" ? "right" : "left");
|
|
// Use the opposite sidebar's *rendered* width, not its stored CSS variable:
|
|
// a collapsed sidebar renders at 0 (w-0), so its space is correctly freed
|
|
// for this one instead of staying reserved at the stored value.
|
|
const otherWidth = opposite
|
|
? Math.round(opposite.getBoundingClientRect().width)
|
|
: 0;
|
|
|
|
return clampSidebarWidth(rawWidth, {
|
|
viewportWidth: window.innerWidth || 1280,
|
|
otherWidth,
|
|
absMax: SIDEBAR_ABS_MAX[side],
|
|
});
|
|
}
|
|
|
|
#persist(side, width) {
|
|
try {
|
|
localStorage.setItem(this.constructor.STORAGE_KEYS[side], String(width));
|
|
} catch (_e) {
|
|
// Private browsing / storage disabled — width still applies for this session.
|
|
}
|
|
}
|
|
|
|
#setWidth(side, width) {
|
|
this.element.style.setProperty(`--${side}-sidebar-width`, `${width}px`);
|
|
}
|
|
|
|
#currentWidth(side) {
|
|
const value = Number.parseInt(
|
|
this.element.style.getPropertyValue(`--${side}-sidebar-width`),
|
|
10,
|
|
);
|
|
return Number.isFinite(value) ? value : SIDEBAR_DEFAULTS[side];
|
|
}
|
|
|
|
#sidebar(side) {
|
|
if (side === "left") {
|
|
return this.hasLeftSidebarTarget ? this.leftSidebarTarget : null;
|
|
}
|
|
return this.hasRightSidebarTarget ? this.rightSidebarTarget : null;
|
|
}
|
|
|
|
#teardownListeners() {
|
|
if (this.#onMove) window.removeEventListener("pointermove", this.#onMove);
|
|
if (this.#onUp) window.removeEventListener("pointerup", this.#onUp);
|
|
this.#onMove = null;
|
|
this.#onUp = null;
|
|
}
|
|
}
|