import { useLayoutEffect, useState } from 'react' /** * Options for useDynamicTabWidths hook */ export interface UseDynamicTabWidthsOptions { /** Ref to the container element holding the tab elements */ containerRef: React.RefObject /** CSS class name used to identify tab elements */ tabClassName: string /** Whether the measurement system is enabled. Defaults to true. */ enabled?: boolean } /** * Return value from useDynamicTabWidths hook */ export interface UseDynamicTabWidthsReturn { /** * A counter that increments whenever tab widths are recalculated. * Use this in dependency arrays to trigger effects when measurements change. */ measurementKey: number } /** * Safely parse CSS padding values, returning 0 for invalid values. * This prevents NaN errors from malformed CSS. */ function parsePaddingValue(value: string): number { const parsedValue = Number.parseFloat(value) return Number.isNaN(parsedValue) ? 0 : parsedValue } /** * Calculates tab width with bold font compensation. * * The 0.28px constant compensates for font-weight difference: * - Empirically measured: average bold glyph is ~0.28px wider per character * - Prevents visual jump when tab becomes active (normal → bold font) * - Applied as: originalWidth + (characterCount × 0.28px) * * @param element - The tab element to measure * @param originalWidth - The current rendered width of the element * @returns The calculated width including bold font compensation */ function calculateBoldCompensatedWidth( element: HTMLElement, originalWidth: number, ): number { const text = element.innerText ?? element.textContent ?? '' const BOLD_COMPENSATION_PER_CHAR = 0.28 let width = originalWidth + text.length * BOLD_COMPENSATION_PER_CHAR // Adjust width if using content-box (subtract padding from measurement) const computedStyles = window.getComputedStyle(element) if (computedStyles.getPropertyValue('box-sizing') === 'content-box') { width -= parsePaddingValue(computedStyles.getPropertyValue('padding-left')) + parsePaddingValue(computedStyles.getPropertyValue('padding-right')) } return width } /** * Custom hook for dynamic tab width measurement. * * Manages tab widths to prevent layout shifts when content changes (e.g., tab counts updating). * Implements a dual-observer system to detect and respond to content changes. * * @example * ```tsx * const { measurementKey } = useDynamicTabWidths({ * containerRef: tabsContainerRef, * tabClassName: 'tab-link', * enabled: true, * }) * * useLayoutEffect(() => { * // Realign active bar when measurements change * alignActiveBar() * }, [measurementKey]) * ``` * * ## Problem Solved * Tab labels can change dynamically (e.g., "Images (5)" → "Images (125)"), * causing the active highlight bar to become misaligned because tab widths change. * * ## Solution * This hook implements a comprehensive measurement system that: * 1. Measures each tab's width accounting for bold font weight when active * 2. Watches for any changes using ResizeObserver and MutationObserver * 3. Triggers realignment by incrementing measurementKey when widths change * * ## Performance * Uses requestAnimationFrame to batch measurements and avoid layout thrashing. * Includes guard against infinite loops when setting width to 'auto' for measurement. */ export function useDynamicTabWidths({ containerRef, tabClassName, enabled = true, }: UseDynamicTabWidthsOptions): UseDynamicTabWidthsReturn { const [measurementKey, setMeasurementKey] = useState(0) useLayoutEffect(() => { // Skip if disabled or no container if (!enabled) { return undefined } const container = containerRef.current // Early exit if container doesn't exist or we're in SSR if (!container || typeof window === 'undefined') { return undefined } // Track pending animation frame to allow cancellation let scheduledFrameId: number | null = null // Flag to prevent infinite measurement loops when ResizeObserver fires during measurement let isMeasurementInProgress = false /** * Measures and applies fixed widths to all tab elements. * * Algorithm: * 1. Check if already measuring to prevent infinite loops * 2. Reset to 'auto' width to get accurate natural measurement * 3. Get current rendered width via getBoundingClientRect * 4. Add buffer for bold font (0.28px per character) * 5. Adjust for box-sizing: content-box if needed (subtract padding) * 6. Apply width only if changed (prevents unnecessary style updates) * 7. Increment measurement key to trigger active bar realignment */ const applyWidths = () => { // Prevent infinite loop: ResizeObserver can trigger when we set width to 'auto' if (isMeasurementInProgress) { return } isMeasurementInProgress = true const tabElements = Array.from( container.getElementsByClassName(tabClassName), ) let anyWidthsChanged = false tabElements.forEach((element) => { // Skip non-HTML elements (shouldn't happen, but type-safe) if (!(element instanceof HTMLElement)) { return } // Reset to 'auto' to measure natural width accurately element.style.width = 'auto' element.style.maxWidth = 'none' // Get the current rendered width of the tab const { width: originalWidth } = element.getBoundingClientRect() // Calculate width with bold font compensation const width = calculateBoldCompensatedWidth(element, originalWidth) // Only update styles if they've changed (prevents unnecessary reflows) const widthString = `${width}px` if ( element.style.width !== widthString || element.style.maxWidth !== widthString ) { element.style.width = widthString element.style.maxWidth = widthString anyWidthsChanged = true } }) // Trigger active bar realignment if any widths changed if (anyWidthsChanged) { setMeasurementKey((value) => value + 1) } isMeasurementInProgress = false } /** * Schedules a width measurement on the next animation frame. * Uses requestAnimationFrame to batch multiple measurement requests * and prevent layout thrashing from rapid consecutive calls. */ const scheduleWidthsMeasurement = () => { // Cancel any pending measurement to avoid duplicate work if (scheduledFrameId) { cancelAnimationFrame(scheduledFrameId) } // Schedule measurement on next animation frame scheduledFrameId = requestAnimationFrame(() => { scheduledFrameId = null applyWidths() }) } // Perform initial measurement scheduleWidthsMeasurement() /** * DUAL OBSERVER SYSTEM * * ResizeObserver (Primary): * - Detects tab content changes (e.g., count updates "5" → "125") * - Optimized for layout changes by the browser * - May miss certain DOM mutations that don't immediately trigger resize * * MutationObserver (Safety Net): * - Catches tabs added/removed (childList) * - Detects text content changes (characterData) * - Handles structural changes (subtree) * - Catches edge cases where ResizeObserver fires inconsistently * * Why not just MutationObserver? * - Less efficient (requires manual measurement on every mutation) * - ResizeObserver is optimized for layout changes * * Why not just ResizeObserver? * - Certain mutations don't trigger resize immediately * - Text changes may not fire ResizeObserver consistently * - Need explicit detection for structural changes * * Together: Comprehensive coverage with minimal redundancy * RAF batching prevents duplicate measurements even with both observers */ /** * ResizeObserver: Watches for size changes on individual tab elements. * Triggers when tab content changes (e.g., count updates in tab labels). * Gracefully degrades if ResizeObserver is not supported (older browsers). */ const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => { scheduleWidthsMeasurement() }) : null /** * Attaches ResizeObserver to all current tab link elements. * Called initially and whenever DOM structure changes. */ const observeCurrentLinks = () => { if (!resizeObserver) { return } Array.from(container.getElementsByClassName(tabClassName)).forEach( (link) => resizeObserver.observe(link), ) } // Start observing initial set of tabs observeCurrentLinks() /** * MutationObserver: Watches for DOM changes within the tabs container. * Detects when: * - Tabs are added/removed (childList) * - Tab structure changes (subtree) * - Tab text content changes (characterData) * * When changes occur, re-attach observers to new elements and remeasure. */ const mutationObserver = typeof MutationObserver !== 'undefined' ? new MutationObserver(() => { observeCurrentLinks() // Re-observe any new tab elements scheduleWidthsMeasurement() // Recalculate widths }) : null // Start watching for DOM mutations mutationObserver?.observe(container, { childList: true, // Watch for added/removed nodes subtree: true, // Watch entire subtree, not just direct children characterData: true, // Watch for text content changes }) // Also watch for window resize (viewport size changes) window.addEventListener('resize', scheduleWidthsMeasurement) /** * Cleanup function: Runs when component unmounts or dependencies change. * Prevents memory leaks by disconnecting all observers and event listeners. */ return () => { if (scheduledFrameId) { cancelAnimationFrame(scheduledFrameId) } resizeObserver?.disconnect() mutationObserver?.disconnect() window.removeEventListener('resize', scheduleWidthsMeasurement) } }, [containerRef, tabClassName, enabled]) return { measurementKey } }