From e56a1825b2710e2505e720c644e35abb3f841123 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Fri, 26 Jun 2026 06:45:08 +0200 Subject: [PATCH] feat: resizable accounts & assistant sidebars (#2482) * 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). --- .../controllers/sidebar_resize_controller.js | 198 ++++++++++++++++++ app/javascript/utils/sidebar_resize.js | 42 ++++ app/views/layouts/application.html.erb | 14 +- .../shared/_sidebar_resize_handle.html.erb | 18 ++ config/locales/views/layout/ca.yml | 2 + config/locales/views/layout/en.yml | 2 + test/javascript/utils/sidebar_resize_test.mjs | 84 ++++++++ 7 files changed, 355 insertions(+), 5 deletions(-) create mode 100644 app/javascript/controllers/sidebar_resize_controller.js create mode 100644 app/javascript/utils/sidebar_resize.js create mode 100644 app/views/layouts/shared/_sidebar_resize_handle.html.erb create mode 100644 test/javascript/utils/sidebar_resize_test.mjs 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 %>
@@ -105,11 +105,13 @@ end %> <%# DESKTOP - Left sidebar %> <%= tag.div class: class_names( - "hidden lg:block py-4 shrink-0 max-w-[320px] transition-all duration-300 border-divider", + "hidden lg:block relative py-4 shrink-0 transition-all duration-300 border-divider", Current.user.show_sidebar? ? [expanded_sidebar_class, "border-r"] : [collapsed_sidebar_class, "border-r-0"], ), + style: "max-width: var(--left-sidebar-width, 320px)", inert: !Current.user.show_sidebar?, - data: { app_layout_target: "leftSidebar" } do %> + data: { app_layout_target: "leftSidebar", sidebar_resize_target: "leftSidebar" } do %> + <%= render "layouts/shared/sidebar_resize_handle", side: "left" %>
<% if content_for?(:sidebar) %> <%= yield :sidebar %> @@ -182,11 +184,13 @@ end %> <%# DESKTOP - Right sidebar %> <%= tag.div class: class_names( - "hidden lg:block h-full shrink-0 max-w-[400px] transition-all duration-300 border-divider", + "hidden lg:block relative h-full shrink-0 transition-all duration-300 border-divider", Current.user.show_ai_sidebar? ? [expanded_sidebar_class, "border-l"] : [collapsed_sidebar_class, "border-l-0"], ), + style: "max-width: var(--right-sidebar-width, 400px)", inert: !Current.user.show_ai_sidebar?, - data: { app_layout_target: "rightSidebar" } do %> + data: { app_layout_target: "rightSidebar", sidebar_resize_target: "rightSidebar" } do %> + <%= render "layouts/shared/sidebar_resize_handle", side: "right" %> <%= tag.div id: "chat-container", class: "relative h-full px-4 overflow-y-auto", data: { controller: "chat hotkey", turbo_permanent: true } do %>
<%= turbo_frame_tag chat_frame, src: chat_view_path(@chat), loading: "lazy", class: "h-full" do %> diff --git a/app/views/layouts/shared/_sidebar_resize_handle.html.erb b/app/views/layouts/shared/_sidebar_resize_handle.html.erb new file mode 100644 index 000000000..3e8fbba7c --- /dev/null +++ b/app/views/layouts/shared/_sidebar_resize_handle.html.erb @@ -0,0 +1,18 @@ +<%# locals: (side:) %> +<%# + Draggable divider for a resizable sidebar. + `side` is "left" (accounts) or "right" (assistant). Drives sidebar_resize_controller. +%> +<% suffix = side.capitalize %> +<% edge = side == "left" ? "right-0 translate-x-1/2" : "left-0 -translate-x-1/2" %> +<%= tag.div( + class: class_names("group absolute inset-y-0 z-20 hidden w-2 cursor-col-resize lg:block", edge), + role: "separator", + tabindex: "0", + aria: { orientation: "vertical", label: t("layouts.application.resize_#{side}_sidebar") }, + data: { + action: "pointerdown->sidebar-resize#start#{suffix} keydown->sidebar-resize#key#{suffix} dblclick->sidebar-resize#reset#{suffix}" + } +) do %> + +<% end %> diff --git a/config/locales/views/layout/ca.yml b/config/locales/views/layout/ca.yml index f7bf5a2b4..a512261c8 100644 --- a/config/locales/views/layout/ca.yml +++ b/config/locales/views/layout/ca.yml @@ -9,6 +9,8 @@ ca: reports: Informes transactions: Transaccions privacy_mode: Commuta el mode de privadesa + resize_left_sidebar: Redimensiona la barra lateral de comptes + resize_right_sidebar: Redimensiona la barra lateral de l'assistent skip_to_main: Salta al contingut principal auth: existing_account: Ja tens un compte? diff --git a/config/locales/views/layout/en.yml b/config/locales/views/layout/en.yml index e38487900..0d5526a2f 100644 --- a/config/locales/views/layout/en.yml +++ b/config/locales/views/layout/en.yml @@ -3,6 +3,8 @@ en: layouts: application: privacy_mode: Toggle privacy mode + resize_left_sidebar: Resize the accounts sidebar + resize_right_sidebar: Resize the assistant sidebar skip_to_main: Skip to main content nav: assistant: Assistant diff --git a/test/javascript/utils/sidebar_resize_test.mjs b/test/javascript/utils/sidebar_resize_test.mjs new file mode 100644 index 000000000..966aad515 --- /dev/null +++ b/test/javascript/utils/sidebar_resize_test.mjs @@ -0,0 +1,84 @@ +import { describe, it } from "node:test" +import assert from "node:assert/strict" + +// Inline the function to avoid needing a bundler for ESM imports. +// Must be kept in sync with app/javascript/utils/sidebar_resize.js +const SIDEBAR_NAVBAR_WIDTH = 84 +const SIDEBAR_MIN_WIDTH = 240 +const SIDEBAR_MIN_MAIN_WIDTH = 400 + +function clampSidebarWidth(rawWidth, { + viewportWidth, + otherWidth = 0, + navbarWidth = SIDEBAR_NAVBAR_WIDTH, + min = SIDEBAR_MIN_WIDTH, + minMain = SIDEBAR_MIN_MAIN_WIDTH, + absMax = Infinity, +} = {}) { + const available = viewportWidth - navbarWidth - otherWidth - minMain + const max = Math.max(min, Math.min(absMax, available)) + const clamped = Math.min(Math.max(rawWidth, min), max) + return Math.round(clamped) +} + +describe("clampSidebarWidth", () => { + // A roomy 1440px viewport with the opposite sidebar at its default width. + const wide = { viewportWidth: 1440, otherWidth: 400 } + + describe("within bounds", () => { + it("returns a width inside the allowed range unchanged", () => { + assert.equal(clampSidebarWidth(320, wide), 320) + }) + + it("rounds fractional pointer widths", () => { + assert.equal(clampSidebarWidth(321.6, wide), 322) + }) + }) + + describe("minimum width", () => { + it("pins anything below the minimum up to the minimum", () => { + assert.equal(clampSidebarWidth(100, wide), SIDEBAR_MIN_WIDTH) + }) + + it("pins negative values to the minimum", () => { + assert.equal(clampSidebarWidth(-50, wide), SIDEBAR_MIN_WIDTH) + }) + }) + + describe("per-side absolute max", () => { + it("caps at absMax even when more room is available", () => { + // 1440 - 84 - 400 - 400 = 556 available, but absMax wins. + assert.equal(clampSidebarWidth(900, { ...wide, absMax: 480 }), 480) + }) + }) + + describe("protecting the center column", () => { + it("never lets the main area drop below the minimum", () => { + // At 1024px with the other sidebar at 240, the most this side can take is + // 1024 - 84 - 240 - 400 = 300px. + const width = clampSidebarWidth(900, { viewportWidth: 1024, otherWidth: 240 }) + assert.equal(width, 300) + const mainLeft = 1024 - 84 - 240 - width + assert.equal(mainLeft, SIDEBAR_MIN_MAIN_WIDTH) + }) + + it("shrinks the allowance as the opposite sidebar grows", () => { + const narrow = clampSidebarWidth(900, { viewportWidth: 1024, otherWidth: 300 }) + assert.equal(narrow, 240) // 1024 - 84 - 300 - 400 = 240 + }) + }) + + describe("degenerate viewports", () => { + it("falls back to the minimum when there is no room to give", () => { + // available goes negative; we accept the squeeze rather than return junk. + assert.equal(clampSidebarWidth(500, { viewportWidth: 600, otherWidth: 240 }), SIDEBAR_MIN_WIDTH) + }) + }) + + describe("default options", () => { + it("treats a missing opposite sidebar as zero width", () => { + // 1440 - 84 - 0 - 400 = 956 available, request honored. + assert.equal(clampSidebarWidth(700, { viewportWidth: 1440 }), 700) + }) + }) +})