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) + }) + }) +})