UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

284 lines (251 loc) 11.4 kB
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 ['small', 'medium', 'large'].includes(width); }; 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 }) { // 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; 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: // 1. Throttled (16ms): Update --pane-max-width CSS variable for immediate visual clamp // 2. Debounced (150ms): Sync refs, ARIA, and React state when resize stops useIsomorphicLayoutEffect(() => { var _paneRef$current4; if (!resizable) return; let lastViewportWidth = window.innerWidth; // Quick CSS-only update for immediate visual feedback (throttled) const updateCSSOnly = () => { var _paneRef$current; const actualMax = getMaxPaneWidthRef.current(); (_paneRef$current = paneRef.current) === null || _paneRef$current === void 0 ? void 0 : _paneRef$current.style.setProperty('--pane-max-width', `${actualMax}px`); }; // Full sync of refs, ARIA, and state (debounced, runs when resize stops) const syncAll = () => { var _paneRef$current2; 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_0 = getMaxPaneWidthRef.current(); // Update CSS variable for visual clamping (may already be set by throttled update) (_paneRef$current2 = paneRef.current) === null || _paneRef$current2 === void 0 ? void 0 : _paneRef$current2.style.setProperty('--pane-max-width', `${actualMax_0}px`); // Track if we clamped current width const wasClamped = currentWidthRef.current > actualMax_0; if (wasClamped) { var _paneRef$current3; currentWidthRef.current = actualMax_0; (_paneRef$current3 = paneRef.current) === null || _paneRef$current3 === void 0 ? void 0 : _paneRef$current3.style.setProperty('--pane-width', `${actualMax_0}px`); } // Update ARIA via DOM - cheap, no React re-render updateAriaValues(handleRef.current, { max: actualMax_0, current: currentWidthRef.current }); // Defer state updates so parent re-renders see accurate values startTransition(() => { setMaxPaneWidth(actualMax_0); if (wasClamped) { setCurrentWidth(actualMax_0); } }); }; // Initial calculation on mount maxWidthDiffRef.current = getPaneMaxWidthDiff(paneRef.current); const initialMax = getMaxPaneWidthRef.current(); setMaxPaneWidth(initialMax); (_paneRef$current4 = paneRef.current) === null || _paneRef$current4 === void 0 ? void 0 : _paneRef$current4.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 CSS updates (16ms ≈ 60fps), debounce full sync (150ms) const THROTTLE_MS = 16; const DEBOUNCE_MS = 150; let rafId = null; let debounceId = null; let lastThrottleTime = 0; const handleResize = () => { const now = Date.now(); // Throttled CSS update for immediate visual feedback if (now - lastThrottleTime >= THROTTLE_MS) { lastThrottleTime = now; updateCSSOnly(); } else if (rafId === null) { // Schedule next frame if we're within throttle window rafId = requestAnimationFrame(() => { rafId = null; lastThrottleTime = Date.now(); updateCSSOnly(); }); } // Debounced full sync (refs, ARIA, state) when resize stops if (debounceId !== null) { clearTimeout(debounceId); } debounceId = setTimeout(() => { debounceId = null; syncAll(); }, 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); window.removeEventListener('resize', handleResize); }; }, [resizable, customMaxWidth, minPaneWidth, paneRef, handleRef]); 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 };