Skip to content
Closed
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
326 changes: 326 additions & 0 deletions assets/js/resizable-panels.js

Copy link
Copy Markdown
Contributor

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.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts on this?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 },
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.

) {
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();
}
})();
Loading
Loading