@primer/react
Version:
An implementation of GitHub's Primer Design System using React
463 lines (446 loc) • 16.1 kB
JavaScript
import { c } from 'react-compiler-runtime';
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
/**
* Width value for the pane - defines constraints and defaults only.
* - `PaneWidth`: Preset size ('small' | 'medium' | 'large')
* - `CustomWidthOptions`: Explicit min/default/max constraints
*/
// ----------------------------------------------------------------------------
// 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);
/**
* Default value for --sidebar-max-width-diff CSS variable.
* Unlike --pane-max-width-diff, this is constant across all viewport sizes.
*/
const DEFAULT_SIDEBAR_MAX_WIDTH_DIFF = Number(classes.sidebarMaxWidthDiffDefault);
// Value for --pane-max-width-diff at/above the wide breakpoint.
const WIDE_MAX_WIDTH_DIFF = Number(classes.paneMaxWidthDiffWide);
// --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 => {
return typeof width === 'object' && 'min' in width && 'default' in width && 'max' in width;
};
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;
};
/**
* Derives the --pane-max-width-diff value from viewport width alone.
* Avoids the expensive getComputedStyle call that forces a synchronous layout recalc.
* The CSS only defines two breakpoint-dependent values, so a simple width check is equivalent.
*/
function getMaxWidthDiffFromViewport() {
if (!canUseDOM) return DEFAULT_MAX_WIDTH_DIFF;
return window.innerWidth >= DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT ? WIDE_MAX_WIDTH_DIFF : 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`);
}
};
const localStoragePersister = {
save: (key, width) => {
try {
localStorage.setItem(key, width.toString());
} catch {
// Ignore write errors (private browsing, quota exceeded, etc.)
}
},
get: key => {
try {
const storedWidth = localStorage.getItem(key);
if (storedWidth !== null) {
const parsed = Number(storedWidth);
if (!isNaN(parsed) && parsed > 0) {
// Round to handle legacy float values from before Math.round was added to saveWidth
return Math.round(parsed);
}
}
} catch {
// localStorage unavailable
}
return null;
}
};
// ----------------------------------------------------------------------------
// Hook
/**
* Manages pane width state with storage persistence and viewport constraints.
* Handles initialization from storage, clamping on viewport resize, and provides
* functions to save and reset width.
*
* Storage behavior:
* - When `resizable` is `true` and `onResizeEnd` is not provided: Uses localStorage
* - When `onResizeEnd` is provided: Calls the callback instead of localStorage
* - When `resizable` is `false` or `undefined`: Not resizable, no persistence
*/
function usePaneWidth(t0) {
const $ = c(42);
const {
width,
minWidth,
resizable,
widthStorageKey,
paneRef,
handleRef,
contentWrapperRef,
constrainToViewport: t1,
onResizeEnd,
currentWidth: controlledWidth
} = t0;
const constrainToViewport = t1 === undefined ? false : t1;
const isCustomWidth = isCustomWidthOptions(width);
const minPaneWidth = isCustomWidth ? parseInt(width.min, 10) : minWidth;
const customMaxWidth = isCustomWidth ? parseInt(width.max, 10) : null;
const widthStorageKeyRef = React.useRef(widthStorageKey);
const onResizeEndRef = React.useRef(onResizeEnd);
let t2;
if ($[0] !== onResizeEnd || $[1] !== widthStorageKey) {
t2 = () => {
widthStorageKeyRef.current = widthStorageKey;
onResizeEndRef.current = onResizeEnd;
};
$[0] = onResizeEnd;
$[1] = widthStorageKey;
$[2] = t2;
} else {
t2 = $[2];
}
useIsomorphicLayoutEffect(t2);
const maxWidthDiffRef = React.useRef(constrainToViewport ? DEFAULT_SIDEBAR_MAX_WIDTH_DIFF : DEFAULT_MAX_WIDTH_DIFF);
let t3;
if ($[3] !== constrainToViewport || $[4] !== customMaxWidth || $[5] !== minPaneWidth) {
t3 = () => {
const viewportWidth = window.innerWidth;
const viewportMax = viewportWidth > 0 ? Math.max(minPaneWidth, viewportWidth - maxWidthDiffRef.current) : minPaneWidth;
if (customMaxWidth !== null) {
return constrainToViewport ? Math.min(customMaxWidth, viewportMax) : customMaxWidth;
}
return viewportMax;
};
$[3] = constrainToViewport;
$[4] = customMaxWidth;
$[5] = minPaneWidth;
$[6] = t3;
} else {
t3 = $[6];
}
const getMaxPaneWidth = t3;
let t4;
if ($[7] !== width) {
t4 = getDefaultPaneWidth(width);
$[7] = width;
$[8] = t4;
} else {
t4 = $[8];
}
const defaultWidth = t4;
let t5;
if ($[9] !== controlledWidth || $[10] !== defaultWidth || $[11] !== onResizeEnd || $[12] !== resizable || $[13] !== widthStorageKey) {
t5 = () => {
if (typeof controlledWidth === "number") {
return controlledWidth;
}
const shouldUseLocalStorage = onResizeEnd === undefined && resizable === true && widthStorageKey !== undefined;
if (shouldUseLocalStorage) {
const storedWidth = localStoragePersister.get(widthStorageKey);
if (storedWidth !== null) {
return storedWidth;
}
}
return defaultWidth;
};
$[9] = controlledWidth;
$[10] = defaultWidth;
$[11] = onResizeEnd;
$[12] = resizable;
$[13] = widthStorageKey;
$[14] = t5;
} else {
t5 = $[14];
}
const [currentWidthState, setCurrentWidthState] = React.useState(t5);
const [prevDefaultWidth, setPrevDefaultWidth] = React.useState(defaultWidth);
const [prevControlledWidth, setPrevControlledWidth] = React.useState(controlledWidth);
const controlledWidthChanged = controlledWidth !== prevControlledWidth;
const defaultWidthChanged = defaultWidth !== prevDefaultWidth;
if (controlledWidthChanged) {
setPrevControlledWidth(controlledWidth);
if (typeof controlledWidth === "number") {
setCurrentWidthState(controlledWidth);
} else {
if (prevControlledWidth !== undefined) {
setCurrentWidthState(defaultWidth);
}
}
}
if (defaultWidthChanged) {
setPrevDefaultWidth(defaultWidth);
if (controlledWidth === undefined && !controlledWidthChanged) {
setCurrentWidthState(defaultWidth);
}
}
const currentWidth = controlledWidth !== null && controlledWidth !== void 0 ? controlledWidth : currentWidthState;
const currentWidthRef = React.useRef(currentWidth);
let t6;
if ($[15] !== customMaxWidth) {
t6 = () => customMaxWidth !== null && customMaxWidth !== void 0 ? customMaxWidth : SSR_DEFAULT_MAX_WIDTH;
$[15] = customMaxWidth;
$[16] = t6;
} else {
t6 = $[16];
}
const [maxPaneWidth, setMaxPaneWidth] = React.useState(t6);
const maxPaneWidthRef = React.useRef(maxPaneWidth);
let t7;
let t8;
if ($[17] !== currentWidth) {
t7 = () => {
currentWidthRef.current = currentWidth;
};
t8 = [currentWidth];
$[17] = currentWidth;
$[18] = t7;
$[19] = t8;
} else {
t7 = $[18];
t8 = $[19];
}
useIsomorphicLayoutEffect(t7, t8);
let t9;
if ($[20] !== width) {
t9 = () => getDefaultPaneWidth(width);
$[20] = width;
$[21] = t9;
} else {
t9 = $[21];
}
const getDefaultWidth = t9;
let t10;
if ($[22] !== resizable) {
t10 = value => {
const rounded = Math.round(value);
currentWidthRef.current = rounded;
startTransition(() => {
setCurrentWidthState(rounded);
});
if (onResizeEndRef.current) {
try {
onResizeEndRef.current(rounded);
} catch {}
return;
}
if (resizable && widthStorageKeyRef.current) {
localStoragePersister.save(widthStorageKeyRef.current, rounded);
}
};
$[22] = resizable;
$[23] = t10;
} else {
t10 = $[23];
}
const saveWidth = t10;
const getMaxPaneWidthRef = React.useRef(getMaxPaneWidth);
let t11;
if ($[24] !== getMaxPaneWidth) {
t11 = () => {
getMaxPaneWidthRef.current = getMaxPaneWidth;
};
$[24] = getMaxPaneWidth;
$[25] = t11;
} else {
t11 = $[25];
}
useIsomorphicLayoutEffect(t11);
let t12;
let t13;
if ($[26] !== constrainToViewport || $[27] !== contentWrapperRef || $[28] !== customMaxWidth || $[29] !== handleRef || $[30] !== minPaneWidth || $[31] !== paneRef || $[32] !== resizable) {
t12 = () => {
var _paneRef$current3;
if (!resizable) {
return;
}
let lastViewportWidth = window.innerWidth;
const syncAll = () => {
var _paneRef$current;
const currentViewportWidth = window.innerWidth;
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 = getMaxWidthDiffFromViewport();
}
const actualMax = getMaxPaneWidthRef.current();
(_paneRef$current = paneRef.current) === null || _paneRef$current === void 0 ? void 0 : _paneRef$current.style.setProperty("--pane-max-width", `${actualMax}px`);
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`);
}
updateAriaValues(handleRef.current, {
max: actualMax,
current: currentWidthRef.current
});
const maxChanged = actualMax !== maxPaneWidthRef.current;
if (maxChanged || wasClamped) {
maxPaneWidthRef.current = actualMax;
startTransition(() => {
setMaxPaneWidth(actualMax);
if (wasClamped) {
setCurrentWidthState(actualMax);
}
});
}
};
maxWidthDiffRef.current = getMaxWidthDiffFromViewport();
const initialMax = getMaxPaneWidthRef.current();
maxPaneWidthRef.current = initialMax;
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
});
if (customMaxWidth !== null && !constrainToViewport) {
return;
}
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 = () => {
startResizeOptimizations();
const now = Date.now();
if (now - lastUpdateTime >= 16) {
lastUpdateTime = now;
syncAll();
} else {
if (!pendingUpdate) {
pendingUpdate = true;
rafId = requestAnimationFrame(() => {
pendingUpdate = false;
rafId = null;
lastUpdateTime = Date.now();
syncAll();
});
}
}
if (debounceId !== null) {
clearTimeout(debounceId);
}
debounceId = setTimeout(() => {
debounceId = null;
endResizeOptimizations();
}, 150);
};
window.addEventListener("resize", handleResize);
return () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
if (debounceId !== null) {
clearTimeout(debounceId);
}
endResizeOptimizations();
window.removeEventListener("resize", handleResize);
};
};
t13 = [resizable, customMaxWidth, constrainToViewport, minPaneWidth, paneRef, handleRef, contentWrapperRef];
$[26] = constrainToViewport;
$[27] = contentWrapperRef;
$[28] = customMaxWidth;
$[29] = handleRef;
$[30] = minPaneWidth;
$[31] = paneRef;
$[32] = resizable;
$[33] = t12;
$[34] = t13;
} else {
t12 = $[33];
t13 = $[34];
}
useIsomorphicLayoutEffect(t12, t13);
let t14;
if ($[35] !== currentWidth || $[36] !== getDefaultWidth || $[37] !== getMaxPaneWidth || $[38] !== maxPaneWidth || $[39] !== minPaneWidth || $[40] !== saveWidth) {
t14 = {
currentWidth,
currentWidthRef,
minPaneWidth,
maxPaneWidth,
getMaxPaneWidth,
saveWidth,
getDefaultWidth
};
$[35] = currentWidth;
$[36] = getDefaultWidth;
$[37] = getMaxPaneWidth;
$[38] = maxPaneWidth;
$[39] = minPaneWidth;
$[40] = saveWidth;
$[41] = t14;
} else {
t14 = $[41];
}
return t14;
}
export { ARROW_KEY_STEP, DEFAULT_MAX_WIDTH_DIFF, DEFAULT_SIDEBAR_MAX_WIDTH_DIFF, SSR_DEFAULT_MAX_WIDTH, defaultPaneWidth, getDefaultPaneWidth, getMaxWidthDiffFromViewport, isCustomWidthOptions, isPaneWidth, updateAriaValues, usePaneWidth };