diff --git a/assets/js/resizable-panels.js b/assets/js/resizable-panels.js new file mode 100644 index 00000000000..415294ffb72 --- /dev/null +++ b/assets/js/resizable-panels.js @@ -0,0 +1,331 @@ +/** + * Resizable docs panels. + * + * Lets readers adjust the left navigation and right TOC widths, persists those + * preferences in localStorage, and provides a reset control. + */ +(function () { + 'use strict'; + + const STORAGE_KEY = 'layer5-docs-panel-widths'; + const RESIZABLE_QUERY = '(min-width: 768px)'; + const STEP = 1; + const DEFAULT_WIDTHS = { + sidebar: 16.6667, + toc: 16.6667, + }; + const LIMITS = { + sidebar: { min: 12, max: 32 }, + toc: { min: 10, max: 28 }, + main: { min: 42 }, + }; + const LEGACY_GRID_COLUMNS = 12; + + function setupResizablePanels(row) { + const sidebar = row.querySelector('.td-sidebar'); + const main = row.querySelector('main[role="main"]'); + const toc = row.querySelector('.td-sidebar-toc'); + const mediaQuery = window.matchMedia(RESIZABLE_QUERY); + let activeHandle = null; + let sidebarHandle = null; + let tocHandle = null; + let startX = 0; + let startWidths = null; + let widths = getStoredWidths(); + + if (!sidebar || !main) { + return; + } + + row.classList.add('resizable-panels-ready'); + applyWidths(widths); + createHandles(); + createResetButton(); + bindEvents(); + + function bindEvents() { + document.addEventListener('pointermove', onPointerMove); + document.addEventListener('pointerup', stopResize); + document.addEventListener('pointercancel', stopResize); + + const onBreakpointChange = () => { + if (mediaQuery.matches) { + applyWidths(widths); + } + }; + + if (mediaQuery.addEventListener) { + mediaQuery.addEventListener('change', onBreakpointChange); + } else { + mediaQuery.addListener(onBreakpointChange); + } + } + + function createHandles() { + sidebarHandle = createHandle('sidebar', 'Resize navigation sidebar'); + sidebar.appendChild(sidebarHandle); + + if (toc) { + tocHandle = createHandle('toc', 'Resize table of contents'); + toc.appendChild(tocHandle); + } + } + + function createHandle(target, label) { + const handle = document.createElement('div'); + handle.className = `resizable-panel-handle resizable-panel-handle--${target}`; + handle.dataset.resizeTarget = target; + handle.tabIndex = 0; + handle.setAttribute('aria-label', label); + handle.setAttribute('aria-orientation', 'vertical'); + handle.setAttribute('role', 'separator'); + handle.title = label; + + handle.addEventListener('pointerdown', (event) => { + startResize(event, handle); + }); + handle.addEventListener('keydown', (event) => { + onHandleKeydown(event, target); + }); + + return handle; + } + + function createResetButton() { + const resetButton = document.createElement('button'); + resetButton.type = 'button'; + resetButton.id = 'reset-panel-widths'; + resetButton.className = 'resizable-panel-reset'; + resetButton.innerHTML = + 'Reset layout'; + resetButton.title = 'Reset panel widths to default'; + resetButton.addEventListener('click', reset); + + sidebar.appendChild(resetButton); + } + + function startResize(event, handle) { + if (!mediaQuery.matches) { + return; + } + + event.preventDefault(); + activeHandle = handle; + startX = event.clientX; + startWidths = { ...widths }; + handle.classList.add('resizable-panel-handle--active'); + handle.setPointerCapture(event.pointerId); + document.body.classList.add('resizable-panels-dragging'); + } + + function onPointerMove(event) { + if (!activeHandle || !startWidths) { + return; + } + + const rowWidth = row.getBoundingClientRect().width; + if (!rowWidth) { + return; + } + + const target = activeHandle.dataset.resizeTarget; + const delta = ((event.clientX - startX) / rowWidth) * 100; + const nextWidths = { ...startWidths }; + + if (target === 'sidebar') { + nextWidths.sidebar = startWidths.sidebar + delta; + } + + if (target === 'toc') { + nextWidths.toc = startWidths.toc - delta; + } + + widths = normalizeWidths(nextWidths); + applyWidths(widths); + } + + function stopResize() { + if (!activeHandle) { + return; + } + + activeHandle.classList.remove('resizable-panel-handle--active'); + activeHandle = null; + startWidths = null; + document.body.classList.remove('resizable-panels-dragging'); + saveWidths(); + } + + function onHandleKeydown(event, target) { + if (!mediaQuery.matches) { + return; + } + + const keys = ['ArrowLeft', 'ArrowRight', 'Home', 'End']; + if (!keys.includes(event.key)) { + return; + } + + event.preventDefault(); + const nextWidths = { ...widths }; + const direction = event.key === 'ArrowRight' ? 1 : -1; + + if (event.key === 'Home') { + nextWidths[target] = LIMITS[target].min; + } else if (event.key === 'End') { + nextWidths[target] = LIMITS[target].max; + } else if (target === 'toc') { + nextWidths.toc -= direction * STEP; + } else { + nextWidths.sidebar += direction * STEP; + } + + widths = normalizeWidths(nextWidths); + applyWidths(widths); + saveWidths(); + } + + function applyWidths(nextWidths) { + const normalized = normalizeWidths(nextWidths); + const mainWidth = 100 - normalized.sidebar - normalized.toc; + + row.style.setProperty('--docs-sidebar-width', `${normalized.sidebar}%`); + row.style.setProperty('--docs-toc-width', `${normalized.toc}%`); + row.style.setProperty('--docs-main-width', `${mainWidth}%`); + row.style.setProperty( + '--docs-main-without-toc-width', + `${100 - normalized.sidebar}%`, + ); + updateHandleValues(normalized); + } + + function updateHandleValues(nextWidths) { + if (sidebarHandle) { + sidebarHandle.setAttribute('aria-valuemin', LIMITS.sidebar.min); + sidebarHandle.setAttribute('aria-valuemax', LIMITS.sidebar.max); + sidebarHandle.setAttribute( + 'aria-valuenow', + Math.round(nextWidths.sidebar), + ); + } + + if (tocHandle) { + tocHandle.setAttribute('aria-valuemin', LIMITS.toc.min); + tocHandle.setAttribute('aria-valuemax', LIMITS.toc.max); + tocHandle.setAttribute('aria-valuenow', Math.round(nextWidths.toc)); + } + } + + function reset() { + widths = { ...DEFAULT_WIDTHS }; + applyWidths(widths); + localStorage.removeItem(STORAGE_KEY); + } + + function getStoredWidths() { + try { + const saved = JSON.parse(localStorage.getItem(STORAGE_KEY)); + + if (isLegacyColumnWidths(saved)) { + return normalizeWidths({ + sidebar: saved.sidebar + ? (saved.sidebar / LEGACY_GRID_COLUMNS) * 100 + : DEFAULT_WIDTHS.sidebar, + toc: saved.toc + ? (saved.toc / LEGACY_GRID_COLUMNS) * 100 + : DEFAULT_WIDTHS.toc, + }); + } + + return normalizeWidths(saved || DEFAULT_WIDTHS); + } catch (error) { + return { ...DEFAULT_WIDTHS }; + } + } + + function isLegacyColumnWidths(saved) { + if (!saved) { + return false; + } + + return ['sidebar', 'toc', 'main'].some((key) => { + const value = Number(saved[key]); + const limit = LIMITS[key]; + + if (!Number.isFinite(value) || !limit) { + return false; + } + + return value < limit.min || (limit.max && value > limit.max); + }); + } + + function saveWidths() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(widths)); + } catch (error) { + // Ignore storage failures so resizing still works in private modes. + } + } + + function normalizeWidths(nextWidths) { + const next = { + sidebar: clamp( + Number(nextWidths && nextWidths.sidebar), + LIMITS.sidebar.min, + LIMITS.sidebar.max, + DEFAULT_WIDTHS.sidebar, + ), + toc: toc + ? clamp( + Number(nextWidths && nextWidths.toc), + LIMITS.toc.min, + LIMITS.toc.max, + DEFAULT_WIDTHS.toc, + ) + : 0, + }; + + const availableForPanels = 100 - LIMITS.main.min; + const panelTotal = next.sidebar + next.toc; + + if (panelTotal > availableForPanels) { + const overflow = panelTotal - availableForPanels; + + if (next.sidebar >= next.toc) { + next.sidebar = Math.max(LIMITS.sidebar.min, next.sidebar - overflow); + } else { + next.toc = Math.max(LIMITS.toc.min, next.toc - overflow); + } + } + + return { + sidebar: Number(next.sidebar.toFixed(4)), + toc: Number(next.toc.toFixed(4)), + }; + } + + function clamp(value, min, max, fallback) { + if (!Number.isFinite(value)) { + return fallback; + } + + return Math.min(max, Math.max(min, value)); + } + } + + function initResizablePanels() { + document.querySelectorAll('.row.flex-xl-nowrap').forEach((row) => { + if (!row.dataset.resizablePanelsInitialized) { + row.dataset.resizablePanelsInitialized = 'true'; + setupResizablePanels(row); + } + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initResizablePanels); + } else { + initResizablePanels(); + } +})(); diff --git a/assets/scss/_resizable-panels.scss b/assets/scss/_resizable-panels.scss new file mode 100644 index 00000000000..9c8044cf161 --- /dev/null +++ b/assets/scss/_resizable-panels.scss @@ -0,0 +1,271 @@ +/** + * Resizable docs side panels. + * + * Docs widths are driven by CSS variables set from resizable-panels.js. + * Mobile keeps the existing Bootstrap column behavior. + */ + +.resizable-panel-handle, +.resizable-panel-reset { + display: none; +} + +@media (min-width: 768px) { + .row.flex-xl-nowrap.resizable-panels-ready { + --docs-sidebar-width: 16.6667%; + --docs-main-width: 66.6666%; + --docs-main-without-toc-width: 83.3333%; + --docs-toc-width: 16.6667%; + + display: flex; + flex-wrap: nowrap; + width: 100%; + min-width: 0; + } + + /** + * LEFT SIDEBAR + */ + .row.flex-xl-nowrap.resizable-panels-ready > .td-sidebar { + flex: 0 0 var(--docs-sidebar-width); + width: var(--docs-sidebar-width); + max-width: 420px; + min-width: 240px; + position: relative; + overflow-x: hidden; + padding-right: 12px; + order: 1; + } + + /** + * MAIN CONTENT + */ + .row.flex-xl-nowrap.resizable-panels-ready > main[role='main'] { + flex: 1 1 auto; + flex-basis: var(--docs-main-without-toc-width); + max-width: var(--docs-main-without-toc-width); + min-width: 500px; + overflow-x: auto; + position: relative; + order: 2; + } + + /** + * Better readable content width + */ + main[role='main'] article { + max-width: 900px; + } + + /** + * TOC SIDEBAR + */ + .row.flex-xl-nowrap.resizable-panels-ready > .td-sidebar-toc { + width: var(--docs-toc-width); + max-width: 320px; + min-width: 220px; + position: relative; + overflow-x: hidden; + padding-left: 12px; + order: 3; + } + + /** + * EXISTING SIDEBAR STYLING + */ + .td-sidebar, + .td-sidebar-toc { + position: sticky; + scrollbar-color: rgba(255, 255, 255, 0.3) transparent; + } + + /** + * Prevent text clipping + */ + .td-sidebar nav, + .td-sidebar-toc nav { + overflow-wrap: break-word; + word-break: break-word; + } + + .td-sidebar a, + .td-sidebar-toc a { + display: block; + max-width: 100%; + } + + /** + * RESIZE HANDLE + */ + .resizable-panel-handle { + appearance: none; + background: transparent; + border: 0; + cursor: col-resize; + display: block; + padding: 0; + position: absolute; + top: 0; + bottom: 0; + width: 4px; + z-index: 20; + } + + /** + * Subtle divider line + */ + .resizable-panel-handle::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: rgba(255, 255, 255, 0.08); + } + + /** + * Handle visual indicator + */ + .resizable-panel-handle::after { + content: ''; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 2px; + height: 32px; + border-radius: 999px; + background-color: rgba(0, 211, 169, 0.55); + opacity: 0; + transition: opacity 0.16s ease; + } + + /** + * SHOW HANDLE ON INTERACTION + */ + .resizable-panel-handle:hover::after, + .resizable-panel-handle:focus-visible::after, + .resizable-panel-handle--active::after { + opacity: 1; + } + + /** + * LEFT SIDEBAR HANDLE + */ + .resizable-panel-handle--sidebar { + right: 0; + } + + .resizable-panel-handle--sidebar::before, + .resizable-panel-handle--sidebar::after { + right: 1px; + } + + /** + * TOC HANDLE + */ + .resizable-panel-handle--toc { + display: none; + left: 0; + } + + .resizable-panel-handle--toc::before, + .resizable-panel-handle--toc::after { + left: 1px; + } + + /** + * CUSTOM SCROLLBARS + */ + .row.flex-xl-nowrap.resizable-panels-ready + > .td-sidebar::-webkit-scrollbar, + .row.flex-xl-nowrap.resizable-panels-ready + > .td-sidebar-toc::-webkit-scrollbar { + width: 8px; + } + + .row.flex-xl-nowrap.resizable-panels-ready + > .td-sidebar::-webkit-scrollbar-thumb, + .row.flex-xl-nowrap.resizable-panels-ready + > .td-sidebar-toc::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.3) !important; + border-radius: 999px; + } + + .row.flex-xl-nowrap.resizable-panels-ready + > .td-sidebar::-webkit-scrollbar-thumb:hover, + .row.flex-xl-nowrap.resizable-panels-ready + > .td-sidebar-toc::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.45) !important; + } + + /** + * RESET BUTTON + */ + .resizable-panel-reset { + align-items: center; + justify-content: center; + width: 100%; + background: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(0, 211, 169, 0.45); + border-radius: 4px; + color: #ccc; + cursor: pointer; + display: inline-flex; + font-size: 0.8125rem; + gap: 0.35rem; + line-height: 1.2; + margin: 1rem 0 0; + padding: 0.45rem 0.65rem; + white-space: nowrap; + } + + .resizable-panel-reset:hover, + .resizable-panel-reset:focus-visible { + background: #00d3a9; + border-color: #00d3a9; + color: #000; + outline: none; + } + + /** + * DRAGGING STATE + */ + .resizable-panels-dragging { + cursor: col-resize; + user-select: none; + } + + .resizable-panels-dragging iframe, + .resizable-panels-dragging img, + .resizable-panels-dragging a { + pointer-events: none; + } +} + +/** + * LARGE SCREENS + */ +@media (min-width: 1200px) { + .row.flex-xl-nowrap.resizable-panels-ready > main[role='main'] { + flex-basis: var(--docs-main-width); + max-width: var(--docs-main-width); + } + + .row.flex-xl-nowrap.resizable-panels-ready > .td-sidebar-toc { + flex: 0 0 var(--docs-toc-width); + } + + .resizable-panel-handle--toc { + display: block; + } +} + +/** + * PRINT MODE + */ +@media print { + .resizable-panel-handle, + .resizable-panel-reset { + display: none !important; + } +} \ No newline at end of file diff --git a/assets/scss/_styles_project.scss b/assets/scss/_styles_project.scss index 00ca1b8770b..44038655971 100644 --- a/assets/scss/_styles_project.scss +++ b/assets/scss/_styles_project.scss @@ -15,6 +15,7 @@ @import "elements_project"; @import "summary.scss"; @import "_kanvas-corner-popup.scss"; +@import "_resizable-panels.scss"; .navbar-dark { min-height: 5rem; @@ -272,6 +273,7 @@ a:not([href]):not([class]):hover { color: $gray-400; font-weight: $font-weight-bold; } + margin-top: 0.5rem; } .td-sidebar-link { diff --git a/layouts/partials/scripts.html b/layouts/partials/scripts.html new file mode 100644 index 00000000000..09d5183fe7f --- /dev/null +++ b/layouts/partials/scripts.html @@ -0,0 +1,13 @@ + +{{ $scripts := slice + "js/resizable-panels.js" + "js/kanvas-architectural-transition.js" + "js/kanvas-corner-popup.js" + "js/offline-search.js" +-}} + +{{ range $scripts -}} + {{ with resources.Get . -}} + + {{ end -}} +{{ end -}}