diff --git a/app/javascript/controllers/sidebar_resize_controller.js b/app/javascript/controllers/sidebar_resize_controller.js new file mode 100644 index 000000000..0ea840a67 --- /dev/null +++ b/app/javascript/controllers/sidebar_resize_controller.js @@ -0,0 +1,198 @@ +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; + } +} diff --git a/app/javascript/utils/sidebar_resize.js b/app/javascript/utils/sidebar_resize.js new file mode 100644 index 000000000..39d59a818 --- /dev/null +++ b/app/javascript/utils/sidebar_resize.js @@ -0,0 +1,42 @@ +// Pure geometry helpers for the resizable app sidebars. +// +// Kept free of any DOM access so the clamping logic can be unit-tested in +// isolation. Must be kept in sync with +// test/javascript/utils/sidebar_resize_test.mjs. + +// Width of the always-visible icon navbar to the left of the sidebars. +export const SIDEBAR_NAVBAR_WIDTH = 84; +// Smallest a sidebar may shrink to while still being usable. +export const SIDEBAR_MIN_WIDTH = 240; +// Minimum breathing room the center content must always keep so the dashboard +// stays legible no matter how wide the sidebars are dragged. +export const SIDEBAR_MIN_MAIN_WIDTH = 400; + +export const SIDEBAR_DEFAULTS = { left: 320, right: 400 }; +// Hard upper bounds per side, regardless of how much room is available. +export const SIDEBAR_ABS_MAX = { left: 480, right: 560 }; + +// Clamp a requested sidebar width to a value that respects the per-side bounds +// AND guarantees the center column keeps at least SIDEBAR_MIN_MAIN_WIDTH. +// +// rawWidth - desired width in px (e.g. derived from the pointer position) +// viewportWidth - window.innerWidth +// otherWidth - current width of the opposite sidebar (0 if collapsed/hidden) +export function clampSidebarWidth( + rawWidth, + { + viewportWidth, + otherWidth = 0, + navbarWidth = SIDEBAR_NAVBAR_WIDTH, + min = SIDEBAR_MIN_WIDTH, + minMain = SIDEBAR_MIN_MAIN_WIDTH, + absMax = Number.POSITIVE_INFINITY, + } = {}, +) { + const available = viewportWidth - navbarWidth - otherWidth - minMain; + // Never let the computed max fall below `min`; if the viewport is genuinely + // too small we pin to `min` and accept the squeeze rather than going invalid. + const max = Math.max(min, Math.min(absMax, available)); + const clamped = Math.min(Math.max(rawWidth, min), max); + return Math.round(clamped); +} diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 55f437a91..0f07cfedd 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -23,7 +23,7 @@ end %> <%= render "layouts/shared/htmldoc" do %>