-
Notifications
You must be signed in to change notification settings - Fork 195
feat(layouts): add resizable side panels with localStorage persistence #1040
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 16 commits
895bfa1
53e9296
8dfa71e
a7040ab
4bcba70
3c4bb21
3671459
e945697
2e8475c
59647a8
ed5d256
c260d36
5fd3cc8
617019f
c2eb6d2
8ca58ae
2a31d58
da33a19
875a062
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,326 @@ | ||
| /** | ||
| * 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 }, | ||
| }; | ||
|
|
||
| class ResizablePanels { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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. |
||
| constructor(row) { | ||
| this.row = row; | ||
| this.sidebar = row.querySelector('.td-sidebar'); | ||
| this.main = row.querySelector('main[role="main"]'); | ||
| this.toc = row.querySelector('.td-sidebar-toc'); | ||
| this.mediaQuery = window.matchMedia(RESIZABLE_QUERY); | ||
| this.activeHandle = null; | ||
| this.startX = 0; | ||
| this.startWidths = null; | ||
| this.widths = this.getStoredWidths(); | ||
|
|
||
| if (!this.sidebar || !this.main) { | ||
| return; | ||
| } | ||
|
|
||
| this.init(); | ||
| } | ||
|
|
||
| init() { | ||
| this.row.classList.add('resizable-panels-ready'); | ||
| this.applyWidths(this.widths); | ||
| this.createHandles(); | ||
| this.createResetButton(); | ||
| this.bindEvents(); | ||
| } | ||
|
|
||
| bindEvents() { | ||
| document.addEventListener('pointermove', (event) => | ||
| this.onPointerMove(event), | ||
| ); | ||
| document.addEventListener('pointerup', () => this.stopResize()); | ||
| document.addEventListener('pointercancel', () => this.stopResize()); | ||
|
|
||
| const onBreakpointChange = () => { | ||
| if (this.mediaQuery.matches) { | ||
| this.applyWidths(this.widths); | ||
| } | ||
| }; | ||
|
|
||
| if (this.mediaQuery.addEventListener) { | ||
| this.mediaQuery.addEventListener('change', onBreakpointChange); | ||
| } else { | ||
| this.mediaQuery.addListener(onBreakpointChange); | ||
| } | ||
| } | ||
|
Comment on lines
+46
to
+58
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Binding function bindEvents() {
const onBreakpointChange = () => {
if (mediaQuery.matches) {
applyWidths(widths);
}
};
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', onBreakpointChange);
} else {
mediaQuery.addListener(onBreakpointChange);
}
} |
||
|
|
||
| createHandles() { | ||
| this.sidebarHandle = this.createHandle( | ||
| 'sidebar', | ||
| 'Resize navigation sidebar', | ||
| ); | ||
| this.sidebar.appendChild(this.sidebarHandle); | ||
|
|
||
| if (this.toc) { | ||
| this.tocHandle = this.createHandle('toc', 'Resize table of contents'); | ||
| this.toc.appendChild(this.tocHandle); | ||
| } | ||
| } | ||
|
|
||
| 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) => | ||
| this.startResize(event, handle), | ||
| ); | ||
| handle.addEventListener('keydown', (event) => | ||
| this.onHandleKeydown(event, target), | ||
| ); | ||
|
|
||
| return handle; | ||
| } | ||
|
|
||
| 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', () => this.reset()); | ||
|
|
||
| this.sidebar.appendChild(resetButton); | ||
| } | ||
|
Comment on lines
+90
to
+100
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Setting a hardcoded ID 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);
}
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After going through the code, there's only one instance per baseof.html. Also, the feedback given above is still pending for a response. |
||
|
|
||
| startResize(event, handle) { | ||
| if (!this.mediaQuery.matches) { | ||
| return; | ||
| } | ||
|
|
||
| event.preventDefault(); | ||
| this.activeHandle = handle; | ||
| this.startX = event.clientX; | ||
| this.startWidths = { ...this.widths }; | ||
| handle.classList.add('resizable-panel-handle--active'); | ||
| handle.setPointerCapture(event.pointerId); | ||
| document.body.classList.add('resizable-panels-dragging'); | ||
| } | ||
|
Comment on lines
+102
to
+126
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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);
} |
||
|
|
||
| onPointerMove(event) { | ||
| if (!this.activeHandle || !this.startWidths) { | ||
| return; | ||
| } | ||
|
|
||
| const rowWidth = this.row.getBoundingClientRect().width; | ||
| if (!rowWidth) { | ||
| return; | ||
| } | ||
|
|
||
| const target = this.activeHandle.dataset.resizeTarget; | ||
| const delta = ((event.clientX - this.startX) / rowWidth) * 100; | ||
| const nextWidths = { ...this.startWidths }; | ||
|
|
||
| if (target === 'sidebar') { | ||
| nextWidths.sidebar = this.startWidths.sidebar + delta; | ||
| } | ||
|
|
||
| if (target === 'toc') { | ||
| nextWidths.toc = this.startWidths.toc - delta; | ||
| } | ||
|
|
||
| this.widths = this.normalizeWidths(nextWidths); | ||
| this.applyWidths(this.widths); | ||
| } | ||
|
Comment on lines
+128
to
+152
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Throttle the pointer move updates using 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);
});
}
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thoughts on this?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Response is still pending. |
||
|
|
||
| stopResize() { | ||
| if (!this.activeHandle) { | ||
| return; | ||
| } | ||
|
|
||
| this.activeHandle.classList.remove('resizable-panel-handle--active'); | ||
| this.activeHandle = null; | ||
| this.startWidths = null; | ||
| document.body.classList.remove('resizable-panels-dragging'); | ||
| this.saveWidths(); | ||
| } | ||
|
Comment on lines
+154
to
+164
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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);
}
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Feedback is still not being addressed. |
||
|
|
||
| onHandleKeydown(event, target) { | ||
| if (!this.mediaQuery.matches) { | ||
| return; | ||
| } | ||
|
|
||
| const keys = ['ArrowLeft', 'ArrowRight', 'Home', 'End']; | ||
| if (!keys.includes(event.key)) { | ||
| return; | ||
| } | ||
|
|
||
| event.preventDefault(); | ||
| const nextWidths = { ...this.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; | ||
| } | ||
|
|
||
| this.widths = this.normalizeWidths(nextWidths); | ||
| this.applyWidths(this.widths); | ||
| this.saveWidths(); | ||
| } | ||
|
|
||
| applyWidths(widths) { | ||
| const normalized = this.normalizeWidths(widths); | ||
| const mainWidth = 100 - normalized.sidebar - normalized.toc; | ||
|
|
||
| this.row.style.setProperty( | ||
| '--docs-sidebar-width', | ||
| `${normalized.sidebar}%`, | ||
| ); | ||
| this.row.style.setProperty('--docs-toc-width', `${normalized.toc}%`); | ||
| this.row.style.setProperty('--docs-main-width', `${mainWidth}%`); | ||
| this.row.style.setProperty( | ||
| '--docs-main-without-toc-width', | ||
| `${100 - normalized.sidebar}%`, | ||
| ); | ||
| this.updateHandleValues(normalized); | ||
| } | ||
|
|
||
| updateHandleValues(widths) { | ||
| if (this.sidebarHandle) { | ||
| this.sidebarHandle.setAttribute('aria-valuemin', LIMITS.sidebar.min); | ||
| this.sidebarHandle.setAttribute('aria-valuemax', LIMITS.sidebar.max); | ||
| this.sidebarHandle.setAttribute( | ||
| 'aria-valuenow', | ||
| Math.round(widths.sidebar), | ||
| ); | ||
| } | ||
|
|
||
| if (this.tocHandle) { | ||
| this.tocHandle.setAttribute('aria-valuemin', LIMITS.toc.min); | ||
| this.tocHandle.setAttribute('aria-valuemax', LIMITS.toc.max); | ||
| this.tocHandle.setAttribute('aria-valuenow', Math.round(widths.toc)); | ||
| } | ||
| } | ||
|
|
||
| reset() { | ||
| this.widths = { ...DEFAULT_WIDTHS }; | ||
| this.applyWidths(this.widths); | ||
| localStorage.removeItem(STORAGE_KEY); | ||
| } | ||
|
|
||
| getStoredWidths() { | ||
| try { | ||
| const saved = JSON.parse(localStorage.getItem(STORAGE_KEY)); | ||
| if ( | ||
| saved && | ||
| (saved.sidebar <= 12 || saved.toc <= 12 || saved.main <= 12) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 }, 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. |
||
| ) { | ||
| return this.normalizeWidths({ | ||
| sidebar: saved.sidebar | ||
| ? (saved.sidebar / 12) * 100 | ||
| : DEFAULT_WIDTHS.sidebar, | ||
| toc: saved.toc ? (saved.toc / 12) * 100 : DEFAULT_WIDTHS.toc, | ||
| }); | ||
| } | ||
|
|
||
| return this.normalizeWidths(saved || DEFAULT_WIDTHS); | ||
| } catch (error) { | ||
| return { ...DEFAULT_WIDTHS }; | ||
| } | ||
| } | ||
|
|
||
| saveWidths() { | ||
| try { | ||
| localStorage.setItem(STORAGE_KEY, JSON.stringify(this.widths)); | ||
| } catch (error) { | ||
| // Ignore storage failures so resizing still works in private modes. | ||
| } | ||
| } | ||
|
|
||
| normalizeWidths(widths) { | ||
| const next = { | ||
| sidebar: this.clamp( | ||
| Number(widths && widths.sidebar), | ||
| LIMITS.sidebar.min, | ||
| LIMITS.sidebar.max, | ||
| DEFAULT_WIDTHS.sidebar, | ||
| ), | ||
| toc: this.toc | ||
| ? this.clamp( | ||
| Number(widths && widths.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)), | ||
| }; | ||
| } | ||
|
|
||
| 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'; | ||
| new ResizablePanels(row); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| if (document.readyState === 'loading') { | ||
| document.addEventListener('DOMContentLoaded', initResizablePanels); | ||
| } else { | ||
| initResizablePanels(); | ||
| } | ||
| })(); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.