@dialpad/dialtone
Version:
Dialpad's Dialtone design system monorepo
1 lines • 110 kB
Source Map (JSON)
{"version":3,"file":"resizable-aOVGO_Os.cjs","names":[],"sources":["../components/resizable/composables/useResizablePanelState.ts","../components/resizable/composables/useResizablePanelControls.ts","../components/resizable/composables/computeLayout.ts","../components/resizable/composables/useResizableStorage.ts","../components/resizable/composables/useResizableGroup.ts","../components/resizable/composables/useResizableDrag.ts","../components/resizable/composables/useResizableAnnouncements.ts","../components/resizable/composables/useResizableOffset.ts","../components/resizable/resizable.vue"],"sourcesContent":["/**\n * Panel State Model\n *\n * Pure functions for panel state creation, constraint calculation, and validation.\n * Single source of truth for all constraint-related logic.\n *\n * Constraint math has been extracted to constraintResolver.ts.\n */\n\nimport { DEFAULT_PANEL_SIZE } from '../resizable_constants';\nimport type { ResizablePanelConfig, ResizablePanelState } from '../resizable_constants';\nimport { parseSizeToPixels } from '../resizable_utils';\nimport { clampToTier } from './constraintResolver';\n\nimport { calculateConstraintHierarchy } from './constraintResolver';\n\n// Re-export constraint types and functions from constraintResolver\nexport type { ConstraintHierarchy } from './constraintResolver';\nexport { calculateConstraintHierarchy } from './constraintResolver';\n\n/**\n * Apply constraints to a pixel size for a panel.\n *\n * Updates panel's constraint pixel values and returns the constrained size.\n *\n * @param panel - Panel state to update constraints on\n * @param pixelSize - Desired pixel size before constraints\n * @param containerSize - Current container size in pixels\n * @param constraintType - Which constraints to apply: 'user' (default) or 'system'\n * @returns Constrained pixel size respecting bounds\n */\nexport function applyPanelPixelConstraints(\n panel: ResizablePanelState,\n pixelSize: number,\n containerSize: number,\n constraintType: 'user' | 'system' = 'user'\n): number {\n const constraints = calculateConstraintHierarchy(panel, containerSize);\n\n panel.userMinSizePixels = constraints.userMinSizePixels;\n panel.userMaxSizePixels = constraints.userMaxSizePixels;\n panel.systemMinSizePixels = constraints.systemMinSizePixels;\n panel.systemMaxSizePixels = constraints.systemMaxSizePixels;\n panel.collapseSizePixels = constraints.collapseSizePixels;\n\n return clampToTier(pixelSize, constraints, constraintType);\n}\n\n// ============================================================================\n// PANEL STATE CREATION\n// ============================================================================\n\nfunction applyConstraintsToSize(pixelSize: number, minSizePixels?: number, maxSizePixels?: number): number {\n let constrained = pixelSize;\n if (minSizePixels !== undefined) {\n constrained = Math.max(constrained, minSizePixels);\n }\n if (maxSizePixels !== undefined) {\n constrained = Math.min(constrained, maxSizePixels);\n }\n return constrained;\n}\n\nfunction derivePanelBehavioralState(\n panelConfig: ResizablePanelConfig,\n existingPanel?: ResizablePanelState\n): { collapsed: boolean; manualTargetSize: number | undefined } {\n return {\n collapsed: existingPanel?.collapsed ?? Boolean(panelConfig.collapsed),\n manualTargetSize: existingPanel?.manualTargetSize,\n };\n}\n\n/**\n * Create initial panel state from configuration.\n */\nexport function createPanelState(\n panelConfig: ResizablePanelConfig,\n containerSize: number,\n existingPanel?: ResizablePanelState\n): ResizablePanelState {\n const constraints = calculateConstraintHierarchy(panelConfig, containerSize);\n\n const rawPixelSize = parseSizeToPixels(panelConfig.initialSize ?? DEFAULT_PANEL_SIZE, containerSize);\n const pixelSize = applyConstraintsToSize(rawPixelSize, constraints.userMinSizePixels, constraints.userMaxSizePixels);\n\n const behavioralState = derivePanelBehavioralState(panelConfig, existingPanel);\n const autoCollapsed = existingPanel?.autoCollapsed;\n\n return {\n ...panelConfig,\n pixelSize,\n ...behavioralState,\n userMinSizePixels: constraints.userMinSizePixels,\n userMaxSizePixels: constraints.userMaxSizePixels,\n systemMinSizePixels: constraints.systemMinSizePixels,\n systemMaxSizePixels: constraints.systemMaxSizePixels,\n collapseSizePixels: constraints.collapseSizePixels,\n autoCollapsed,\n };\n}\n\n/**\n * Create panel states for all panels in a configuration array.\n */\nexport function createBasicPanelStates(\n allPanels: ResizablePanelConfig[],\n containerSize: number,\n existingPanels?: ResizablePanelState[]\n): ResizablePanelState[] {\n return allPanels.map(panelConfig => {\n const existingPanel = existingPanels?.find(p => p.id === panelConfig.id);\n return createPanelState(panelConfig, containerSize, existingPanel);\n });\n}\n\n// ============================================================================\n// PANEL STATE VALIDATION\n// ============================================================================\n\n/**\n * Check if a panel pair should be skipped during constraint processing.\n */\nexport function shouldSkipPanelPair(beforePanel: ResizablePanelState, afterPanel: ResizablePanelState): boolean {\n return (\n beforePanel.resizable === false ||\n !!beforePanel.collapsed ||\n afterPanel.resizable === false ||\n !!afterPanel.collapsed\n );\n}\n\n/**\n * Check if a panel pair can be reset to initial sizes.\n */\nexport function canResetPanelPair(beforePanel: ResizablePanelState, afterPanel: ResizablePanelState): boolean {\n return (\n beforePanel.resizable !== false && !beforePanel.collapsed && afterPanel.resizable !== false && !afterPanel.collapsed\n );\n}\n","/**\n * Panel Controls Controller\n *\n * Operations for manipulating panel state: resize, collapse, reset.\n * Receives resizeHandler from Integration layer (single instance pattern).\n */\n\nimport type { Ref } from 'vue';\nimport { DEFAULT_PANEL_SIZE } from '../resizable_constants';\nimport type {\n ResizablePanelState,\n ResizableSizeValue,\n CollapseRule,\n} from '../resizable_constants';\nimport { parseSizeToPixels } from '../resizable_utils';\nimport { applyPanelPixelConstraints, canResetPanelPair } from './useResizablePanelState';\n\n// ============================================================================\n// TYPES\n// ============================================================================\n\n/**\n * State snapshot captured before a panel is collapsed.\n */\nexport interface PreCollapseState {\n sizes: Map<string, number>;\n manualTargets: Map<string, number | undefined>;\n containerSize: number;\n}\n\nexport type CollapseRequestSource =\n | 'user'\n | 'system'\n | 'prop'\n | 'storage';\n\nexport interface CollapseRequest {\n panelId: string;\n action: 'collapse' | 'expand';\n source: CollapseRequestSource;\n}\n\nexport interface CollapseRequestResult {\n success: boolean;\n reason?: string;\n panelId: string;\n newState: 'collapsed' | 'expanded' | 'unchanged';\n}\n\nexport interface CollapseOptions {\n isAutoCollapse?: boolean;\n}\n\nexport interface ResizablePanelControlsOptions {\n panels: Ref<ResizablePanelState[]>;\n containerSize: Ref<number>;\n containerRef: Ref<HTMLElement | null>;\n onPanelResize: (panelId: string, pixelSize: number) => void;\n onPanelCollapse: (panelId: string, collapsed: boolean) => void;\n updateSavedPanel: (panelId: string, updates: Partial<import('./useResizableStorage').SavedPanelData>) => void;\n}\n\n// ============================================================================\n// MAIN COMPOSABLE\n// ============================================================================\n\nexport function useResizablePanelControls(options: ResizablePanelControlsOptions) {\n const {\n panels,\n containerSize,\n containerRef,\n onPanelResize,\n onPanelCollapse,\n updateSavedPanel,\n } = options;\n\n const preCollapseStates = new Map<string, PreCollapseState>();\n\n // ---- Size Conversion ----\n\n function convertToPixelSize(initialSize: ResizableSizeValue): number {\n return parseSizeToPixels(initialSize, containerSize.value);\n }\n\n // ============================================================================\n // CENTRALIZED COLLAPSE LOGIC\n // ============================================================================\n\n function processCollapseRequest(request: CollapseRequest): CollapseRequestResult {\n const { panelId, action, source } = request;\n const panel = panels.value.find(p => p.id === panelId);\n const isCollapse = action === 'collapse';\n\n const validationResult = validateCollapseRequest(panelId, panel, isCollapse);\n if (validationResult) return validationResult;\n\n if (isCollapse) {\n executeCollapse(panel!, source);\n } else {\n executeExpand(panel!);\n }\n\n onPanelCollapse(panelId, isCollapse);\n if (containerRef.value) {\n containerRef.value.dispatchEvent(new CustomEvent('panels-updated'));\n }\n\n const newState = isCollapse ? 'collapsed' : 'expanded';\n\n return { success: true, panelId, newState };\n }\n\n function validateCollapseRequest(\n panelId: string,\n panel: ResizablePanelState | undefined,\n isCollapse: boolean\n ): CollapseRequestResult | undefined {\n if (!panel) {\n return { success: false, reason: 'Panel not found', panelId, newState: 'unchanged' };\n }\n\n const currentlyCollapsed = panel.collapsed;\n if (isCollapse && currentlyCollapsed) {\n return { success: true, reason: 'Already collapsed', panelId, newState: 'unchanged' };\n }\n if (!isCollapse && !currentlyCollapsed) {\n return { success: true, reason: 'Already expanded', panelId, newState: 'unchanged' };\n }\n if (isCollapse && !panel.collapsible) {\n return { success: false, reason: 'Panel is not collapsible', panelId, newState: 'unchanged' };\n }\n\n return undefined;\n }\n\n function executeCollapse(panel: ResizablePanelState, source: CollapseRequestSource): void {\n const state: PreCollapseState = {\n sizes: new Map(),\n manualTargets: new Map(),\n containerSize: containerSize.value,\n };\n panels.value.forEach(p => {\n state.sizes.set(p.id, p.pixelSize);\n state.manualTargets.set(p.id, p.manualTargetSize);\n });\n preCollapseStates.set(panel.id, state);\n\n updateSavedPanel(panel.id, { collapsed: true, autoCollapsed: source === 'system' });\n\n // Clear manual ratios so remaining panels fill the freed space\n panels.value.forEach(p => {\n if (p.id !== panel.id && !p.collapsed) {\n updateSavedPanel(p.id, { manualTargetRatio: undefined });\n }\n });\n }\n\n function executeExpand(panel: ResizablePanelState): void {\n const wasAutoCollapsed = panel.autoCollapsed ?? false;\n\n const preCollapse = preCollapseStates.get(panel.id);\n\n if (preCollapse) {\n const viewportChangeRatio = Math.abs(containerSize.value - preCollapse.containerSize)\n / preCollapse.containerSize;\n const viewportChanged = viewportChangeRatio > 0.1;\n const shouldRestoreSizes = !viewportChanged || !wasAutoCollapsed;\n\n if (shouldRestoreSizes) {\n const savedPanelSize = preCollapse.sizes.get(panel.id);\n if (savedPanelSize !== undefined) {\n updateSavedPanel(panel.id, { pixelSize: savedPanelSize, collapsed: false, autoCollapsed: undefined });\n } else {\n updateSavedPanel(panel.id, { collapsed: false, autoCollapsed: undefined });\n }\n\n panels.value.forEach(p => {\n if (p.id === panel.id) return;\n const savedSize = preCollapse.sizes.get(p.id);\n const hadManualTargetBefore = preCollapse.manualTargets.get(p.id) !== undefined;\n const hasManualTargetNow = p.manualTargetSize !== undefined;\n\n if (!hadManualTargetBefore && !hasManualTargetNow && savedSize !== undefined) {\n updateSavedPanel(p.id, { pixelSize: savedSize });\n }\n });\n } else {\n updateSavedPanel(panel.id, { collapsed: false, autoCollapsed: undefined });\n }\n\n preCollapseStates.delete(panel.id);\n } else {\n const initialSize = parseSizeToPixels(panel.initialSize ?? DEFAULT_PANEL_SIZE, containerSize.value);\n const constrainedSize = applyPanelPixelConstraints(panel, initialSize, containerSize.value, 'system');\n updateSavedPanel(panel.id, { pixelSize: constrainedSize, collapsed: false, autoCollapsed: undefined });\n }\n }\n\n // ---- Panel Operations ----\n\n function commitPanelSize(panelId: string, pixels: number): void {\n const rounded = Math.round(pixels);\n const cSize = containerSize.value;\n const ratio = cSize > 0 ? rounded / cSize : undefined;\n updateSavedPanel(panelId, { pixelSize: rounded, manualTargetRatio: ratio });\n }\n\n function resizePanel(panelId: string, newPixelSize: number) {\n const panel = panels.value.find(p => p.id === panelId);\n if (!panel || panel.collapsed) return;\n\n const constrainedSize = applyPanelPixelConstraints(panel, newPixelSize, containerSize.value);\n commitPanelSize(panelId, constrainedSize);\n\n onPanelResize(panelId, constrainedSize);\n }\n\n /**\n */\n function collapsePanel(panelId: string, collapsed: boolean, options?: CollapseOptions) {\n const source: CollapseRequestSource = options?.isAutoCollapse ? 'system' : 'prop';\n\n processCollapseRequest({\n panelId,\n action: collapsed ? 'collapse' : 'expand',\n source,\n });\n }\n\n // ---- Panel Reset Operations ----\n\n function resetAdjacentPanels(beforePanel: ResizablePanelState, afterPanel: ResizablePanelState) {\n const combinedSpace = beforePanel.pixelSize + afterPanel.pixelSize;\n const beforeInitial = convertToPixelSize(beforePanel.initialSize || DEFAULT_PANEL_SIZE);\n const afterInitial = convertToPixelSize(afterPanel.initialSize || DEFAULT_PANEL_SIZE);\n const totalInitial = beforeInitial + afterInitial;\n\n const beforeSize = totalInitial > 0\n ? Math.round(combinedSpace * (beforeInitial / totalInitial))\n : Math.round(combinedSpace / 2);\n const afterSize = combinedSpace - beforeSize;\n\n updateSavedPanel(beforePanel.id, {\n pixelSize: beforeSize, manualTargetRatio: undefined,\n });\n updateSavedPanel(afterPanel.id, {\n pixelSize: afterSize, manualTargetRatio: undefined,\n });\n\n onPanelResize(beforePanel.id, beforeSize);\n onPanelResize(afterPanel.id, afterSize);\n }\n\n function resetAllPanelPairs() {\n for (let i = 0; i < panels.value.length - 1; i++) {\n const currentPanel = panels.value[i];\n const nextPanel = panels.value[i + 1];\n\n if (canResetPanelPair(currentPanel, nextPanel)) {\n resetAdjacentPanels(currentPanel, nextPanel);\n }\n }\n }\n\n function resetSpecificPanelPair(beforePanelId?: string, afterPanelId?: string) {\n const beforePanel = beforePanelId ? panels.value.find(p => p.id === beforePanelId) : undefined;\n const afterPanel = afterPanelId ? panels.value.find(p => p.id === afterPanelId) : undefined;\n\n if (beforePanel && afterPanel && canResetPanelPair(beforePanel, afterPanel)) {\n resetAdjacentPanels(beforePanel, afterPanel);\n }\n }\n\n function resetSinglePanel(panelId?: string) {\n if (!panelId) return;\n const panel = panels.value.find(p => p.id === panelId);\n if (!panel || panel.collapsed || panel.resizable === false) return;\n\n const initialSize = convertToPixelSize(panel.initialSize || DEFAULT_PANEL_SIZE);\n const delta = initialSize - panel.pixelSize;\n\n const panelIndex = panels.value.indexOf(panel);\n const adjacentPanel = panels.value.find((p, i) =>\n i !== panelIndex && !p.collapsed && p.resizable !== false\n );\n\n if (!adjacentPanel) return;\n\n const newAdjacentSize = adjacentPanel.pixelSize - delta;\n\n updateSavedPanel(panel.id, {\n pixelSize: initialSize, manualTargetRatio: undefined,\n });\n updateSavedPanel(adjacentPanel.id, { pixelSize: newAdjacentSize });\n\n onPanelResize(panel.id, initialSize);\n onPanelResize(adjacentPanel.id, newAdjacentSize);\n }\n\n function resetPanels(\n beforePanelId?: string,\n afterPanelId?: string,\n behavior: 'both' | 'before' | 'after' | 'all' = 'all'\n ) {\n try {\n if (behavior === 'all') {\n resetAllPanelPairs();\n } else if (behavior === 'before') {\n resetSinglePanel(beforePanelId);\n } else if (behavior === 'after') {\n resetSinglePanel(afterPanelId);\n } else {\n resetSpecificPanelPair(beforePanelId, afterPanelId);\n }\n } catch (error) {\n console.error('[resizable] Error in resetPanels:', error);\n }\n }\n\n // ---- Auto-Collapse/Expand ----\n\n function checkAutoCollapse(): string[] {\n const collapsedPanels: string[] = [];\n const currentContainerSize = containerSize.value;\n\n for (const panel of panels.value) {\n if (panel.collapsed || !panel.collapsible || !panel.collapseSizePixels) {\n continue;\n }\n\n if (currentContainerSize < panel.collapseSizePixels) {\n const result = processCollapseRequest({\n panelId: panel.id,\n action: 'collapse',\n source: 'system',\n });\n\n if (result.newState === 'collapsed') {\n collapsedPanels.push(panel.id);\n }\n }\n }\n\n return collapsedPanels;\n }\n\n function checkAutoExpand(): string[] {\n const expandedPanels: string[] = [];\n const currentContainerSize = containerSize.value;\n\n for (const panel of panels.value) {\n if (!panel.collapsed || !panel.autoCollapsed || !panel.collapseSizePixels) {\n continue;\n }\n\n if (currentContainerSize >= panel.collapseSizePixels) {\n const result = processCollapseRequest({\n panelId: panel.id,\n action: 'expand',\n source: 'system',\n });\n\n if (result.newState === 'expanded') {\n expandedPanels.push(panel.id);\n }\n }\n }\n\n return expandedPanels;\n }\n\n function processAutoCollapseExpand(): { collapsed: string[]; expanded: string[] } {\n const expanded = checkAutoExpand();\n const collapsed = checkAutoCollapse();\n\n return { collapsed, expanded };\n }\n\n return {\n commitPanelSize,\n resizePanel,\n collapsePanel,\n resetPanels,\n processCollapseRequest,\n checkAutoCollapse,\n checkAutoExpand,\n processAutoCollapseExpand,\n };\n}\n\n// ============================================================================\n// COLLAPSE RULE UTILITIES\n// ============================================================================\n\n/**\n * Sorts collapse rules by priority (lower numbers collapse first).\n * Creates a new sorted array - does not mutate the original.\n * Maintains stable sort for rules with equal priority.\n */\nexport function sortCollapseRules(rules: CollapseRule[]): CollapseRule[] {\n if (!rules || rules.length === 0) {\n return [];\n }\n\n const indexed = rules.map((rule, index) => ({ rule, index }));\n\n indexed.sort((a, b) => {\n const priorityDiff = a.rule.priority - b.rule.priority;\n if (priorityDiff !== 0) {\n return priorityDiff;\n }\n return a.index - b.index;\n });\n\n return indexed.map(item => item.rule);\n}\n\n// ============================================================================\n// AUTO-COLLAPSE UTILITIES\n// ============================================================================\n\nfunction getCollapseThreshold(\n panel: ResizablePanelState,\n rule: CollapseRule,\n containerSize: number\n): number | undefined {\n if (rule.minSizeBeforeCollapse !== undefined) {\n return parseSizeToPixels(rule.minSizeBeforeCollapse, containerSize);\n }\n return panel.userMinSizePixels;\n}\n\nfunction shouldPanelCollapse(\n panels: ResizablePanelState[],\n rule: CollapseRule,\n containerSize: number\n): string | undefined {\n const panel = panels.find(p => p.id === rule.panelId);\n\n if (!panel || panel.collapsed) {\n return undefined;\n }\n\n const threshold = getCollapseThreshold(panel, rule, containerSize);\n\n if (threshold === undefined) {\n return undefined;\n }\n\n return panel.pixelSize <= threshold ? panel.id : undefined;\n}\n\n/**\n * Checks which panels should auto-collapse based on their current size and collapse rules.\n * Returns panel IDs in priority order (lowest priority first = first to collapse).\n */\nexport function checkAutoCollapseRules(\n panels: ResizablePanelState[],\n collapseRules: CollapseRule[],\n containerSize: number\n): string[] {\n if (!panels || panels.length === 0 || !collapseRules || collapseRules.length === 0) {\n return [];\n }\n\n const sortedRules = sortCollapseRules(collapseRules);\n\n return sortedRules\n .map(rule => shouldPanelCollapse(panels, rule, containerSize))\n .filter((id): id is string => id !== undefined);\n}\n","/**\n * computeLayout — Pure Layout Engine\n *\n * Takes panel configs + container size and returns positions for every panel\n * and handle. Constraints are applied WITHIN the computation, not after.\n *\n * This is a pure function: no DOM access, no Vue reactivity, no localStorage,\n * no side effects. It takes data in and returns data out.\n *\n * Key design: proportional scaling\n * - Panels with manualTargetRatio scale to their stored ratio each render\n * - Panels without a ratio distribute remaining space proportionally\n * - Fixed panels (resizable: false) keep their exact pixel size\n * - Min/max constraints clamp results; overflow is redistributed in the same pass\n *\n * @see constraintResolver.ts\n */\n\nimport { DEFAULT_PANEL_SIZE, buildHandleId } from '../resizable_constants';\nimport type { ResizablePanelConfig } from '../resizable_constants';\nimport { parseSizeToPixels } from '../resizable_utils';\nimport { calculateConstraintHierarchy, clampToTier, type ConstraintHierarchy } from './constraintResolver';\n\n// ============================================================================\n// SAVED STATE TYPE (inlined from useResizableStorage — ported in Task #2)\n// ============================================================================\n\n/**\n * Data shape for a single panel's saved state in localStorage.\n * Defined here so computeLayout stays self-contained without depending\n * on the storage composable. useResizableStorage will re-export this\n * type when it is ported.\n */\nexport interface SavedPanelData {\n id: string;\n pixelSize: number;\n collapsed?: boolean;\n /** Whether this panel was auto-collapsed by the system (vs manually by user) */\n autoCollapsed?: boolean;\n /** Proportion of container this panel should occupy (set by drag, used for viewport scaling) */\n manualTargetRatio?: number;\n}\n\n// ============================================================================\n// INPUT / OUTPUT TYPES\n// ============================================================================\n\n/**\n * Input to the layout computation.\n * All values must be resolved before calling (no lazy-loading, no Vue refs).\n */\nexport interface LayoutInput {\n /** Registered panel configurations, in render order */\n panels: ResizablePanelConfig[];\n /** Current container width (row direction) or height (column direction), in pixels */\n containerSize: number;\n /**\n * Saved state from localStorage (optional).\n * When provided, sizes and collapsed state are restored from here.\n * IDs that do not match current panels are ignored.\n * Values that fail validation fall back to initialSize.\n */\n savedState?: SavedPanelData[];\n}\n\n/**\n * Computed position for a single panel.\n * All values are in pixels, relative to the container's top-left corner.\n */\nexport interface PanelPosition {\n id: string;\n /** Distance from the container's left edge to this panel's left edge */\n left: number;\n /** Distance from this panel's right edge to the container's right edge */\n right: number;\n /** Rendered width of the panel (0 when collapsed) */\n width: number;\n /** Whether the panel is currently collapsed */\n collapsed: boolean;\n /** Resolved constraints for this panel at the current container size */\n constraints: ConstraintHierarchy;\n}\n\n/**\n * Computed position for a single drag handle.\n * Handles sit between adjacent visible panels.\n */\nexport interface HandlePosition {\n /** Handle identifier: \"{beforePanelId}:{afterPanelId}\" */\n id: string;\n beforePanelId: string;\n afterPanelId: string;\n /** Pixel offset from the container's left edge to the handle's left edge */\n left: number;\n /**\n * True when the handle should be non-interactive.\n * Set when either adjacent panel is collapsed or resizable: false.\n */\n disabled: boolean;\n}\n\n/**\n * Output of computeLayout.\n * Immutable — create a new layout by calling computeLayout again.\n */\nexport interface LayoutResult {\n panels: Map<string, PanelPosition>;\n handles: HandlePosition[];\n}\n\n// ============================================================================\n// INTERNAL WORKING STATE\n// ============================================================================\n\n/**\n * Internal representation while computing sizes before positions are finalized.\n */\ninterface WorkingPanel {\n config: ResizablePanelConfig;\n constraints: ConstraintHierarchy;\n width: number;\n collapsed: boolean;\n /** True when resizable: false — panel keeps its exact pixel size, no handle rendered */\n isFixed: boolean;\n /**\n * The manual ratio stored from a previous drag (0–1, fraction of container).\n * When present, this panel's target width = manualTargetRatio * containerSize,\n * still subject to constraints.\n */\n manualTargetRatio?: number;\n}\n\n// ============================================================================\n// HELPERS\n// ============================================================================\n\n/**\n * Resolve initial pixel size for a panel from saved state or initialSize config.\n * Returns the raw size before constraint clamping.\n */\nfunction resolveRawSize(\n config: ResizablePanelConfig,\n containerSize: number,\n savedPanel: SavedPanelData | undefined\n): number {\n if (savedPanel !== undefined) {\n const saved = savedPanel.pixelSize;\n // Reject corrupted saved values\n if (!isFinite(saved) || saved < 0 || (containerSize > 0 && saved > containerSize * 2)) {\n return parseSizeToPixels(config.initialSize ?? DEFAULT_PANEL_SIZE, containerSize);\n }\n return saved;\n }\n\n return parseSizeToPixels(config.initialSize ?? DEFAULT_PANEL_SIZE, containerSize);\n}\n\n/**\n * Determine whether a panel should be collapsed for a given input.\n * Priority: savedState.collapsed > config.collapsed\n */\nfunction resolveCollapsed(config: ResizablePanelConfig, savedPanel: SavedPanelData | undefined): boolean {\n if (savedPanel !== undefined && savedPanel.collapsed !== undefined) {\n return savedPanel.collapsed;\n }\n return Boolean(config.collapsed);\n}\n\n/**\n * Clamp a width to the constraint window.\n * Returns the clamped value and the amount of overflow/underflow.\n *\n * @param tier - Which constraints to apply:\n * 'user' — user-min/max (for ratio panels, user-dragged sizes)\n * 'system' — system-min/max falling back to user (for proportional panels,\n * viewport-driven redistribution)\n */\nfunction clampToConstraints(\n width: number,\n constraints: ConstraintHierarchy,\n tier: 'user' | 'system' = 'system'\n): { clamped: number; delta: number } {\n const clamped = clampToTier(width, constraints, tier);\n return { clamped, delta: clamped - width };\n}\n\n/**\n * Build the index of saved state for O(1) lookup during panel iteration.\n * Returns undefined if saved state is structurally incompatible with current panels.\n *\n * Incompatible = current panel IDs have entries NOT present in saved state.\n * (Panel IDs that exist in saved state but not current panels are simply ignored.)\n */\nfunction buildSavedIndex(\n savedState: SavedPanelData[] | undefined\n): Map<string, SavedPanelData> | undefined {\n if (!savedState) return undefined;\n return new Map<string, SavedPanelData>(savedState.map(s => [s.id, s]));\n}\n\n// ============================================================================\n// ALGORITHM\n// ============================================================================\n\n/**\n * Step 1: Build working panels with constraints and raw sizes.\n */\nfunction buildWorkingPanels(\n panels: ResizablePanelConfig[],\n containerSize: number,\n savedIndex: Map<string, SavedPanelData> | undefined\n): WorkingPanel[] {\n return panels.map(config => {\n const constraints = calculateConstraintHierarchy(config, containerSize);\n const savedPanel = savedIndex?.get(config.id);\n const collapsed = resolveCollapsed(config, savedPanel);\n const isFixed = config.resizable === false;\n\n const savedRatio = savedPanel?.manualTargetRatio;\n\n let rawWidth: number;\n if (collapsed) {\n rawWidth = 0;\n } else if (savedRatio !== undefined && containerSize > 0) {\n // Scale from ratio — the panel wants (ratio * container) pixels\n rawWidth = savedRatio * containerSize;\n } else {\n rawWidth = resolveRawSize(config, containerSize, savedPanel);\n }\n\n return {\n config,\n constraints,\n width: rawWidth,\n collapsed,\n isFixed,\n manualTargetRatio: savedRatio,\n };\n });\n}\n\n/**\n * Allocate space for Tier 1 (fixed) and Tier 2 (ratio) panels.\n * Mutates panel widths in place. Returns the total reserved space.\n */\nfunction allocateReservedPanels(visiblePanels: WorkingPanel[], containerSize: number): number {\n // Tier 1: Fixed panels (resizable: false)\n let reservedTotal = 0;\n for (const p of visiblePanels) {\n if (p.isFixed) {\n const { clamped } = clampToConstraints(p.width, p.constraints);\n p.width = clamped;\n reservedTotal += clamped;\n }\n }\n\n // Tier 2: Ratio panels (manualTargetRatio) — user constraints only.\n const ratioPanels = visiblePanels.filter(p => !p.isFixed && p.manualTargetRatio !== undefined);\n for (const p of ratioPanels) {\n const targetWidth = (p.manualTargetRatio ?? 0) * containerSize;\n const { clamped } = clampToConstraints(targetWidth, p.constraints, 'user');\n p.width = clamped;\n reservedTotal += clamped;\n }\n\n return reservedTotal;\n}\n\n/**\n * Iteratively clamp proportional panels to their constraint windows and\n * redistribute overflow/underflow to unclamped panels. Applies a final\n * clamp pass to catch floating-point drift.\n */\nfunction constrainAndRedistribute(propPanels: WorkingPanel[]): void {\n const maxPasses = propPanels.length + 1;\n\n for (let pass = 0; pass < maxPasses; pass++) {\n let totalDelta = 0;\n const clampedIds = new Set<string>();\n\n for (const p of propPanels) {\n const { clamped, delta } = clampToConstraints(p.width, p.constraints);\n if (delta !== 0) {\n p.width = clamped;\n clampedIds.add(p.config.id);\n totalDelta += delta;\n }\n }\n\n if (totalDelta === 0) break;\n\n const freePanels = propPanels.filter(p => !clampedIds.has(p.config.id));\n if (freePanels.length === 0) break;\n\n const changePerPanel = -totalDelta / freePanels.length;\n for (const p of freePanels) {\n p.width += changePerPanel;\n }\n }\n\n // Final clamp to catch any floating-point drift\n for (const p of propPanels) {\n const { clamped } = clampToConstraints(p.width, p.constraints);\n p.width = clamped;\n }\n}\n\n/**\n * If total allocated width < containerSize (all proportional panels hit max),\n * expand the last non-fixed visible panel to absorb the gap.\n */\nfunction applyFillGuarantee(visiblePanels: WorkingPanel[], propPanels: WorkingPanel[], containerSize: number): void {\n const allocatedTotal = visiblePanels.reduce((sum, p) => sum + p.width, 0);\n const gap = containerSize - allocatedTotal;\n if (gap > 1) {\n const lastFlexible = [...propPanels].reverse().find(p => !p.isFixed);\n if (lastFlexible) {\n lastFlexible.width += gap;\n }\n }\n}\n\n/**\n * Step 2: Distribute container space among non-collapsed panels.\n *\n * Distribution order (priority, highest first):\n * 1. Fixed panels (`resizable: false`) — keep exact initial pixel size\n * 2. Ratio panels (`manualTargetRatio`) — target (ratio × container), constrained\n * 3. Proportional panels — share remaining space using raw widths as weights\n *\n * After initial allocation, constraints are applied.\n * When a panel is min-clamped (forced larger), the extra space it consumed is\n * taken from unclamped proportional panels. When a panel is max-clamped (forced\n * smaller), the freed space is given back to unclamped proportional panels.\n * This redistribution iterates until stable (all panels within constraints) or\n * until no unclamped panels remain.\n */\nfunction distributeSpace(working: WorkingPanel[], containerSize: number): void {\n // Early exit for zero container\n if (containerSize <= 0) {\n working.forEach(p => {\n if (!p.collapsed) p.width = 0;\n });\n return;\n }\n\n const visiblePanels = working.filter(p => !p.collapsed);\n if (visiblePanels.length === 0) return;\n\n // ── Tier 1 + 2: Fixed and ratio panels ────────────────────────────────────\n const reservedTotal = allocateReservedPanels(visiblePanels, containerSize);\n\n // ── Tier 3: Proportional panels ───────────────────────────────────────────\n const propPanels = visiblePanels.filter(p => !p.isFixed && p.manualTargetRatio === undefined);\n const remainingSpace = Math.max(0, containerSize - reservedTotal);\n\n if (propPanels.length === 0) return;\n\n // Use current raw widths as proportional weights\n const totalPropWeight = propPanels.reduce((sum, p) => sum + Math.max(0, p.width), 0);\n\n // Initial allocation: proportional share of remaining space\n for (const p of propPanels) {\n if (totalPropWeight > 0) {\n p.width = (p.width / totalPropWeight) * remainingSpace;\n } else {\n // All weights are zero — distribute equally\n p.width = remainingSpace / propPanels.length;\n }\n }\n\n // ── Constraint pass with overflow redistribution ──────────────────────────\n constrainAndRedistribute(propPanels);\n\n // ── Fill guarantee ──────────────────────────────────────────────────────\n applyFillGuarantee(visiblePanels, propPanels, containerSize);\n}\n\n/**\n * Distribute +1px to panels with the largest fractional remainders,\n * skipping panels that are at their max constraint or fixed.\n */\nfunction distributeShortfall(remainders: { panel: WorkingPanel; remainder: number }[], shortfall: number): void {\n remainders.sort((a, b) => b.remainder - a.remainder);\n let remaining = shortfall;\n for (const { panel } of remainders) {\n if (remaining <= 0) break;\n const max = panel.constraints.systemMaxSizePixels ?? panel.constraints.userMaxSizePixels ?? Infinity;\n if (panel.width < max && !panel.isFixed) {\n panel.width += 1;\n remaining -= 1;\n }\n }\n}\n\n/**\n * Step 3: Round widths to integers and fix up rounding error.\n *\n * After distributing floating-point widths, some panels get ceil'd and some\n * floor'd. We use a \"largest remainder\" approach so the total always equals\n * the container size exactly (when not fully clamped by constraints).\n */\nfunction roundWidths(working: WorkingPanel[], containerSize: number): void {\n if (containerSize <= 0) return;\n\n const visiblePanels = working.filter(p => !p.collapsed);\n if (visiblePanels.length === 0) return;\n\n // Floor all widths first, collect remainders\n const remainders: { panel: WorkingPanel; remainder: number }[] = [];\n\n for (const p of visiblePanels) {\n const floored = Math.floor(p.width);\n remainders.push({ panel: p, remainder: p.width - floored });\n p.width = floored;\n }\n\n const currentTotal = visiblePanels.reduce((s, p) => s + p.width, 0);\n const shortfall = containerSize - currentTotal;\n\n distributeShortfall(remainders, shortfall);\n}\n\n/**\n * Step 4: Compute absolute left/right positions for each panel.\n */\nfunction computePositions(working: WorkingPanel[], containerSize: number): Map<string, PanelPosition> {\n const result = new Map<string, PanelPosition>();\n let cursor = 0;\n\n for (const p of working) {\n const width = p.collapsed ? 0 : p.width;\n const left = cursor;\n const right = Math.max(0, containerSize - left - width);\n\n result.set(p.config.id, {\n id: p.config.id,\n left,\n right,\n width,\n collapsed: p.collapsed,\n constraints: p.constraints,\n });\n\n cursor += width;\n }\n\n return result;\n}\n\n/**\n * Step 5: Compute handle positions.\n *\n * A handle is only generated for pairs of adjacent panels where BOTH panels\n * are resizable (resizable !== false). Pairs involving a fixed panel are\n * skipped entirely — no ResizableHandle component exists between them, and\n * including them in the array would shift the autoIndex values that handle\n * components use to look up their own position.\n *\n * Its left position equals the right edge of the before-panel.\n * It is disabled when either adjacent panel is collapsed.\n */\nfunction computeHandles(\n panels: ResizablePanelConfig[],\n positions: Map<string, PanelPosition>,\n): HandlePosition[] {\n const handles: HandlePosition[] = [];\n\n for (let i = 0; i < panels.length - 1; i++) {\n const before = panels[i];\n const after = panels[i + 1];\n\n // Skip pairs that involve a fixed (non-resizable) panel.\n // No ResizableHandle component is placed between fixed panels, so\n // generating an entry would misalign autoIndex → handles[] lookups.\n if (before.resizable === false || after.resizable === false) continue;\n\n const beforePos = positions.get(before.id);\n const afterPos = positions.get(after.id);\n\n if (!beforePos || !afterPos) continue;\n\n const disabled = beforePos.collapsed || afterPos.collapsed;\n\n handles.push({\n id: buildHandleId(before.id, after.id),\n beforePanelId: before.id,\n afterPanelId: after.id,\n left: beforePos.left + beforePos.width,\n disabled,\n });\n }\n\n return handles;\n}\n\n// ============================================================================\n// PUBLIC API\n// ============================================================================\n\n/**\n * Compute the complete layout for a group of resizable panels.\n *\n * This is a pure function — it has no side effects and does not read from\n * or write to the DOM, Vue reactivity, or localStorage.\n *\n * @param input - Panel configs, container size, and optional saved state\n * @returns Immutable layout result with panel positions and handle positions\n *\n * @example\n * const layout = computeLayout({\n * panels: registeredPanels,\n * containerSize: containerWidth,\n * savedState: loadFromStorage(),\n * });\n * const feedPos = layout.panels.get('feed-panel');\n * // feedPos.left, feedPos.width, feedPos.right, feedPos.collapsed\n */\nexport function computeLayout(input: LayoutInput): LayoutResult {\n const { panels, containerSize, savedState } = input;\n\n // Degenerate cases\n if (panels.length === 0) {\n return { panels: new Map(), handles: [] };\n }\n\n // Build saved-state lookup (returns undefined if incompatible with current panels)\n const savedIndex = buildSavedIndex(savedState);\n\n const working = buildWorkingPanels(panels, containerSize, savedIndex);\n distributeSpace(working, containerSize);\n roundWidths(working, containerSize);\n const positions = computePositions(working, containerSize);\n const handles = computeHandles(panels, positions);\n\n return { panels: positions, handles };\n}\n","import { DEFAULT_PANEL_SIZE } from '../resizable_constants';\nimport type { ResizablePanelConfig, ResizablePanelState } from '../resizable_constants';\nimport type { ResizableStorageAdapter, ResizableStoragePanelData } from '../resizable_constants';\nimport { parseSizeToPixels } from '../resizable_utils';\n\n// Re-export for backward compatibility\nexport type SavedPanelData = ResizableStoragePanelData;\n\n// ============================================================================\n// VALIDATION\n// ============================================================================\n\nfunction validateRequiredProperties(data: Record<string, unknown>): boolean {\n return typeof data.id === 'string' && typeof data.pixelSize === 'number' && data.pixelSize >= 0;\n}\n\nfunction validateOptionalProperties(data: Record<string, unknown>): boolean {\n return (\n (data.collapsed === undefined || typeof data.collapsed === 'boolean') &&\n (data.autoCollapsed === undefined || typeof data.autoCollapsed === 'boolean')\n );\n}\n\nfunction isSavedPanelData(obj: unknown): obj is ResizableStoragePanelData {\n if (typeof obj !== 'object' || obj === null) return false;\n const data = obj as Record<string, unknown>;\n return validateRequiredProperties(data) && validateOptionalProperties(data);\n}\n\nfunction isSavedPanelDataArray(obj: unknown): obj is ResizableStoragePanelData[] {\n return Array.isArray(obj) && obj.every(item => isSavedPanelData(item));\n}\n\n/**\n * Validates a stored panel size against container bounds and returns a safe value.\n *\n * Checks for non-finite values, negative values, and oversized values (> 2x container).\n */\nexport function validateStoredPanelSize(\n storedSize: number,\n containerSize: number,\n panelConfig: ResizablePanelConfig\n): number {\n if (!isFinite(storedSize) || storedSize < 0) {\n return parseSizeToPixels(panelConfig.initialSize || DEFAULT_PANEL_SIZE, containerSize);\n }\n\n if (containerSize > 0 && storedSize > containerSize * 2) {\n console.warn(\n `[resizable] Stored size ${storedSize}px for panel '${panelConfig.id}' exceeds 2x container (${containerSize}px). Resetting.`\n );\n return parseSizeToPixels(panelConfig.initialSize || DEFAULT_PANEL_SIZE, containerSize);\n }\n\n return storedSize;\n}\n\n// ============================================================================\n// LOCALSTORAGE ADAPTER\n// ============================================================================\n\n/**\n * Create a localStorage-backed storage adapter.\n *\n * @param key - The localStorage key to use\n * @returns A ResizableStorageAdapter backed by localStorage\n *\n * @example\n * ```vue\n * <dt-resizable :storage=\"localStorageAdapter('my-layout')\">\n * ```\n */\nexport function localStorageAdapter(key: string): ResizableStorageAdapter {\n return {\n save(data: ResizableStoragePanelData[]): void {\n try {\n localStorage.setItem(key, JSON.stringify(data));\n } catch (error) {\n console.error('[resizable] Failed to save to localStorage:', error);\n }\n },\n\n load(): ResizableStoragePanelData[] | null {\n try {\n const saved = localStorage.getItem(key);\n if (!saved) return null;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(saved);\n } catch {\n localStorage.removeItem(key);\n return null;\n }\n\n if (!isSavedPanelDataArray(parsed)) {\n localStorage.removeItem(key);\n return null;\n }\n\n return parsed;\n } catch (error) {\n console.warn('[resizable] Failed to load from localStorage:', error);\n try { localStorage.removeItem(key); } catch { /* ignore */ }\n return null;\n }\n },\n\n clear(): void {\n try {\n localStorage.removeItem(key);\n } catch { /* ignore */ }\n },\n };\n}\n\n// ============================================================================\n// COMPOSABLE\n// ============================================================================\n\n/**\n * Composable for managing panel persistence.\n *\n * Accepts either a storageKey (string → auto-creates localStorage adapter)\n * or a custom ResizableStorageAdapter. The adapter takes precedence.\n *\n * @param storageKeyOrAdapter - localStorage key string, or null\n * @param customAdapter - Optional custom adapter (overrides storageKey)\n */\nexport function useResizableStorage(\n storageKeyOrAdapter: string | null,\n customAdapter?: ResizableStorageAdapter\n) {\n // Resolve the adapter: custom > storageKey > null\n const adapter: ResizableStorageAdapter | null = customAdapter\n ?? (storageKeyOrAdapter ? localStorageAdapter(storageKeyOrAdapter) : null);\n\n function saveToStorage(panels: ResizableStoragePanelData[] | ResizablePanelState[]): void {\n if (!adapter) return;\n\n const data: ResizableStoragePanelData[] = panels.map(panel => ({\n id: panel.id,\n pixelSize: panel.pixelSize,\n collapsed: panel.collapsed,\n autoCollapsed: panel.autoCollapsed,\n }));\n\n adapter.save(data);\n }\n\n function loadFromStorage(): ResizableStoragePanelData[] | null {\n if (!adapter) return null;\n\n const data = adapter.load();\n if (!data) return null;\n\n // Re-validate even if adapter returned data (defense in depth)\n if (!isSavedPanelDataArray(data)) {\n adapter.clear();\n return null;\n }\n\n return data;\n }\n\n function restorePanelFromStorage(panel: ResizablePanelState, savedPanel: ResizableStoragePanelData): void {\n if (panel.restoredFromStorage) return;\n\n if (savedPanel.pixelSize !== undefined) {\n panel.pixelSize = savedPanel.pixelSize;\n }\n if (savedPanel.collapsed !== undefined) {\n panel.collapsed = savedPanel.collapsed;\n }\n if (savedPanel.autoCollapsed !== undefined) {\n panel.autoCollapsed = savedPanel.autoCollapsed;\n }\n\n panel.restoredFromStorage = true;\n }\n\n function loadFromStorageWithValidation(panels: ResizablePanelState[]): boolean {\n const savedState = loadFromStorage();\n if (!savedState) return false;\n\n const currentPanelIds = new Set(panels.map(p => p.id));\n const savedPanelIds = new Set(savedState.map(p => p.id));\n\n // Clear if current panels don't all exist in saved (panel config changed)\n const hasIncompatiblePanels = Array.from(currentPanelIds).some(id => !savedPanelIds.has(id));\n if (hasIncompatiblePanels) {\n if (adapter) adapter.clear();\n return false;\n }\n\n savedState.forEach(savedPanel => {\n const panel = panels.find(p => p.id === savedPanel.id);\n if (panel) restorePanelFromStorage(panel, savedPanel);\n });\n\n return true;\n }\n\n function clearStorage(): void {\n if (adapter) adapter.clear();\n }\n\n return {\n saveToStorage,\n loadFromStorage,\n loadFromStorageWithValidation,\n restorePanelFromStorage,\n clearStorage,\n };\n}\n","/**\n * useResizableGroup — Reactive Layout Controller\n *\n * Replaces the timer-based initialization flow with a Vue `computed` that\n * re-runs synchronously whenever panels or the container size change.\n *\n * Responsibilities:\n * - Maintain the `registeredPanels` ref (panels call registerPanel/unregisterPanel)\n * - Track `containerSize` via a ResizeObserver (set up here, not in the component)\n * - Load saved state ONCE synchronously at creation time\n * - Expose `layout` computed that calls `computeLayout()`\n * - Expose `syncedPanels` computed that converts `LayoutResult` to `ResizablePanelState[]`\n *\n * @see computeLayout.ts — the pure layout engine\n */\n\nimport { ref, computed, watch, nextTick, type ComputedRef, type Ref } from 'vue';\nimport type { ResizablePanelConfig, ResizablePanelState, ResizableDirection } from '../resizable_constants';\nimport type { LayoutResult } from './computeLayout';\nimport type { SavedPanelData } from './useResizableStorage';\nimport { computeLayout } from './computeLayout';\nimport { useResizableStorage } from './useResizableStorage';\nimport { calculateConstraintHierarchy } from './constraintResolver';\nimport { validateContainerSize } from '../resizable_utils';\n\n// ============================================================================\n// OPTIONS\n// ============================================================================\n\nexport interface UseResizableGroupOptions {\n storageKey: string | null;\n direction: ComputedRef<ResizableDirection>;\n containerRef: Ref<HTMLElement | null>;\n /** Custom storage adapter. Overrides storageKey when provided. */\n storageAdapter?: import('../resizable_constants').ResizableStorageAdapter;\n}\n\n// ============================================================================\n// HELPERS\n// ============================================================================\n\n/** Clamp a container dimension to a valid range. Delegates to the shared utility. */\nconst clampContainerSize = validateContainerSize;\n\n/**\n * Build a `ResizablePanelState` from a panel config + the layout result.\n * Reads `manualTargetRatio` and `autoCollapsed` from savedState.\n */\nfunction buildPanelState(\n config: ResizablePanelConfig,\n containerSize: number,\n layoutResult: LayoutResult,\n saved: SavedPanelData | undefined\n): ResizablePanelState {\n const position = layoutResult.panels.get(config.id);\n const constraints = position?.constraints ?? calculateConstraintHierarchy(config, containerSize);\n\n const pixelSize = position?.width ?? 0;\n const collapsed = position?.collapsed ?? Boolean(config.collapsed);\n\n return {\n ...config,\n pixelSize,\n collap