UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

463 lines (446 loc) • 16.1 kB
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 };