@primer/react
Version:
An implementation of GitHub's Primer Design System using React
296 lines (265 loc) • 12.3 kB
JavaScript
import React, { startTransition } from 'react';
import { canUseDOM } from '../utils/environment.js';
import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect.js';
import classes from './PageLayout.module.css.js';
// ----------------------------------------------------------------------------
// Types
// ----------------------------------------------------------------------------
// Constants
/**
* Default value for --pane-max-width-diff CSS variable.
* Imported from CSS to ensure JS fallback matches the CSS default.
*/
const DEFAULT_MAX_WIDTH_DIFF = Number(classes.paneMaxWidthDiffDefault);
// --pane-max-width-diff changes at this breakpoint in PageLayout.module.css.
const DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT = Number(classes.paneMaxWidthDiffBreakpoint);
/**
* Default max pane width for SSR when viewport is unknown.
* Updated to actual value in layout effect before paint.
*/
const SSR_DEFAULT_MAX_WIDTH = 600;
/**
* Pixel increment for keyboard arrow key resizing.
* @see https://github.com/github/accessibility/issues/5101#issuecomment-1822870655
*/
const ARROW_KEY_STEP = 3;
/** Default widths for preset size options */
const defaultPaneWidth = {
small: 256,
medium: 296,
large: 320
};
// ----------------------------------------------------------------------------
// Helper functions
const isCustomWidthOptions = width => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return width.default !== undefined;
};
const isPaneWidth = width => {
return width === 'small' || width === 'medium' || width === 'large';
};
const getDefaultPaneWidth = w => {
if (isPaneWidth(w)) {
return defaultPaneWidth[w];
} else if (isCustomWidthOptions(w)) {
return parseInt(w.default, 10);
}
return 0;
};
/**
* Gets the --pane-max-width-diff CSS variable value from a pane element.
* This value is set by CSS media queries and controls the max pane width constraint.
* Note: This calls getComputedStyle which forces layout - cache the result when possible.
*/
function getPaneMaxWidthDiff(paneElement) {
if (!paneElement) return DEFAULT_MAX_WIDTH_DIFF;
const value = parseInt(getComputedStyle(paneElement).getPropertyValue('--pane-max-width-diff'), 10);
return value > 0 ? value : DEFAULT_MAX_WIDTH_DIFF;
}
// Helper to update ARIA slider attributes via direct DOM manipulation
// This avoids re-renders when values change during drag or on viewport resize
const updateAriaValues = (handle, values) => {
if (!handle) return;
if (values.min !== undefined) handle.setAttribute('aria-valuemin', String(values.min));
if (values.max !== undefined) handle.setAttribute('aria-valuemax', String(values.max));
if (values.current !== undefined) {
handle.setAttribute('aria-valuenow', String(values.current));
handle.setAttribute('aria-valuetext', `Pane width ${values.current} pixels`);
}
};
// ----------------------------------------------------------------------------
// Hook
/**
* Manages pane width state with localStorage persistence and viewport constraints.
* Handles initialization from storage, clamping on viewport resize, and provides
* functions to save and reset width.
*/
function usePaneWidth({
width,
minWidth,
resizable,
widthStorageKey,
paneRef,
handleRef,
contentWrapperRef
}) {
// Derive constraints from width configuration
const isCustomWidth = isCustomWidthOptions(width);
const minPaneWidth = isCustomWidth ? parseInt(width.min, 10) : minWidth;
const customMaxWidth = isCustomWidth ? parseInt(width.max, 10) : null;
// Cache the CSS variable value to avoid getComputedStyle during drag (causes layout thrashing)
// Updated on mount and resize when breakpoints might change
const maxWidthDiffRef = React.useRef(DEFAULT_MAX_WIDTH_DIFF);
// Calculate max width constraint - for custom widths this is fixed, otherwise viewport-dependent
const getMaxPaneWidth = React.useCallback(() => {
if (customMaxWidth !== null) return customMaxWidth;
const viewportWidth = window.innerWidth;
return viewportWidth > 0 ? Math.max(minPaneWidth, viewportWidth - maxWidthDiffRef.current) : minPaneWidth;
}, [customMaxWidth, minPaneWidth]);
// --- State ---
// Current width for React renders (ARIA attributes). Updates go through saveWidth() or clamp on resize.
//
// NOTE: We read from localStorage during initial state to avoid a visible resize flicker
// when the stored width differs from the default. This causes a React hydration mismatch
// (server renders default width, client renders stored width), but we handle this with
// suppressHydrationWarning on the Pane element. The mismatch only affects the --pane-width
// CSS variable, not DOM structure or children.
const [currentWidth, setCurrentWidth] = React.useState(() => {
const defaultWidth = getDefaultPaneWidth(width);
if (!resizable || !canUseDOM) {
return defaultWidth;
}
try {
const storedWidth = localStorage.getItem(widthStorageKey);
if (storedWidth !== null) {
const parsed = Number(storedWidth);
if (!isNaN(parsed) && parsed > 0) {
return parsed;
}
}
} catch {
// localStorage unavailable - keep default
}
return defaultWidth;
});
// Mutable ref for drag operations - avoids re-renders on every pixel move
const currentWidthRef = React.useRef(currentWidth);
// Max width for ARIA - SSR uses custom max or a sensible default, updated on mount
const [maxPaneWidth, setMaxPaneWidth] = React.useState(() => customMaxWidth !== null && customMaxWidth !== void 0 ? customMaxWidth : SSR_DEFAULT_MAX_WIDTH);
// --- Callbacks ---
const getDefaultWidth = React.useCallback(() => getDefaultPaneWidth(width), [width]);
const saveWidth = React.useCallback(value => {
currentWidthRef.current = value;
// Visual update already done via inline styles - React state sync is non-urgent
startTransition(() => {
setCurrentWidth(value);
});
try {
localStorage.setItem(widthStorageKey, value.toString());
} catch {
// Ignore write errors (private browsing, quota exceeded, etc.)
}
}, [widthStorageKey]);
// --- Effects ---
// Stable ref to getMaxPaneWidth for use in resize handler without re-subscribing
const getMaxPaneWidthRef = React.useRef(getMaxPaneWidth);
useIsomorphicLayoutEffect(() => {
getMaxPaneWidthRef.current = getMaxPaneWidth;
});
// Update CSS variable, refs, and ARIA on mount and window resize.
// Strategy: Only sync when resize stops (debounced) to avoid layout thrashing on large DOMs
useIsomorphicLayoutEffect(() => {
var _paneRef$current3;
if (!resizable) return;
let lastViewportWidth = window.innerWidth;
// Full sync of refs, ARIA, and state (debounced, runs when resize stops)
const syncAll = () => {
var _paneRef$current;
const currentViewportWidth = window.innerWidth;
// Only call getComputedStyle if we crossed the breakpoint (expensive)
const crossedBreakpoint = lastViewportWidth < DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT && currentViewportWidth >= DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT || lastViewportWidth >= DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT && currentViewportWidth < DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT;
lastViewportWidth = currentViewportWidth;
if (crossedBreakpoint) {
maxWidthDiffRef.current = getPaneMaxWidthDiff(paneRef.current);
}
const actualMax = getMaxPaneWidthRef.current();
// Update CSS variable for visual clamping (may already be set by throttled update)
(_paneRef$current = paneRef.current) === null || _paneRef$current === void 0 ? void 0 : _paneRef$current.style.setProperty('--pane-max-width', `${actualMax}px`);
// Track if we clamped current width
const wasClamped = currentWidthRef.current > actualMax;
if (wasClamped) {
var _paneRef$current2;
currentWidthRef.current = actualMax;
(_paneRef$current2 = paneRef.current) === null || _paneRef$current2 === void 0 ? void 0 : _paneRef$current2.style.setProperty('--pane-width', `${actualMax}px`);
}
// Update ARIA via DOM - cheap, no React re-render
updateAriaValues(handleRef.current, {
max: actualMax,
current: currentWidthRef.current
});
// Defer state updates so parent re-renders see accurate values
startTransition(() => {
setMaxPaneWidth(actualMax);
if (wasClamped) {
setCurrentWidth(actualMax);
}
});
};
// Initial calculation on mount
maxWidthDiffRef.current = getPaneMaxWidthDiff(paneRef.current);
const initialMax = getMaxPaneWidthRef.current();
setMaxPaneWidth(initialMax);
(_paneRef$current3 = paneRef.current) === null || _paneRef$current3 === void 0 ? void 0 : _paneRef$current3.style.setProperty('--pane-max-width', `${initialMax}px`);
updateAriaValues(handleRef.current, {
min: minPaneWidth,
max: initialMax,
current: currentWidthRef.current
});
// For custom widths, max is fixed - no need to listen to resize
if (customMaxWidth !== null) return;
// Throttle approach for window resize - provides immediate visual feedback for small DOMs
// while still limiting update frequency
const THROTTLE_MS = 16; // ~60fps
const DEBOUNCE_MS = 150; // Delay before removing containment after resize stops
let lastUpdateTime = 0;
let pendingUpdate = false;
let rafId = null;
let debounceId = null;
let isResizing = false;
const startResizeOptimizations = () => {
var _paneRef$current4, _contentWrapperRef$cu;
if (isResizing) return;
isResizing = true;
(_paneRef$current4 = paneRef.current) === null || _paneRef$current4 === void 0 ? void 0 : _paneRef$current4.setAttribute('data-dragging', 'true');
(_contentWrapperRef$cu = contentWrapperRef.current) === null || _contentWrapperRef$cu === void 0 ? void 0 : _contentWrapperRef$cu.setAttribute('data-dragging', 'true');
};
const endResizeOptimizations = () => {
var _paneRef$current5, _contentWrapperRef$cu2;
if (!isResizing) return;
isResizing = false;
(_paneRef$current5 = paneRef.current) === null || _paneRef$current5 === void 0 ? void 0 : _paneRef$current5.removeAttribute('data-dragging');
(_contentWrapperRef$cu2 = contentWrapperRef.current) === null || _contentWrapperRef$cu2 === void 0 ? void 0 : _contentWrapperRef$cu2.removeAttribute('data-dragging');
};
const handleResize = () => {
// Apply containment on first resize event (stays applied until resize stops)
startResizeOptimizations();
const now = Date.now();
if (now - lastUpdateTime >= THROTTLE_MS) {
lastUpdateTime = now;
syncAll();
} else if (!pendingUpdate) {
pendingUpdate = true;
rafId = requestAnimationFrame(() => {
pendingUpdate = false;
rafId = null;
lastUpdateTime = Date.now();
syncAll();
});
}
// Debounce the cleanup — remove containment after resize stops
if (debounceId !== null) clearTimeout(debounceId);
debounceId = setTimeout(() => {
debounceId = null;
endResizeOptimizations();
}, DEBOUNCE_MS);
};
// eslint-disable-next-line github/prefer-observers -- Uses window resize events instead of ResizeObserver to avoid INP issues. ResizeObserver on document.documentElement fires on any content change (typing, etc), while window resize only fires on actual viewport changes.
window.addEventListener('resize', handleResize);
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
if (debounceId !== null) clearTimeout(debounceId);
endResizeOptimizations();
window.removeEventListener('resize', handleResize);
};
}, [resizable, customMaxWidth, minPaneWidth, paneRef, handleRef, contentWrapperRef]);
return {
currentWidth,
currentWidthRef,
minPaneWidth,
maxPaneWidth,
getMaxPaneWidth,
saveWidth,
getDefaultWidth
};
}
export { ARROW_KEY_STEP, DEFAULT_MAX_WIDTH_DIFF, SSR_DEFAULT_MAX_WIDTH, defaultPaneWidth, getDefaultPaneWidth, getPaneMaxWidthDiff, isCustomWidthOptions, isPaneWidth, updateAriaValues, usePaneWidth };