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).
This commit is contained in:
Guillem Arias Fauste
2026-06-26 06:45:08 +02:00
committed by GitHub
parent b88deb3545
commit e56a1825b2
7 changed files with 355 additions and 5 deletions

View File

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