import { css, html } from "lit"; import { BaseElement } from "../../core/base-element.ts"; /** * @fileoverview UI Resizable Panel Group Component - Container for resizable panels * * @module ct-resizable-panel-group * @description * A container component that manages multiple resizable panels with draggable handles between them. * Works in conjunction with ct-resizable-panel and ct-resizable-handle components to create * flexible layouts where users can adjust panel sizes by dragging dividers. * * @example * ```html * * * Left panel content * * * * Right panel content * * * ``` */ export type PanelGroupDirection = "horizontal" | "vertical"; interface PanelInfo { element: HTMLElement; minSize: number; maxSize: number; defaultSize: number; currentSize: number; } /** * CTResizablePanelGroup manages a collection of resizable panels separated by draggable handles. * * @tag ct-resizable-panel-group * @extends BaseElement * * @property {PanelGroupDirection} direction - Layout direction ("horizontal" | "vertical") * * @attribute {string} direction - Sets the layout direction for panels * * @event {CustomEvent} ct-resize - Fired when panels are resized * @detail {Object} detail - Event detail object * @detail {Array<{element: HTMLElement, size: number}>} detail.panels - Array of panel elements with their current sizes * * @slot default - Container for ct-resizable-panel and ct-resizable-handle elements * * @csspart panel-group - The main container element */ export class CTResizablePanelGroup extends BaseElement { static override properties = { direction: { type: String }, }; declare direction: PanelGroupDirection; static override styles = css` :host { display: block; width: 100%; height: 100%; position: relative; } .panel-group { display: flex; width: 100%; height: 100%; position: relative; } .panel-group.direction-horizontal { flex-direction: row; } .panel-group.direction-vertical { flex-direction: column; } :host(.resizing) { user-select: none; } :host(.resizing) * { pointer-events: none; } :host(.resizing) ::slotted(ct-resizable-handle) { pointer-events: auto; } ::slotted(ct-resizable-panel) { overflow: hidden; position: relative; } ::slotted(ct-resizable-handle) { flex-shrink: 0; z-index: 10; } /* Horizontal layout */ .panel-group.direction-horizontal ::slotted(ct-resizable-panel) { height: 100%; } .panel-group.direction-horizontal ::slotted(ct-resizable-handle) { width: 6px; height: 100%; cursor: col-resize; } /* Vertical layout */ .panel-group.direction-vertical ::slotted(ct-resizable-panel) { width: 100%; } .panel-group.direction-vertical ::slotted(ct-resizable-handle) { width: 100%; height: 6px; cursor: row-resize; } `; constructor() { super(); this.direction = "horizontal"; } private _panels: Map = new Map(); private _handles: HTMLElement[] = []; private _activeHandle: HTMLElement | null = null; private _startPosition = 0; private _startSizes: number[] = []; private _observer: MutationObserver | null = null; override connectedCallback() { super.connectedCallback(); // Set up mutation observer to watch for panel changes this._observer = new MutationObserver(() => this.updatePanels()); this._observer.observe(this, { childList: true }); // Initial panel setup this.updatePanels(); } override disconnectedCallback() { super.disconnectedCallback(); // Clean up observer this._observer?.disconnect(); // Clean up event listeners this._handles.forEach((handle) => { handle.removeEventListener("mousedown", this.handleMouseDown); handle.removeEventListener("touchstart", this.handleTouchStart); handle.removeEventListener( "ct-handle-adjust", this.handleAdjust as EventListener, ); }); } override render() { return html`
`; } private updatePanels(): void { this._panels.clear(); this._handles = []; const panels = Array.from(this.querySelectorAll("ct-resizable-panel")); const handles = Array.from(this.querySelectorAll("ct-resizable-handle")); // Store panel information panels.forEach((panel) => { const minSize = panel.getAttribute("min-size") ? parseFloat(panel.getAttribute("min-size")!) : 0; const maxSize = panel.getAttribute("max-size") ? parseFloat(panel.getAttribute("max-size")!) : 100; const defaultSize = panel.getAttribute("default-size") ? parseFloat(panel.getAttribute("default-size")!) : 50; this._panels.set(panel as HTMLElement, { element: panel as HTMLElement, minSize, maxSize, defaultSize, currentSize: defaultSize, }); }); // Set up handle event listeners handles.forEach((handle) => { this._handles.push(handle as HTMLElement); (handle as HTMLElement).addEventListener( "mousedown", this.handleMouseDown, ); (handle as HTMLElement).addEventListener( "touchstart", this.handleTouchStart, ); handle.addEventListener( "ct-handle-adjust", this.handleAdjust as EventListener, ); // Set orientation on handle (handle as HTMLElement).setAttribute("data-orientation", this.direction); }); // Apply initial sizes this.applyPanelSizes(); } private handleMouseDown = (e: MouseEvent): void => { e.preventDefault(); this.startResize(e.target as HTMLElement, e.clientX, e.clientY); document.addEventListener("mousemove", this.handleMouseMove); document.addEventListener("mouseup", this.handleMouseUp); }; private handleTouchStart = (e: TouchEvent): void => { e.preventDefault(); const touch = e.touches[0]; this.startResize(e.target as HTMLElement, touch.clientX, touch.clientY); document.addEventListener("touchmove", this.handleTouchMove); document.addEventListener("touchend", this.handleTouchEnd); }; private handleMouseMove = (e: MouseEvent): void => { this.resize(e.clientX, e.clientY); }; private handleTouchMove = (e: TouchEvent): void => { const touch = e.touches[0]; this.resize(touch.clientX, touch.clientY); }; private handleMouseUp = (): void => { this.endResize(); document.removeEventListener("mousemove", this.handleMouseMove); document.removeEventListener("mouseup", this.handleMouseUp); }; private handleTouchEnd = (): void => { this.endResize(); document.removeEventListener("touchmove", this.handleTouchMove); document.removeEventListener("touchend", this.handleTouchEnd); }; private startResize(handle: HTMLElement, x: number, y: number): void { this._activeHandle = handle; this._startPosition = this.direction === "horizontal" ? x : y; // Store current sizes this._startSizes = Array.from(this._panels.values()).map((info) => info.currentSize ); // Add resizing class this.classList.add("resizing"); } private resize(x: number, y: number): void { if (!this._activeHandle) return; const handleIndex = this._handles.indexOf(this._activeHandle); if (handleIndex === -1) return; const panels = Array.from(this._panels.values()); const totalSize = this.direction === "horizontal" ? this.offsetWidth : this.offsetHeight; const currentPosition = this.direction === "horizontal" ? x : y; const delta = currentPosition - this._startPosition; const deltaPercent = (delta / totalSize) * 100; // Update sizes of panels adjacent to the handle const leftPanel = panels[handleIndex]; const rightPanel = panels[handleIndex + 1]; if (leftPanel && rightPanel) { const newLeftSize = Math.max( leftPanel.minSize, Math.min( leftPanel.maxSize, this._startSizes[handleIndex] + deltaPercent, ), ); const newRightSize = Math.max( rightPanel.minSize, Math.min( rightPanel.maxSize, this._startSizes[handleIndex + 1] - deltaPercent, ), ); // Ensure the total size remains constant const totalNewSize = newLeftSize + newRightSize; const totalOldSize = this._startSizes[handleIndex] + this._startSizes[handleIndex + 1]; if (Math.abs(totalNewSize - totalOldSize) < 0.1) { leftPanel.currentSize = newLeftSize; rightPanel.currentSize = newRightSize; this.applyPanelSizes(); // Emit resize event this.emit("ct-resize", { panels: panels.map((p) => ({ element: p.element, size: p.currentSize, })), }); } } } private endResize(): void { this._activeHandle = null; this.classList.remove("resizing"); } private handleAdjust = (e: CustomEvent<{ delta: number }>): void => { const handle = e.target as HTMLElement; const handleIndex = this._handles.indexOf(handle); if (handleIndex === -1) return; const panels = Array.from(this._panels.values()); const leftPanel = panels[handleIndex]; const rightPanel = panels[handleIndex + 1]; if (leftPanel && rightPanel) { const delta = e.detail.delta; const adjustAmount = Math.min(Math.abs(delta), 5) * Math.sign(delta); const newLeftSize = Math.max( leftPanel.minSize, Math.min(leftPanel.maxSize, leftPanel.currentSize + adjustAmount), ); const newRightSize = Math.max( rightPanel.minSize, Math.min(rightPanel.maxSize, rightPanel.currentSize - adjustAmount), ); // Ensure the total size remains constant const totalNewSize = newLeftSize + newRightSize; const totalOldSize = leftPanel.currentSize + rightPanel.currentSize; if (Math.abs(totalNewSize - totalOldSize) < 0.1) { leftPanel.currentSize = newLeftSize; rightPanel.currentSize = newRightSize; this.applyPanelSizes(); // Update ARIA value on handle const percentage = Math.round( (leftPanel.currentSize / (leftPanel.currentSize + rightPanel.currentSize)) * 100, ); handle.setAttribute("aria-valuenow", percentage.toString()); // Emit resize event this.emit("ct-resize", { panels: panels.map((p) => ({ element: p.element, size: p.currentSize, })), }); } } }; private applyPanelSizes(): void { const panels = Array.from(this._panels.values()); const dimension = this.direction === "horizontal" ? "width" : "height"; panels.forEach((panel) => { panel.element.style[dimension] = `${panel.currentSize}%`; }); } } globalThis.customElements.define( "ct-resizable-panel-group", CTResizablePanelGroup, );