From 44becabfe9e0717f97ec6ad409d6da0f757cac35 Mon Sep 17 00:00:00 2001 From: LEO DANIEL A Date: Wed, 14 Jan 2026 10:40:38 +0530 Subject: [PATCH 1/2] feat: add resizable layout primitives --- src/components/Resizable/Resizable.story.vue | 258 +++++++++++++++++ src/components/Resizable/ResizableHandle.vue | 274 +++++++++++++++++++ src/components/Resizable/ResizablePanel.vue | 111 ++++++++ src/components/Resizable/ResizableRoot.vue | 209 ++++++++++++++ src/components/Resizable/index.ts | 14 + src/components/Resizable/types.ts | 103 +++++++ src/components/Resizable/utils.ts | 140 ++++++++++ src/index.ts | 1 + 8 files changed, 1110 insertions(+) create mode 100644 src/components/Resizable/Resizable.story.vue create mode 100644 src/components/Resizable/ResizableHandle.vue create mode 100644 src/components/Resizable/ResizablePanel.vue create mode 100644 src/components/Resizable/ResizableRoot.vue create mode 100644 src/components/Resizable/index.ts create mode 100644 src/components/Resizable/types.ts create mode 100644 src/components/Resizable/utils.ts diff --git a/src/components/Resizable/Resizable.story.vue b/src/components/Resizable/Resizable.story.vue new file mode 100644 index 000000000..75291db8c --- /dev/null +++ b/src/components/Resizable/Resizable.story.vue @@ -0,0 +1,258 @@ + + + diff --git a/src/components/Resizable/ResizableHandle.vue b/src/components/Resizable/ResizableHandle.vue new file mode 100644 index 000000000..6970d4e6b --- /dev/null +++ b/src/components/Resizable/ResizableHandle.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/src/components/Resizable/ResizablePanel.vue b/src/components/Resizable/ResizablePanel.vue new file mode 100644 index 000000000..a28cb8528 --- /dev/null +++ b/src/components/Resizable/ResizablePanel.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/src/components/Resizable/ResizableRoot.vue b/src/components/Resizable/ResizableRoot.vue new file mode 100644 index 000000000..1051b1a5a --- /dev/null +++ b/src/components/Resizable/ResizableRoot.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/src/components/Resizable/index.ts b/src/components/Resizable/index.ts new file mode 100644 index 000000000..b1a236b28 --- /dev/null +++ b/src/components/Resizable/index.ts @@ -0,0 +1,14 @@ +export { default as ResizableRoot } from './ResizableRoot.vue' +export { default as ResizablePanel } from './ResizablePanel.vue' +export { default as ResizableHandle } from './ResizableHandle.vue' + +export type { + ResizableRootProps, + ResizableRootEmits, + ResizablePanelProps, + ResizablePanelEmits, + ResizableHandleProps, + ResizableDirection, + PanelData, + ResizableContext, +} from './types' diff --git a/src/components/Resizable/types.ts b/src/components/Resizable/types.ts new file mode 100644 index 000000000..59171d71f --- /dev/null +++ b/src/components/Resizable/types.ts @@ -0,0 +1,103 @@ +export type ResizableDirection = 'horizontal' | 'vertical' + +export interface ResizableRootProps { + /** Resize axis direction */ + direction?: ResizableDirection + /** Render element or component */ + as?: string + /** Controlled panel sizes (%) */ + modelValue?: number[] + /** Initial sizes for uncontrolled mode (%) */ + defaultValue?: number[] + /** Persist sizes to localStorage */ + storageKey?: string + /** Sync sizes across multiple roots */ + syncId?: string + /** Disable all resizing */ + disabled?: boolean + /** Reverse drag direction */ + reverse?: boolean + /** RTL support */ + rtl?: boolean +} + +export interface ResizableRootEmits { + (e: 'update:modelValue', sizes: number[]): void + (e: 'resizeStart', payload: { index: number }): void + (e: 'resize', payload: { sizes: number[] }): void + (e: 'resizeEnd', payload: { sizes: number[] }): void +} + +export interface ResizablePanelProps { + /** Stable panel identity */ + id?: string + /** Render element or component */ + as?: string + /** Minimum size in percentage */ + minSize?: number + /** Maximum size in percentage */ + maxSize?: number + /** Initial size in percentage */ + defaultSize?: number + /** Allow panel to collapse */ + collapsible?: boolean + /** Size when collapsed in percentage */ + collapsedSize?: number + /** Panel order for dynamic layouts */ + order?: number + /** Fill remaining space */ + grow?: boolean + /** Disable resize for this panel */ + resizable?: boolean +} + +export interface ResizablePanelEmits { + (e: 'collapse'): void + (e: 'expand'): void +} + +export interface ResizableHandleProps { + /** Panel boundary index */ + index?: number + /** Render element or component */ + as?: string + /** Disable this handle */ + disabled?: boolean + /** Invisible drag area in pixels */ + hitArea?: number + /** Custom cursor style */ + cursor?: string + /** Accessibility label */ + ariaLabel?: string + /** Arrow key resize step in percentage */ + keyboardStep?: number +} + +export interface PanelData { + id: string + size: number + minSize: number + maxSize: number + collapsible: boolean + collapsedSize: number + order: number + grow: boolean + resizable: boolean + element?: HTMLElement +} + +export interface ResizableContext { + direction: ResizableDirection + disabled: boolean + reverse: boolean + rtl: boolean + panels: Map + registerPanel: (id: string, data: PanelData) => void + unregisterPanel: (id: string) => void + updatePanelSize: (id: string, size: number) => void + getPanelSize: (id: string) => number + startResize: (index: number) => void + resize: (delta: number) => void + endResize: () => void + isResizing: boolean +} diff --git a/src/components/Resizable/utils.ts b/src/components/Resizable/utils.ts new file mode 100644 index 000000000..524136150 --- /dev/null +++ b/src/components/Resizable/utils.ts @@ -0,0 +1,140 @@ +import { InjectionKey } from 'vue' +import type { ResizableContext } from './types' + +export const RESIZABLE_CONTEXT_KEY: InjectionKey = Symbol('resizable-context') + +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max) +} + +export function getStorageKey(key: string): string { + return `resizable-${key}` +} + +export function loadSizesFromStorage(key: string): number[] | null { + if (typeof window === 'undefined') return null + + try { + const stored = localStorage.getItem(getStorageKey(key)) + if (stored) { + const parsed = JSON.parse(stored) + if (Array.isArray(parsed) && parsed.every(n => typeof n === 'number')) { + return parsed + } + } + } catch (e) { + console.warn('Failed to load resizable sizes from storage:', e) + } + + return null +} + +export function saveSizesToStorage(key: string, sizes: number[]): void { + if (typeof window === 'undefined') return + + try { + localStorage.setItem(getStorageKey(key), JSON.stringify(sizes)) + } catch (e) { + console.warn('Failed to save resizable sizes to storage:', e) + } +} + +export function distributeSizes( + totalSize: number, + panels: Array<{ minSize: number; maxSize: number; defaultSize?: number; grow?: boolean }>, + currentSizes?: number[] +): number[] { + const sizes: number[] = [] + let remaining = 100 + + // First pass: assign default or current sizes + for (let i = 0; i < panels.length; i++) { + const panel = panels[i] + let size = currentSizes?.[i] ?? panel.defaultSize ?? 0 + + if (size === 0 && panels.length > 0) { + // Auto-distribute if no size specified + size = 100 / panels.length + } + + size = clamp(size, panel.minSize, panel.maxSize) + sizes.push(size) + remaining -= size + } + + // Second pass: distribute remaining space to grow panels + if (remaining !== 0) { + const growPanels = panels + .map((p, i) => ({ ...p, index: i })) + .filter(p => p.grow) + + if (growPanels.length > 0) { + const perPanel = remaining / growPanels.length + + for (const panel of growPanels) { + const newSize = clamp( + sizes[panel.index] + perPanel, + panel.minSize, + panel.maxSize + ) + sizes[panel.index] = newSize + } + } + } + + // Normalize to ensure total is 100% + const total = sizes.reduce((sum, size) => sum + size, 0) + if (total !== 100 && total > 0) { + return sizes.map(size => (size / total) * 100) + } + + return sizes +} + +export function adjustSizes( + sizes: number[], + index: number, + delta: number, + panels: Array<{ minSize: number; maxSize: number; collapsible: boolean; collapsedSize: number }> +): number[] { + const newSizes = [...sizes] + + if (index < 0 || index >= newSizes.length - 1) { + return newSizes + } + + const leftPanel = panels[index] + const rightPanel = panels[index + 1] + + let leftSize = newSizes[index] + delta + let rightSize = newSizes[index + 1] - delta + + // Handle collapsing + if (leftPanel.collapsible && leftSize < leftPanel.minSize) { + leftSize = leftPanel.collapsedSize + rightSize = newSizes[index] + newSizes[index + 1] - leftSize + } + + if (rightPanel.collapsible && rightSize < rightPanel.minSize) { + rightSize = rightPanel.collapsedSize + leftSize = newSizes[index] + newSizes[index + 1] - rightSize + } + + // Apply constraints + leftSize = clamp(leftSize, leftPanel.minSize, leftPanel.maxSize) + rightSize = clamp(rightSize, rightPanel.minSize, rightPanel.maxSize) + + // Ensure total size is preserved + const totalChange = (leftSize - newSizes[index]) + (rightSize - newSizes[index + 1]) + if (Math.abs(totalChange) > 0.01) { + // Adjust to maintain total + const adjustment = totalChange / 2 + leftSize -= adjustment + rightSize -= adjustment + } + + newSizes[index] = leftSize + newSizes[index + 1] = rightSize + + return newSizes +} diff --git a/src/index.ts b/src/index.ts index c7475dbec..f42cd6573 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ export { default as LoadingText } from './components/LoadingText.vue' export * from './components/Progress' export * from './components/Popover' export * from './components/Rating' +export * from './components/Resizable' export { default as Resource } from './components/Resource.vue' export * from './components/Select' export * from './components/Password' From bf6660d9f6c8f55aa9ac00074d3ee8aaf44e9ccb Mon Sep 17 00:00:00 2001 From: LEO DANIEL A Date: Tue, 3 Feb 2026 23:49:07 +0530 Subject: [PATCH 2/2] Feat: Update the story file and add .md file for documentation --- docs/content/docs/components/resizable.md | 35 +++ src/components/Resizable/Resizable.story.vue | 258 ------------------ src/components/Resizable/Resizable.vue | 100 +++++++ src/components/Resizable/ResizableHandle.vue | 121 ++++++-- src/components/Resizable/ResizablePanel.vue | 64 ++++- src/components/Resizable/ResizableRoot.vue | 60 ++-- src/components/Resizable/index.ts | 1 + .../Resizable/stories/Collapsible.vue | 68 +++++ .../Resizable/stories/CustomContainer.vue | 252 +++++++++++++++++ src/components/Resizable/stories/Examples.vue | 93 +++++++ .../Resizable/stories/NestedLayout.vue | 61 +++++ .../Resizable/stories/Reorderable.vue | 124 +++++++++ src/components/Resizable/types.ts | 34 ++- src/components/Resizable/utils.ts | 150 ++++++---- 14 files changed, 1044 insertions(+), 377 deletions(-) create mode 100644 docs/content/docs/components/resizable.md delete mode 100644 src/components/Resizable/Resizable.story.vue create mode 100644 src/components/Resizable/Resizable.vue create mode 100644 src/components/Resizable/stories/Collapsible.vue create mode 100644 src/components/Resizable/stories/CustomContainer.vue create mode 100644 src/components/Resizable/stories/Examples.vue create mode 100644 src/components/Resizable/stories/NestedLayout.vue create mode 100644 src/components/Resizable/stories/Reorderable.vue diff --git a/docs/content/docs/components/resizable.md b/docs/content/docs/components/resizable.md new file mode 100644 index 000000000..83965849c --- /dev/null +++ b/docs/content/docs/components/resizable.md @@ -0,0 +1,35 @@ +# Resizable + +Provides a flexible system for creating resizable panel layouts. Supports horizontal and vertical stacking, collapsible panels, and drag-and-drop reordering, making it easy to build complex, adjustable interfaces. + + +## Basic Usage + +You can use the `Resizable` component directly with a `panels` prop for a config-driven approach. + + + + +## Collapsible Panels + +Panels can be made collapsible when resized beyond a certain threshold. + + + +## Nested Layouts + +Resizable groups can be nested to create complex layouts. + + + +## Reorderable Panels + +Panels can be reordered by dragging and dropping them. + + + +## Custom Container Slot + +You can provide a custom container for the resizable groups. + + diff --git a/src/components/Resizable/Resizable.story.vue b/src/components/Resizable/Resizable.story.vue deleted file mode 100644 index 75291db8c..000000000 --- a/src/components/Resizable/Resizable.story.vue +++ /dev/null @@ -1,258 +0,0 @@ - - - diff --git a/src/components/Resizable/Resizable.vue b/src/components/Resizable/Resizable.vue new file mode 100644 index 000000000..4c32d3101 --- /dev/null +++ b/src/components/Resizable/Resizable.vue @@ -0,0 +1,100 @@ + + + diff --git a/src/components/Resizable/ResizableHandle.vue b/src/components/Resizable/ResizableHandle.vue index 6970d4e6b..0011fcdba 100644 --- a/src/components/Resizable/ResizableHandle.vue +++ b/src/components/Resizable/ResizableHandle.vue @@ -4,26 +4,35 @@ ref="handleRef" :class="handleClasses" :style="handleStyle" + v-bind="$attrs" :data-disabled="isDisabled" :aria-label="ariaLabel || `Resize handle ${index}`" :aria-orientation="context.direction === 'horizontal' ? 'vertical' : 'horizontal'" role="separator" tabindex="0" - @mousedown="handleMouseDown" - @touchstart="handleTouchStart" @keydown="handleKeyDown" > -
+
+
+ + +
+
+ + diff --git a/src/components/Resizable/stories/CustomContainer.vue b/src/components/Resizable/stories/CustomContainer.vue new file mode 100644 index 000000000..30642c147 --- /dev/null +++ b/src/components/Resizable/stories/CustomContainer.vue @@ -0,0 +1,252 @@ + + + diff --git a/src/components/Resizable/stories/Examples.vue b/src/components/Resizable/stories/Examples.vue new file mode 100644 index 000000000..ab760b1b2 --- /dev/null +++ b/src/components/Resizable/stories/Examples.vue @@ -0,0 +1,93 @@ + + + diff --git a/src/components/Resizable/stories/NestedLayout.vue b/src/components/Resizable/stories/NestedLayout.vue new file mode 100644 index 000000000..46021cd98 --- /dev/null +++ b/src/components/Resizable/stories/NestedLayout.vue @@ -0,0 +1,61 @@ + + + diff --git a/src/components/Resizable/stories/Reorderable.vue b/src/components/Resizable/stories/Reorderable.vue new file mode 100644 index 000000000..bcb2f9df4 --- /dev/null +++ b/src/components/Resizable/stories/Reorderable.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/src/components/Resizable/types.ts b/src/components/Resizable/types.ts index 59171d71f..aa27f52d1 100644 --- a/src/components/Resizable/types.ts +++ b/src/components/Resizable/types.ts @@ -1,3 +1,5 @@ +import type { Slots } from 'vue' + export type ResizableDirection = 'horizontal' | 'vertical' export interface ResizableRootProps { @@ -5,12 +7,12 @@ export interface ResizableRootProps { direction?: ResizableDirection /** Render element or component */ as?: string + /** Unique identifier (used as fallback for storage persistence) */ + id?: string /** Controlled panel sizes (%) */ modelValue?: number[] /** Initial sizes for uncontrolled mode (%) */ defaultValue?: number[] - /** Persist sizes to localStorage */ - storageKey?: string /** Sync sizes across multiple roots */ syncId?: string /** Disable all resizing */ @@ -26,11 +28,14 @@ export interface ResizableRootEmits { (e: 'resizeStart', payload: { index: number }): void (e: 'resize', payload: { sizes: number[] }): void (e: 'resizeEnd', payload: { sizes: number[] }): void + (e: 'reorder', payload: { fromIndex: number; toIndex: number; panels: ResizablePanelConfig[] }): void } export interface ResizablePanelProps { /** Stable panel identity */ id?: string + /** Optional label for custom container slots */ + label?: string /** Render element or component */ as?: string /** Minimum size in percentage */ @@ -71,6 +76,16 @@ export interface ResizableHandleProps { ariaLabel?: string /** Arrow key resize step in percentage */ keyboardStep?: number + /** Show visual handle grip */ + withHandle?: boolean + /** Enable drag-to-reorder panels */ + draggable?: boolean + /** Callback when drag starts */ + onDragStart?: (index: number) => void +} + +export interface ResizablePanelConfig extends ResizablePanelProps { + order?: number } export interface PanelData { @@ -84,6 +99,7 @@ export interface PanelData { grow: boolean resizable: boolean element?: HTMLElement + defaultSize?: number } export interface ResizableContext { @@ -101,3 +117,17 @@ export interface ResizableContext { endResize: () => void isResizing: boolean } + +export interface ResizableProviderContext { + panels: ResizablePanelConfig[] + hasPanels: boolean + rootProps: ResizableRootProps + slots: Slots + attrs: Record + listeners: { + 'update:modelValue': (sizes: number[]) => void + resizeStart: (payload: { index: number }) => void + resize: (payload: { sizes: number[] }) => void + resizeEnd: (payload: { sizes: number[] }) => void + } +} diff --git a/src/components/Resizable/utils.ts b/src/components/Resizable/utils.ts index 524136150..a2b0a126a 100644 --- a/src/components/Resizable/utils.ts +++ b/src/components/Resizable/utils.ts @@ -1,43 +1,14 @@ -import { InjectionKey } from 'vue' -import type { ResizableContext } from './types' +import { InjectionKey, type ComputedRef } from 'vue' +import type { ResizableContext, ResizableProviderContext } from './types' export const RESIZABLE_CONTEXT_KEY: InjectionKey = Symbol('resizable-context') +export const RESIZABLE_PROVIDER_KEY: InjectionKey> = + Symbol('resizable-provider') export function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max) } -export function getStorageKey(key: string): string { - return `resizable-${key}` -} - -export function loadSizesFromStorage(key: string): number[] | null { - if (typeof window === 'undefined') return null - - try { - const stored = localStorage.getItem(getStorageKey(key)) - if (stored) { - const parsed = JSON.parse(stored) - if (Array.isArray(parsed) && parsed.every(n => typeof n === 'number')) { - return parsed - } - } - } catch (e) { - console.warn('Failed to load resizable sizes from storage:', e) - } - - return null -} - -export function saveSizesToStorage(key: string, sizes: number[]): void { - if (typeof window === 'undefined') return - - try { - localStorage.setItem(getStorageKey(key), JSON.stringify(sizes)) - } catch (e) { - console.warn('Failed to save resizable sizes to storage:', e) - } -} export function distributeSizes( totalSize: number, @@ -47,13 +18,11 @@ export function distributeSizes( const sizes: number[] = [] let remaining = 100 - // First pass: assign default or current sizes for (let i = 0; i < panels.length; i++) { const panel = panels[i] let size = currentSizes?.[i] ?? panel.defaultSize ?? 0 if (size === 0 && panels.length > 0) { - // Auto-distribute if no size specified size = 100 / panels.length } @@ -62,7 +31,6 @@ export function distributeSizes( remaining -= size } - // Second pass: distribute remaining space to grow panels if (remaining !== 0) { const growPanels = panels .map((p, i) => ({ ...p, index: i })) @@ -82,7 +50,6 @@ export function distributeSizes( } } - // Normalize to ensure total is 100% const total = sizes.reduce((sum, size) => sum + size, 0) if (total !== 100 && total > 0) { return sizes.map(size => (size / total) * 100) @@ -91,6 +58,8 @@ export function distributeSizes( return sizes } + + export function adjustSizes( sizes: number[], index: number, @@ -106,35 +75,96 @@ export function adjustSizes( const leftPanel = panels[index] const rightPanel = panels[index + 1] - let leftSize = newSizes[index] + delta - let rightSize = newSizes[index + 1] - delta - - // Handle collapsing - if (leftPanel.collapsible && leftSize < leftPanel.minSize) { - leftSize = leftPanel.collapsedSize - rightSize = newSizes[index] + newSizes[index + 1] - leftSize + const initialLeft = newSizes[index] + const initialRight = newSizes[index + 1] + const totalSize = initialLeft + initialRight + + let targetLeft = initialLeft + delta + + let useLeftCollapsed = false + if (leftPanel.collapsible) { + if (initialLeft <= leftPanel.collapsedSize) { + if (targetLeft > leftPanel.collapsedSize) { + if (targetLeft < leftPanel.minSize) { + if (delta > 0) { + useLeftCollapsed = false + const threshold = leftPanel.collapsedSize + (leftPanel.minSize - leftPanel.collapsedSize) * 0.05 + if (targetLeft > threshold) { + } else { + useLeftCollapsed = true + } + } else { + useLeftCollapsed = true + } + } + } else { + useLeftCollapsed = true + } + } else { + if (targetLeft < leftPanel.minSize) { + useLeftCollapsed = true + } + } } - if (rightPanel.collapsible && rightSize < rightPanel.minSize) { - rightSize = rightPanel.collapsedSize - leftSize = newSizes[index] + newSizes[index + 1] - rightSize + let useRightCollapsed = false + if (rightPanel.collapsible) { + const currentRight = totalSize - initialLeft + const targetRight = totalSize - targetLeft + + if (currentRight <= rightPanel.collapsedSize) { + if (targetRight > rightPanel.collapsedSize) { + if (targetRight < rightPanel.minSize) { + if (delta < 0) { + useRightCollapsed = false + const threshold = rightPanel.collapsedSize + (rightPanel.minSize - rightPanel.collapsedSize) * 0.05 + if (targetRight <= threshold) { + useRightCollapsed = true + } + } else { + useRightCollapsed = true + } + } + } else { + useRightCollapsed = true + } + } else { + if (totalSize - targetLeft < rightPanel.minSize) { + useRightCollapsed = true + } + } } - // Apply constraints - leftSize = clamp(leftSize, leftPanel.minSize, leftPanel.maxSize) - rightSize = clamp(rightSize, rightPanel.minSize, rightPanel.maxSize) - - // Ensure total size is preserved - const totalChange = (leftSize - newSizes[index]) + (rightSize - newSizes[index + 1]) - if (Math.abs(totalChange) > 0.01) { - // Adjust to maintain total - const adjustment = totalChange / 2 - leftSize -= adjustment - rightSize -= adjustment + let finalLeft = targetLeft + + const minLeft = leftPanel.minSize + const maxLeft = leftPanel.maxSize + const minLeftFromRight = totalSize - rightPanel.maxSize + const maxLeftFromRight = totalSize - rightPanel.minSize + + const constraintMin = Math.max(minLeft, minLeftFromRight) + const constraintMax = Math.min(maxLeft, maxLeftFromRight) + + if (useLeftCollapsed) { + finalLeft = leftPanel.collapsedSize + } else if (useRightCollapsed) { + const finalRight = rightPanel.collapsedSize + finalLeft = totalSize - finalRight + } else { + let effectiveMin = constraintMin + let effectiveMax = constraintMax + + if (leftPanel.collapsible && initialLeft <= leftPanel.collapsedSize && delta > 0) { + } + + if (rightPanel.collapsible && (totalSize - initialLeft) <= rightPanel.collapsedSize && delta < 0) { + } + + finalLeft = clamp(targetLeft, effectiveMin, effectiveMax) } - newSizes[index] = leftSize - newSizes[index + 1] = rightSize + newSizes[index] = finalLeft + newSizes[index + 1] = totalSize - finalLeft return newSizes }