diff --git a/app/assets/stylesheets/base/root.css b/app/assets/stylesheets/base/root.css index 9324f4a..10d4984 100644 --- a/app/assets/stylesheets/base/root.css +++ b/app/assets/stylesheets/base/root.css @@ -6,9 +6,9 @@ body { } .container { - max-width: var(--max-width-container); + max-width: none; width: 100%; - margin: 0 auto; + margin: 0; padding: 0; background-color: var(--color-bg-container); min-width: 0; @@ -16,16 +16,66 @@ body { } .page-layout.with-sidebar { + --sidebar-width: 360px; + --sidebar-resizer-width: 10px; display: grid; - grid-template-columns: 480px 1fr; + grid-template-columns: var(--sidebar-width) var(--sidebar-resizer-width) minmax(0, 1fr); + grid-template-areas: "sidebar resizer main"; gap: 0; align-items: start; + overflow: visible; } .page-layout.with-sidebar .layout-sidebar { background: var(--color-bg-card); - border-right: var(--border-width) solid var(--color-border); min-height: calc(100vh - var(--nav-height)); background-color: var(--color-bg-sidebar); align-self: stretch; + min-width: 0; + grid-area: sidebar; +} + +.page-layout.with-sidebar .layout-sidebar-resizer { + cursor: col-resize; + background: var(--color-bg-page); + border-right: var(--border-width) solid var(--color-border); + min-height: calc(100vh - var(--nav-height)); + position: sticky; + top: var(--nav-height); + touch-action: none; + width: var(--sidebar-resizer-width); + grid-area: resizer; +} + +.page-layout.with-sidebar .layout-sidebar-resizer:hover, +.page-layout.with-sidebar .layout-sidebar-resizer:active { + background: var(--color-bg-hover); +} + +body.sidebar-resizing { + cursor: col-resize; + user-select: none; +} + +body.sidebar-collapsed .page-layout.with-sidebar { + grid-template-columns: 0 var(--sidebar-resizer-width) minmax(0, 1fr); +} + +body.sidebar-collapsed .layout-sidebar { + visibility: hidden; + pointer-events: none; +} + +body.sidebar-collapsed .layout-sidebar-resizer { + border-left: var(--border-width) solid var(--color-border); + justify-self: start; +} + +body.sidebar-collapsed .page-layout.with-sidebar > main.container { + max-width: none; + width: 100%; +} + +.page-layout.with-sidebar > main.container { + grid-area: main; } diff --git a/app/assets/stylesheets/components/sidebar.css b/app/assets/stylesheets/components/sidebar.css index 579c779..4ad74b6 100644 --- a/app/assets/stylesheets/components/sidebar.css +++ b/app/assets/stylesheets/components/sidebar.css @@ -7,6 +7,32 @@ padding-right: var(--spacing-2); } +.layout-sidebar-resizer { + display: flex; + align-items: center; + justify-content: center; +} + +.sidebar-collapse-button { + border: none; + background: var(--color-bg-card); + color: var(--color-text-secondary); + width: 28px; + height: 28px; + border-radius: 999px; + box-shadow: var(--shadow-sm); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform var(--transition-fast), background-color var(--transition-fast); +} + +.sidebar-collapse-button:hover { + background: var(--color-bg-hover); + transform: scale(1.05); +} + .sidebar-section { margin-bottom: var(--spacing-8); } diff --git a/app/javascript/controllers/sidebar_controller.js b/app/javascript/controllers/sidebar_controller.js new file mode 100644 index 0000000..a02a3ed --- /dev/null +++ b/app/javascript/controllers/sidebar_controller.js @@ -0,0 +1,161 @@ +import { Controller } from "@hotwired/stimulus" + +const STORAGE_WIDTH_KEY = "hackorum-sidebar-width" +const STORAGE_COLLAPSED_KEY = "hackorum-sidebar-collapsed" +const DEFAULT_WIDTH = 360 +const MIN_WIDTH = 260 +const MAX_WIDTH = 960 +const MAX_WIDTH_RATIO = 0.75 + +export default class extends Controller { + static targets = ["layout", "sidebar", "resizer", "toggleButton", "toggleIcon"] + + connect() { + if (!this.hasLayoutTarget || !this.hasSidebarTarget) { + return + } + + this.handleWindowResize = this.handleWindowResize.bind(this) + window.addEventListener("resize", this.handleWindowResize) + + this.applyStoredState() + } + + disconnect() { + window.removeEventListener("resize", this.handleWindowResize) + this.stopResize() + } + + toggle() { + if (this.isCollapsed()) { + this.expand() + } else { + this.collapse() + } + } + + startResize(event) { + if (this.isCollapsed()) { + return + } + + event.preventDefault() + this.isResizing = true + this.startX = this.clientXFrom(event) + this.startWidth = this.sidebarTarget.getBoundingClientRect().width + + this.boundHandleResize = this.handleResize.bind(this) + this.boundStopResize = this.stopResize.bind(this) + + document.addEventListener("mousemove", this.boundHandleResize) + document.addEventListener("mouseup", this.boundStopResize) + document.addEventListener("touchmove", this.boundHandleResize, { passive: false }) + document.addEventListener("touchend", this.boundStopResize) + + document.body.classList.add("sidebar-resizing") + } + + handleResize(event) { + if (!this.isResizing) { + return + } + + event.preventDefault() + const delta = this.clientXFrom(event) - this.startX + const nextWidth = this.clampWidth(this.startWidth + delta) + this.setSidebarWidth(nextWidth) + } + + stopResize() { + if (!this.isResizing) { + return + } + + this.isResizing = false + document.body.classList.remove("sidebar-resizing") + + document.removeEventListener("mousemove", this.boundHandleResize) + document.removeEventListener("mouseup", this.boundStopResize) + document.removeEventListener("touchmove", this.boundHandleResize) + document.removeEventListener("touchend", this.boundStopResize) + + window.localStorage.setItem(STORAGE_WIDTH_KEY, String(this.currentWidth || this.startWidth || DEFAULT_WIDTH)) + } + + handleWindowResize() { + if (this.isCollapsed()) { + return + } + + const stored = this.readWidth() + this.setSidebarWidth(this.clampWidth(stored)) + } + + applyStoredState() { + const collapsed = window.localStorage.getItem(STORAGE_COLLAPSED_KEY) === "true" + if (collapsed) { + this.collapse(false) + return + } + + this.expand(false) + this.setSidebarWidth(this.clampWidth(this.readWidth())) + } + + collapse(shouldStore = true) { + document.body.classList.add("sidebar-collapsed") + if (shouldStore) { + window.localStorage.setItem(STORAGE_COLLAPSED_KEY, "true") + } + this.updateToggleIcon() + } + + expand(shouldStore = true) { + document.body.classList.remove("sidebar-collapsed") + if (shouldStore) { + window.localStorage.setItem(STORAGE_COLLAPSED_KEY, "false") + } + this.setSidebarWidth(this.clampWidth(this.readWidth())) + this.updateToggleIcon() + } + + updateToggleIcon() { + if (!this.hasToggleIconTarget) { + return + } + + this.toggleIconTarget.textContent = this.isCollapsed() ? "▶" : "◀" + } + + isCollapsed() { + return document.body.classList.contains("sidebar-collapsed") + } + + readWidth() { + const stored = Number.parseFloat(window.localStorage.getItem(STORAGE_WIDTH_KEY)) + if (Number.isFinite(stored) && stored > 0) { + return stored + } + return DEFAULT_WIDTH + } + + setSidebarWidth(width) { + this.currentWidth = width + this.layoutTarget.style.setProperty("--sidebar-width", `${width}px`) + } + + clampWidth(width) { + const maxWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.round(window.innerWidth * MAX_WIDTH_RATIO))) + return Math.min(Math.max(width, MIN_WIDTH), maxWidth) + } + + clientXFrom(event) { + if (event.touches && event.touches.length > 0) { + return event.touches[0].clientX + } + if (event.changedTouches && event.changedTouches.length > 0) { + return event.changedTouches[0].clientX + } + return event.clientX + } +} diff --git a/app/views/layouts/application.html.slim b/app/views/layouts/application.html.slim index c0d321d..ad43ac8 100644 --- a/app/views/layouts/application.html.slim +++ b/app/views/layouts/application.html.slim @@ -65,9 +65,12 @@ html data-theme="light" = link_to "Register", new_registration_path, class: "nav-link" - if content_for?(:sidebar) - .page-layout.with-sidebar - .layout-sidebar + .page-layout.with-sidebar data-controller="sidebar" data-sidebar-target="layout" + .layout-sidebar#layout-sidebar data-sidebar-target="sidebar" = yield :sidebar + .layout-sidebar-resizer data-sidebar-target="resizer" role="separator" aria-orientation="vertical" aria-label="Resize sidebar" data-action="mousedown->sidebar#startResize touchstart->sidebar#startResize" + button.sidebar-collapse-button type="button" aria-label="Toggle sidebar" data-action="click->sidebar#toggle" data-sidebar-target="toggleButton" + span data-sidebar-target="toggleIcon" ◀ main.container - flash.each do |type, message| .flash class=type