feat(layouts): add resizable side panels with localStorage persistence#1040
feat(layouts): add resizable side panels with localStorage persistence#1040Rudra2637 wants to merge 17 commits into
Conversation
Signed-off-by: Rudra2637 <singhrudra2637@gmail.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a resizable panels feature, allowing users to adjust the widths of the sidebar and table of contents with persistence via localStorage. The implementation includes a new JavaScript class, SCSS styling for handles, and a reset layout button. Several issues were identified in the review, including a syntax error in the SCSS transition property and an incorrect script loading method in the HTML template. Additionally, feedback was provided to improve code quality by removing unused variables, optimizing event listener performance, and replacing magic numbers with named constants for better maintainability.
| .td-sidebar, | ||
| .td-sidebar-toc, | ||
| main[role="main"] { | ||
| transition: flex: 0.3s ease; |
| @@ -0,0 +1,16 @@ | |||
| <!-- Page scripts --> | |||
| <script defer src="{{ "js/resizable-panels.js" | relURL }}"></script> | |||
There was a problem hiding this comment.
The script js/resizable-panels.js is located in the assets/ directory, but it's being loaded using relURL, which typically points to the static/ directory. This will likely result in a 404 error. It should be loaded using Hugo's resources.Get to properly process it from the assets/ folder, consistent with the other scripts in this file.
| <script defer src="{{ "js/resizable-panels.js" | relURL }}"></script> | |
| {{ $resizablePanels := resources.Get "js/resizable-panels.js" -}} | |
| {{ if $resizablePanels }} | |
| <script defer src="{{ $resizablePanels.RelPermalink }}"></script> | |
| {{ end -}} |
| const COL_CLASSES = { | ||
| 'col-1': 8.33, | ||
| 'col-2': 16.66, | ||
| 'col-3': 25, | ||
| 'col-4': 33.33, | ||
| 'col-5': 41.66, | ||
| 'col-6': 50, | ||
| 'col-7': 58.33, | ||
| 'col-8': 66.66, | ||
| 'col-9': 75, | ||
| 'col-10': 83.33, | ||
| 'col-11': 91.66, | ||
| 'col-12': 100 | ||
| }; |
| this.isResizing = false; | ||
| this.currentResizeTarget = null; | ||
| this.startX = 0; | ||
| this.startWidth = 0; |
| addEventListeners() { | ||
| document.addEventListener('mousedown', (e) => this.onMouseDown(e)); | ||
| document.addEventListener('mousemove', (e) => this.onMouseMove(e)); | ||
| document.addEventListener('mouseup', (e) => this.onMouseUp(e)); | ||
| } |
There was a problem hiding this comment.
| const newMainPercent = mainPercent - (adjustment * 100); | ||
|
|
||
| // Constrain widths: min 1 col, max 5 cols for sidebar; min 4 cols for main | ||
| if (newSidebarPercent >= 8.33 && newSidebarPercent <= 41.66 && newMainPercent >= 33.33) { |
There was a problem hiding this comment.
Signed-off-by: Rudra2637 <singhrudra2637@gmail.com>
|
🚀 Preview deployment: https://layer5io.github.io/docs/pr-preview/pr-1040/
|
Signed-off-by: Rudra2637 <singhrudra2637@gmail.com>
| overflow: hidden; | ||
| padding: 0px; | ||
|
|
||
| &__section-title { |
There was a problem hiding this comment.
@Rudra2637 , Thanks for the effort on this PR. I think these changes can be reverted, as this issue can likely be resolved by simply adding margin-top: 0.5rem inside &_section-title container;
There was a problem hiding this comment.
Thanks for the feedback! I’ll revert the current changes and update the PR by adding margin-top: 0.5rem inside the &_section-title container instead.
There was a problem hiding this comment.
I initially interpreted the issue as requiring a broader UI adjustment and didn’t notice that the panel was already scrollable
Signed-off-by: Rudra2637 <singhrudra2637@gmail.com>
|
@Rudra2637 Thank you for your contribution! Let's discuss this during the website call tomorrow at 5:30 PM IST | 7 AM CST Add it as an agenda item to the meeting minutes, if you would 🙂 |
|
@banana-three-join, how are we doing here? |
|
REPEAT: Acceptance Tests |
banana-three-join
left a comment
There was a problem hiding this comment.
Disregard the last comment and bring back the changes you previously implemented. You were right about the issue, it requires to adjust the width of both side panels so it fits the user's preference so the content can be read. The previous suggestion has nothing to do with what needs to be implemented, as only margin on the y axis gets added, which by definition, doesn't affect the width of the container.
Go back to the commit with your proposed changes, and once that's done, I'll be reviewing your PR again!
|
Thanks for the clarification. I’ll revert back to the previous implementation with the side panel width adjustments and update the PR accordingly. Once that’s done, will let you know. |
Signed-off-by: Rudra2637 <singhrudra2637@gmail.com>
Signed-off-by: Rudra2637 <singhrudra2637@gmail.com>
Signed-off-by: Rudra2637 <singhrudra2637@gmail.com>
Signed-off-by: Rudra2637 <singhrudra2637@gmail.com>
Signed-off-by: Rudra2637 <singhrudra2637@gmail.com>
Signed-off-by: Rudra2637 <singhrudra2637@gmail.com>
Signed-off-by: Rudra2637 <singhrudra2637@gmail.com>
Signed-off-by: Rudra2637 <singhrudra2637@gmail.com>
There was a problem hiding this comment.
Instead of single handedly adding each resource, add all of the needed resources in an array and loop through the slice to request the .RelPermalink
| main: { min: 42 }, | ||
| }; | ||
|
|
||
| class ResizablePanels { |
There was a problem hiding this comment.
The usage of classes is an overkill. The classes are discarded just as quickly as they got created, adding a lot of boilerplate in the process which makes the code harded to read.
| const saved = JSON.parse(localStorage.getItem(STORAGE_KEY)); | ||
| if ( | ||
| saved && | ||
| (saved.sidebar <= 12 || saved.toc <= 12 || saved.main <= 12) |
There was a problem hiding this comment.
Don't utilize magic numbers because if this code checks the current storedWidth and compares it with the limit of each item to normalize it to the intended min size then this check is wrong because above you have the following Limits:
sidebar: { min: 12, max: 32 },
toc: { min: 10, max: 28 },
main: { min: 42 },
So now all the items are checking it's width against 12 and not their intended bottom limit. Also, the upper limit isn't being checked.
|
Ok, I will look into the suggested changes and re request review once i am done adjusting the pr as per the suggestions |
Signed-off-by: Rudra2637 <singhrudra2637@gmail.com>
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces resizable documentation panels, allowing users to adjust the widths of the left navigation sidebar and the right table of contents (TOC). It includes a JavaScript module to handle dragging, keyboard accessibility, and persistence via localStorage, alongside SCSS styles for layout and visual indicators. The review feedback focuses on improving performance and robustness: it suggests removing hardcoded pixel limits in the CSS that conflict with JavaScript percentage calculations, throttling pointer move updates using requestAnimationFrame to prevent layout thrashing, dynamically binding pointer events only during active resizing, and removing a hardcoded HTML ID to prevent duplicates when multiple panels are initialized.
| .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; | ||
| } |
There was a problem hiding this comment.
The hardcoded pixel limits (min-width: 240px and max-width: 420px) in the CSS conflict with the percentage-based limits (min: 12, max: 32) defined in the JavaScript. When these pixel boundaries are hit, the browser will clamp the sidebar width, but the JS will continue to update the percentage width and the drag handle position, causing them to become visually desynchronized. Removing these pixel limits from the CSS allows the JS percentage limits to govern the boundaries consistently.
.row.flex-xl-nowrap.resizable-panels-ready > .td-sidebar {
flex: 0 0 var(--docs-sidebar-width);
width: var(--docs-sidebar-width);
position: relative;
overflow-x: hidden;
padding-right: 12px;
order: 1;
}| .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; | ||
| } |
There was a problem hiding this comment.
The hardcoded pixel limits (min-width: 220px and max-width: 320px) in the CSS conflict with the percentage-based limits (min: 10, max: 28) defined in the JavaScript. When these pixel boundaries are hit, the browser will clamp the TOC width, but the JS will continue to update the percentage width and the drag handle position, causing them to become visually desynchronized. Removing these pixel limits from the CSS allows the JS percentage limits to govern the boundaries consistently.
.row.flex-xl-nowrap.resizable-panels-ready > .td-sidebar-toc {
width: var(--docs-toc-width);
position: relative;
overflow-x: hidden;
padding-left: 12px;
order: 3;
}| let activeHandle = null; | ||
| let sidebarHandle = null; | ||
| let tocHandle = null; | ||
| let startX = 0; | ||
| let startWidths = null; | ||
| let widths = getStoredWidths(); |
There was a problem hiding this comment.
To support smooth, high-performance resizing without layout thrashing, we should throttle the DOM updates using requestAnimationFrame. Let's declare a variable to hold the animation frame ID so we can cancel any pending frames when a new pointer move event occurs or when resizing stops.
| let activeHandle = null; | |
| let sidebarHandle = null; | |
| let tocHandle = null; | |
| let startX = 0; | |
| let startWidths = null; | |
| let widths = getStoredWidths(); | |
| let activeHandle = null; | |
| let sidebarHandle = null; | |
| let tocHandle = null; | |
| let startX = 0; | |
| let startWidths = null; | |
| let widths = getStoredWidths(); | |
| let animationFrameId = null; |
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
Binding pointermove, pointerup, and pointercancel listeners globally on the document at all times is inefficient and can cause performance degradation, as these handlers will execute on every mouse/pointer movement even when the user is not resizing. Instead, we should dynamically bind these listeners only when resizing starts (pointerdown) and unbind them when resizing stops.
function bindEvents() {
const onBreakpointChange = () => {
if (mediaQuery.matches) {
applyWidths(widths);
}
};
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', onBreakpointChange);
} else {
mediaQuery.addListener(onBreakpointChange);
}
}| function createResetButton() { | ||
| const resetButton = document.createElement('button'); | ||
| resetButton.type = 'button'; | ||
| resetButton.id = 'reset-panel-widths'; | ||
| resetButton.className = 'resizable-panel-reset'; | ||
| resetButton.innerHTML = | ||
| '<i class="bi bi-arrow-clockwise" aria-hidden="true"></i><span>Reset layout</span>'; | ||
| resetButton.title = 'Reset panel widths to default'; | ||
| resetButton.addEventListener('click', reset); | ||
|
|
||
| sidebar.appendChild(resetButton); | ||
| } |
There was a problem hiding this comment.
Setting a hardcoded ID reset-panel-widths on the reset button will result in duplicate IDs in the DOM if multiple resizable rows are initialized on the same page. Since the styling in _resizable-panels.scss only targets the class .resizable-panel-reset, we can safely remove the id attribute to maintain valid HTML standards.
function createResetButton() {
const resetButton = document.createElement('button');
resetButton.type = 'button';
resetButton.className = 'resizable-panel-reset';
resetButton.innerHTML =
'<i class="bi bi-arrow-clockwise" aria-hidden="true"></i><span>Reset layout</span>';
resetButton.title = 'Reset panel widths to default';
resetButton.addEventListener('click', reset);
sidebar.appendChild(resetButton);
}There was a problem hiding this comment.
If the indicator for a "resizable-panel" is it having a ".row.flex-xl-nowrap" then the id comment is correct. But this means that there's multiple items that have different sidebars which are being managed individually. Is the difference in design that big for there to be different size managers for the side panels?
| 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'); | ||
| } |
There was a problem hiding this comment.
Dynamically attach the global pointer move and up/cancel event listeners here when the user actually starts dragging the handle.
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');
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', stopResize);
document.addEventListener('pointercancel', stopResize);
}| 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); | ||
| } |
There was a problem hiding this comment.
Throttle the pointer move updates using requestAnimationFrame to ensure smooth 60fps+ resizing and prevent layout thrashing.
function onPointerMove(event) {
if (!activeHandle || !startWidths) {
return;
}
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
const clientX = event.clientX;
animationFrameId = requestAnimationFrame(() => {
const rowWidth = row.getBoundingClientRect().width;
if (!rowWidth) {
return;
}
const target = activeHandle.dataset.resizeTarget;
const delta = ((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);
});
}There was a problem hiding this comment.
Thoughts on this?
| function stopResize() { | ||
| if (!activeHandle) { | ||
| return; | ||
| } | ||
|
|
||
| activeHandle.classList.remove('resizable-panel-handle--active'); | ||
| activeHandle = null; | ||
| startWidths = null; | ||
| document.body.classList.remove('resizable-panels-dragging'); | ||
| saveWidths(); | ||
| } |
There was a problem hiding this comment.
Clean up the dynamic pointer event listeners and cancel any pending animation frames when resizing stops.
function stopResize() {
if (!activeHandle) {
return;
}
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
activeHandle.classList.remove('resizable-panel-handle--active');
activeHandle = null;
startWidths = null;
document.body.classList.remove('resizable-panels-dragging');
saveWidths();
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', stopResize);
document.removeEventListener('pointercancel', stopResize);
}|
@Rudra2637 Please review the recommendations and resolved them. The most recent recommendations (Both mine's a Gemini's) don't point out any major bugs so after the feedback has been addressed, and it has been stated how it has been addressed, a video proof should be more than enough to merge the pr! |
|
Ok i will look into your suggestions as well as gemini and create video |
|
The latest recommendations have been addressed. The resource-loading code now uses an array and loop instead of adding each asset individually, the resizable-panels logic has already been refactored to avoid unnecessary class usage, and the width normalization now uses the defined per-item limits instead of a shared magic number. Screen.Recording.2026-05-29.194900.mp4 |
There was a problem hiding this comment.
I looked further and the addition of scripts.html isn't needed for the following reason:
All of the JS assets being imported "aside from resizable-panels.js" are currently being imported elsewhere in the code, so it's inclusion isn't needed, not only because they're already imported, but also because they are being imported globally even when they aren't needed.
scripts.html shouldn't be added, as Docsy (The theme currently being imported) already has a scripts.html so adding another one would override the one being currently in use. Instead, the "resizable-panels.js" script should be imported where both "td-sidebar" are being used.
| function createResetButton() { | ||
| const resetButton = document.createElement('button'); | ||
| resetButton.type = 'button'; | ||
| resetButton.id = 'reset-panel-widths'; | ||
| resetButton.className = 'resizable-panel-reset'; | ||
| resetButton.innerHTML = | ||
| '<i class="bi bi-arrow-clockwise" aria-hidden="true"></i><span>Reset layout</span>'; | ||
| resetButton.title = 'Reset panel widths to default'; | ||
| resetButton.addEventListener('click', reset); | ||
|
|
||
| sidebar.appendChild(resetButton); | ||
| } |
There was a problem hiding this comment.
If the indicator for a "resizable-panel" is it having a ".row.flex-xl-nowrap" then the id comment is correct. But this means that there's multiple items that have different sidebars which are being managed individually. Is the difference in design that big for there to be different size managers for the side panels?
| 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); | ||
| } |
There was a problem hiding this comment.
Thoughts on this?
There was a problem hiding this comment.
The feedback regarding the dynamic use of the event listening for the dragging of the panels hasn't been implemented. As of right now, there's always an event listening of the pointer movement despite there being a window in which a listening for those particulars event isn't needed.
Description
This PR implements the resizable side panels feature for Layer5 Docs, allowing users to customize the width of the left sidebar and right table of contents (TOC) panel.
Related Issue
Fixes #1039
Changes Made
assets/js/resizable-panels.js): Drag-to-resize functionality with localStorage persistence and reset buttonassets/scss/_resizable-panels.scss): Visual feedback for resize handles, responsive hiding on mobile/printFeatures Implemented
✅ Users can drag to adjust panel widths (left sidebar and right TOC)
✅ Preferences are saved in localStorage and persist across page visits
✅ Reset button available to restore default panel widths
✅ Responsive design: resize functionality only on desktop (XL screens)
✅ Constrained min/max widths to prevent layout breaking
Testing
Notes for Reviewers
Per the acceptance criteria, a matching issue should be created for Meshery Docs to receive the same enhancement.
Signed Commits
Contributing Conventions: PR includes descriptive title with [layouts] component, changes tested, and commits are signed.