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.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 new file mode 100644 index 000000000..0011fcdba --- /dev/null +++ b/src/components/Resizable/ResizableHandle.vue @@ -0,0 +1,337 @@ + + + + + diff --git a/src/components/Resizable/ResizablePanel.vue b/src/components/Resizable/ResizablePanel.vue new file mode 100644 index 000000000..7f1d6883d --- /dev/null +++ b/src/components/Resizable/ResizablePanel.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/src/components/Resizable/ResizableRoot.vue b/src/components/Resizable/ResizableRoot.vue new file mode 100644 index 000000000..89c25a144 --- /dev/null +++ b/src/components/Resizable/ResizableRoot.vue @@ -0,0 +1,229 @@ + + + + + diff --git a/src/components/Resizable/index.ts b/src/components/Resizable/index.ts new file mode 100644 index 000000000..8420783bc --- /dev/null +++ b/src/components/Resizable/index.ts @@ -0,0 +1,15 @@ +export { default as Resizable } from './Resizable.vue' +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/stories/Collapsible.vue b/src/components/Resizable/stories/Collapsible.vue new file mode 100644 index 000000000..c6443190d --- /dev/null +++ b/src/components/Resizable/stories/Collapsible.vue @@ -0,0 +1,68 @@ + + + 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 new file mode 100644 index 000000000..aa27f52d1 --- /dev/null +++ b/src/components/Resizable/types.ts @@ -0,0 +1,133 @@ +import type { Slots } from 'vue' + +export type ResizableDirection = 'horizontal' | 'vertical' + +export interface ResizableRootProps { + /** Resize axis direction */ + 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[] + /** 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 + (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 */ + 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 + /** 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 { + id: string + size: number + minSize: number + maxSize: number + collapsible: boolean + collapsedSize: number + order: number + grow: boolean + resizable: boolean + element?: HTMLElement + defaultSize?: number +} + +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 +} + +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 new file mode 100644 index 000000000..a2b0a126a --- /dev/null +++ b/src/components/Resizable/utils.ts @@ -0,0 +1,170 @@ +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 distributeSizes( + totalSize: number, + panels: Array<{ minSize: number; maxSize: number; defaultSize?: number; grow?: boolean }>, + currentSizes?: number[] +): number[] { + const sizes: number[] = [] + let remaining = 100 + + 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) { + size = 100 / panels.length + } + + size = clamp(size, panel.minSize, panel.maxSize) + sizes.push(size) + remaining -= size + } + + 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 + } + } + } + + 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] + + 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 + } + } + } + + 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 + } + } + } + + 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] = finalLeft + newSizes[index + 1] = totalSize - finalLeft + + return newSizes +} diff --git a/src/index.ts b/src/index.ts index 597c8c8c8..aa3ee7858 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/Slider'